Merge remote-tracking branch 'origin/develop' into feat-bluetooth

This commit is contained in:
Justin Mitchell
2026-07-14 04:20:50 -04:00
118 changed files with 22879 additions and 6746 deletions
+8
View File
@@ -224,6 +224,14 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
KOREADER_STORE.saveToFile();
},
"koMatchMethod", StrId::STR_KOREADER_SYNC),
SettingInfo::DynamicEnum(
StrId::STR_SEND_METADATA, {StrId::STR_STATE_OFF, StrId::STR_STATE_ON},
[] { return static_cast<uint8_t>(KOREADER_STORE.getSendMetadata()); },
[](uint8_t v) {
KOREADER_STORE.setSendMetadata(v != 0);
KOREADER_STORE.saveToFile();
},
"koSendMetadata", StrId::STR_KOREADER_SYNC),
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount,
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
+12 -6
View File
@@ -149,6 +149,10 @@ void SleepActivity::renderCustomSleepScreen() const {
renderDefaultSleepScreen();
}
// Sleep screens paint with a single HALF refresh (stock parity): the OEM X4
// firmware's only clean refresh in normal operation is the single-pass 0xD7
// sequence, used once for the sleep image. It never runs the multi-flash GC
// waveform (0xF7) that FULL_REFRESH selects (#2471's blinking complaint).
void SleepActivity::renderDefaultSleepScreen() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
@@ -163,7 +167,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.invertScreen();
}
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
@@ -219,11 +223,13 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
}
if (hasGreyscale) {
// OEM grayscale pipeline base: use a full sleep-screen paint so the panel
// enters deep sleep from a clean B/W baseline before the gray nudge refresh.
renderer.displayGrayscaleBase(HalDisplay::FULL_REFRESH);
// OEM grayscale pipeline base. Must stay HALF: the gray nudge LUT is
// calibrated against the pixel state the single-pass HALF waveform leaves
// behind. A FULL (GC) base parks pixels in a different charge state and
// the differential nudge then lands unevenly (blotchy noise in gray areas).
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
} else {
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
if (hasGreyscale) {
@@ -331,5 +337,5 @@ void SleepActivity::renderLastScreenSleepScreen() const {
void SleepActivity::renderBlankSleepScreen() const {
renderer.clearScreen();
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
@@ -1,5 +1,6 @@
#include "OpdsBookBrowserActivity.h"
#include <Arduino.h>
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
@@ -19,7 +20,9 @@
namespace {
constexpr int PAGE_ITEMS = 23;
}
constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5;
constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000;
} // namespace
void OpdsBookBrowserActivity::onEnter() {
Activity::onEnter();
@@ -193,7 +196,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
return;
}
std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(server.url, path);
std::string url = UrlUtils::buildUrl(server.url, path);
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
OpdsParser parser;
{
@@ -216,14 +219,19 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
searchTemplate = parser.getSearchTemplate();
const auto& nextUrl = parser.getNextPageUrl();
const auto& prevUrl = parser.getPrevPageUrl();
const bool feedTruncated = parser.truncated();
entries = std::move(parser).getEntries();
entries.reserve(entries.size() + (prevUrl.empty() ? 0 : 1) + (nextUrl.empty() ? 0 : 1));
if (!prevUrl.empty()) {
entries.insert(entries.begin(), OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", prevUrl, ""});
}
if (!nextUrl.empty()) {
entries.push_back(OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", nextUrl, ""});
}
if (feedTruncated) {
LOG_INF("OPDS", "Feed truncated to fit memory");
}
selectorIndex = 0;
state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING;
@@ -231,6 +239,8 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
requestUpdate();
}
void OpdsBookBrowserActivity::releaseEntries() { std::vector<OpdsEntry>().swap(entries); }
void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
navigationHistory.push_back(currentPath);
// Resolve to a full URL so sub-sub-navigation retains parent path context
@@ -239,7 +249,7 @@ void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
entries.clear();
releaseEntries();
selectorIndex = 0;
requestUpdate(true);
fetchFeed(currentPath);
@@ -253,7 +263,7 @@ void OpdsBookBrowserActivity::navigateBack() {
navigationHistory.pop_back();
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
entries.clear();
releaseEntries();
selectorIndex = 0;
requestUpdate();
fetchFeed(currentPath);
@@ -273,12 +283,22 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
"/" + StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) + ".epub";
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
int lastRenderedPercent = -1;
unsigned long lastProgressUpdateMs = 0;
const auto result = HttpDownloader::downloadToFile(
downloadUrl, filename,
[this](const size_t downloaded, const size_t total) {
[this, &lastRenderedPercent, &lastProgressUpdateMs](const size_t downloaded, const size_t total) {
downloadProgress = downloaded;
downloadTotal = total;
requestUpdate(true);
const int percent = total > 0 ? static_cast<int>(static_cast<uint64_t>(downloaded) * 100 / total) : 0;
const unsigned long now = millis();
if (percent >= 100 || lastRenderedPercent < 0 ||
percent >= lastRenderedPercent + DOWNLOAD_PROGRESS_STEP_PERCENT ||
now - lastProgressUpdateMs >= DOWNLOAD_PROGRESS_MIN_UPDATE_MS) {
lastRenderedPercent = percent;
lastProgressUpdateMs = now;
requestUpdate(true);
}
},
nullptr, server.username, server.password);
@@ -286,6 +306,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
clearBookCache(filename);
state = BrowserState::BROWSING;
} else {
LOG_ERR("OPDS", "Download failed: %d", static_cast<int>(result));
state = BrowserState::ERROR;
errorMessage = tr(STR_DOWNLOAD_FAILED);
}
@@ -340,6 +361,8 @@ void OpdsBookBrowserActivity::performSearch(const std::string& query) {
state = BrowserState::LOADING;
statusMessage = tr(STR_LOADING);
releaseEntries();
selectorIndex = 0;
requestUpdate(true);
fetchFeed(url);
}
@@ -46,6 +46,7 @@ class OpdsBookBrowserActivity final : public Activity {
void launchWifiSelection();
void onWifiSelectionComplete(bool connected);
void fetchFeed(const std::string& path);
void releaseEntries();
void navigateToEntry(const OpdsEntry& entry);
void navigateBack();
void downloadBook(const OpdsEntry& book);
+241 -36
View File
@@ -6,6 +6,8 @@
#include <Logging.h>
#include <WiFi.h>
#include <algorithm>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
@@ -27,6 +29,7 @@ void WifiSelectionActivity::onEnter() {
// Reset state
selectedNetworkIndex = 0;
networks.clear();
realNetworkCount = 0;
state = WifiSelectionState::SCANNING;
selectedSSID.clear();
connectedIP.clear();
@@ -36,6 +39,9 @@ void WifiSelectionActivity::onEnter() {
savePromptSelection = 0;
forgetPromptSelection = 0;
autoConnecting = false;
manualNetworkListRequested = false;
autoAttemptedSsids.clear();
autoAttemptedSsids.reserve(WIFI_STORE.getCredentials().size());
// Cache MAC address for display
uint8_t mac[6];
@@ -48,23 +54,20 @@ void WifiSelectionActivity::onEnter() {
// Trigger first update to show scanning message
requestUpdate();
// Attempt to auto-connect to the last network
if (allowAutoConnect) {
// Attempt to auto-connect to known networks. Try the last successful
// network first for speed, then scan and try any visible saved networks by
// signal strength. The user can interrupt this and show the scan result.
if (allowAutoConnect && !WIFI_STORE.getCredentials().empty()) {
const std::string lastSsid = WIFI_STORE.getLastConnectedSsid();
if (!lastSsid.empty()) {
const auto* cred = WIFI_STORE.findCredential(lastSsid);
if (cred) {
LOG_DBG("WIFI", "Attempting to auto-connect to %s", lastSsid.c_str());
selectedSSID = cred->ssid;
enteredPassword = cred->password;
selectedRequiresPassword = !cred->password.empty();
usedSavedPassword = true;
autoConnecting = true;
attemptConnection();
requestUpdate();
if (cred && tryAutoConnectCredential(*cred)) {
return;
}
}
startWifiScan(true);
return;
}
// Fallback to scanning
@@ -88,8 +91,9 @@ void WifiSelectionActivity::onExit() {
LOG_DBG("WIFI", "Free heap at onExit end: %d bytes", ESP.getFreeHeap());
}
void WifiSelectionActivity::startWifiScan() {
autoConnecting = false;
void WifiSelectionActivity::startWifiScan(const bool autoScan) {
autoConnecting = autoScan;
manualNetworkListRequested = false;
state = WifiSelectionState::SCANNING;
networks.clear();
requestUpdate();
@@ -117,7 +121,13 @@ void WifiSelectionActivity::processWifiScanResults() {
}
if (scanResult == WIFI_SCAN_FAILED) {
networks.clear();
realNetworkCount = 0;
appendHiddenNetworkEntry();
autoConnecting = false;
manualNetworkListRequested = false;
state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0;
requestUpdate();
return;
}
@@ -159,18 +169,46 @@ void WifiSelectionActivity::processWifiScanResults() {
return a.rssi > b.rssi;
});
realNetworkCount = networks.size();
appendHiddenNetworkEntry();
WiFi.scanDelete();
if (autoConnecting && !manualNetworkListRequested && tryNextSavedNetworkFromScan()) {
return;
}
autoConnecting = false;
manualNetworkListRequested = false;
state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0;
requestUpdate();
}
void WifiSelectionActivity::appendHiddenNetworkEntry() {
// Synthetic list entry that lets the user type an SSID that is not broadcast.
// ESP32 can join hidden APs as long as the SSID is supplied to WiFi.begin().
WifiNetworkInfo placeholder;
placeholder.rssi = 0;
placeholder.isEncrypted = true; // Treated as encrypted; an empty password still connects open APs
placeholder.hasSavedPassword = false;
placeholder.isHiddenPlaceholder = true;
networks.push_back(std::move(placeholder));
}
void WifiSelectionActivity::selectNetwork(const int index) {
if (index < 0 || index >= static_cast<int>(networks.size())) {
return;
}
const auto& network = networks[index];
// Synthetic "Add hidden network..." entry: prompt the user to type the SSID first
if (network.isHiddenPlaceholder) {
promptHiddenSsid();
return;
}
selectedSSID = network.ssid;
selectedRequiresPassword = network.isEncrypted;
usedSavedPassword = false;
@@ -189,27 +227,127 @@ void WifiSelectionActivity::selectNetwork(const int index) {
}
if (selectedRequiresPassword) {
// Show password entry
state = WifiSelectionState::PASSWORD_ENTRY;
// Don't allow screen updates while changing activity
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_ENTER_WIFI_PASSWORD),
"", // No initial text
64, // Max password length
InputType::Password),
[this](const ActivityResult& result) {
if (result.isCancelled) {
state = WifiSelectionState::NETWORK_LIST;
} else {
enteredPassword = std::get<KeyboardResult>(result.data).text;
// state will be updated in next loop iteration
}
});
promptPasswordEntry();
} else {
// Connect directly for open networks
attemptConnection();
}
}
void WifiSelectionActivity::promptPasswordEntry() {
// Show password entry
state = WifiSelectionState::PASSWORD_ENTRY;
// Don't allow screen updates while changing activity
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_ENTER_WIFI_PASSWORD),
"", // No initial text
64, // Max password length
InputType::Password),
[this](const ActivityResult& result) {
if (result.isCancelled) {
state = WifiSelectionState::NETWORK_LIST;
} else {
enteredPassword = std::get<KeyboardResult>(result.data).text;
// state will be updated in next loop iteration
}
});
}
void WifiSelectionActivity::promptHiddenSsid() {
selectedSSID.clear();
selectedRequiresPassword = true; // Hidden networks are usually encrypted; empty password still joins open APs
usedSavedPassword = false;
enteredPassword.clear();
autoConnecting = false;
// Suppress rendering during the activity transition (see render()).
state = WifiSelectionState::HIDDEN_SSID_ENTRY;
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_ENTER_WIFI_SSID),
"", // No initial text
32, // Max SSID length (IEEE 802.11: 32 bytes)
InputType::Text),
[this](const ActivityResult& result) {
if (result.isCancelled) {
state = WifiSelectionState::NETWORK_LIST;
return;
}
selectedSSID = std::get<KeyboardResult>(result.data).text;
if (selectedSSID.empty()) {
state = WifiSelectionState::NETWORK_LIST;
}
// Otherwise stay in HIDDEN_SSID_ENTRY; loop() continues the flow.
});
}
bool WifiSelectionActivity::hasAttemptedAutoSsid(const std::string& ssid) const {
return std::find(autoAttemptedSsids.begin(), autoAttemptedSsids.end(), ssid) != autoAttemptedSsids.end();
}
bool WifiSelectionActivity::tryAutoConnectCredential(const WifiCredential& cred) {
if (hasAttemptedAutoSsid(cred.ssid)) {
return false;
}
LOG_DBG("WIFI", "Attempting saved network: %s", cred.ssid.c_str());
autoAttemptedSsids.push_back(cred.ssid);
selectedSSID = cred.ssid;
enteredPassword = cred.password;
selectedRequiresPassword = !cred.password.empty();
usedSavedPassword = true;
autoConnecting = true;
manualNetworkListRequested = false;
attemptConnection();
requestUpdate();
return true;
}
bool WifiSelectionActivity::tryNextSavedNetworkFromScan() {
for (const auto& network : networks) {
if (!network.hasSavedPassword || hasAttemptedAutoSsid(network.ssid)) {
continue;
}
const auto* cred = WIFI_STORE.findCredential(network.ssid);
if (cred && tryAutoConnectCredential(*cred)) {
return true;
}
}
return false;
}
void WifiSelectionActivity::handleAutoConnectFailure() {
LOG_DBG("WIFI", "Saved network failed: %s", selectedSSID.c_str());
WiFi.disconnect();
if (!networks.empty()) {
if (tryNextSavedNetworkFromScan()) {
return;
}
autoConnecting = false;
state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0;
requestUpdate();
return;
}
startWifiScan(true);
}
void WifiSelectionActivity::showNetworkListFromAutoConnect() {
LOG_DBG("WIFI", "User requested manual network list");
WiFi.disconnect();
autoConnecting = false;
manualNetworkListRequested = true;
if (networks.empty()) {
startWifiScan(false);
return;
}
state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0;
requestUpdate();
}
void WifiSelectionActivity::attemptConnection() {
state = autoConnecting ? WifiSelectionState::AUTO_CONNECTING : WifiSelectionState::CONNECTING;
connectionStartTime = millis();
@@ -289,15 +427,24 @@ void WifiSelectionActivity::checkConnectionStatus() {
if (status == WL_NO_SSID_AVAIL) {
connectionError = tr(STR_ERROR_NETWORK_NOT_FOUND);
}
if (autoConnecting) {
handleAutoConnectFailure();
return;
}
state = WifiSelectionState::CONNECTION_FAILED;
requestUpdate();
return;
}
// Check for timeout
if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) {
const unsigned long timeoutMs = autoConnecting ? AUTO_CONNECTION_TIMEOUT_MS : CONNECTION_TIMEOUT_MS;
if (millis() - connectionStartTime > timeoutMs) {
WiFi.disconnect();
connectionError = tr(STR_ERROR_CONNECTION_TIMEOUT);
if (autoConnecting) {
handleAutoConnectFailure();
return;
}
state = WifiSelectionState::CONNECTION_FAILED;
requestUpdate();
return;
@@ -307,16 +454,53 @@ void WifiSelectionActivity::checkConnectionStatus() {
void WifiSelectionActivity::loop() {
// Check scan progress
if (state == WifiSelectionState::SCANNING) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
WiFi.scanDelete();
onComplete(false);
return;
}
if (autoConnecting && mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
autoConnecting = false;
manualNetworkListRequested = true;
requestUpdate();
}
processWifiScanResults();
return;
}
// Check connection progress
if (state == WifiSelectionState::CONNECTING || state == WifiSelectionState::AUTO_CONNECTING) {
if (state == WifiSelectionState::AUTO_CONNECTING) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
WiFi.disconnect();
onComplete(false);
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
showNetworkListFromAutoConnect();
return;
}
}
checkConnectionStatus();
return;
}
// Reached once the hidden-network SSID has been entered (and was non-empty).
if (state == WifiSelectionState::HIDDEN_SSID_ENTRY) {
const auto* savedCred = WIFI_STORE.findCredential(selectedSSID);
if (savedCred && !savedCred->password.empty()) {
// We already know this hidden network - connect with the saved password
enteredPassword = savedCred->password;
usedSavedPassword = true;
LOG_DBG("WiFi", "Using saved password for hidden network %s", selectedSSID.c_str());
attemptConnection();
} else {
// Prompt for the password (empty password connects to open hidden APs)
promptPasswordEntry();
}
return;
}
if (state == WifiSelectionState::PASSWORD_ENTRY) {
// Reach here once password entry finished in subactivity
attemptConnection();
@@ -477,9 +661,9 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi
}
void WifiSelectionActivity::render(RenderLock&&) {
// Don't render if we're in PASSWORD_ENTRY state - we're just transitioning
// Don't render if we're in a keyboard-entry state - we're just transitioning
// from the keyboard subactivity back to the main activity
if (state == WifiSelectionState::PASSWORD_ENTRY) {
if (state == WifiSelectionState::PASSWORD_ENTRY || state == WifiSelectionState::HIDDEN_SSID_ENTRY) {
return;
}
@@ -491,7 +675,7 @@ void WifiSelectionActivity::render(RenderLock&&) {
// Draw header
char countStr[32];
snprintf(countStr, sizeof(countStr), tr(STR_NETWORKS_FOUND), networks.size());
snprintf(countStr, sizeof(countStr), tr(STR_NETWORKS_FOUND), realNetworkCount);
GUI.drawHeader(renderer, Rect{screen.x, screen.y + metrics.topPadding, screen.width, metrics.headerHeight},
tr(STR_WIFI_NETWORKS), countStr);
GUI.drawSubHeader(
@@ -509,6 +693,9 @@ void WifiSelectionActivity::render(RenderLock&&) {
case WifiSelectionState::NETWORK_LIST:
renderNetworkList(&screen, &metrics);
break;
case WifiSelectionState::HIDDEN_SSID_ENTRY:
// Transitioning to/from the SSID keyboard subactivity - nothing to draw
break;
case WifiSelectionState::CONNECTING:
renderConnecting(&screen, &metrics);
break;
@@ -542,9 +729,17 @@ void WifiSelectionActivity::renderNetworkList(const Rect* screen, const ThemeMet
int contentHeight = screen->height - contentTop - metrics->verticalSpacing * 2;
GUI.drawList(
renderer, Rect{screen->x, contentTop, screen->width, contentHeight}, static_cast<int>(networks.size()),
selectedNetworkIndex, [this](int index) { return networks[index].ssid; }, nullptr, nullptr,
selectedNetworkIndex,
[this](int index) {
auto network = networks[index];
const auto& network = networks[index];
return network.isHiddenPlaceholder ? std::string(tr(STR_ADD_HIDDEN_NETWORK)) : network.ssid;
},
nullptr, nullptr,
[this](int index) {
const auto& network = networks[index];
if (network.isHiddenPlaceholder) {
return std::string();
}
return std::string(network.hasSavedPassword ? "+ " : "") + (network.isEncrypted ? "* " : "") +
getSignalStrengthIndicator(network.rssi);
});
@@ -566,9 +761,15 @@ void WifiSelectionActivity::renderConnecting(const Rect* screen, const ThemeMetr
const auto top = screen->y + (screen->height - height) / 2;
if (state == WifiSelectionState::SCANNING) {
UITheme::drawCenteredText(renderer, *screen, UI_10_FONT_ID, top, tr(STR_SCANNING));
UITheme::drawCenteredText(renderer, *screen, UI_10_FONT_ID, top,
autoConnecting ? tr(STR_FINDING_SAVED_WIFI) : tr(STR_SCANNING));
if (autoConnecting) {
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_SHOW_NETWORKS), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
} else {
UITheme::drawCenteredText(renderer, *screen, UI_12_FONT_ID, top - 40, tr(STR_CONNECTING), true,
UITheme::drawCenteredText(renderer, *screen, UI_12_FONT_ID, top - 40,
autoConnecting ? tr(STR_CONNECTING_SAVED_WIFI) : tr(STR_CONNECTING), true,
EpdFontFamily::BOLD);
std::string ssidInfo = std::string(tr(STR_TO_PREFIX)) + selectedSSID;
@@ -576,6 +777,10 @@ void WifiSelectionActivity::renderConnecting(const Rect* screen, const ThemeMetr
ssidInfo.replace(22, ssidInfo.length() - 22, "...");
}
UITheme::drawCenteredText(renderer, *screen, UI_10_FONT_ID, top, ssidInfo.c_str());
if (autoConnecting) {
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_SHOW_NETWORKS), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
}
}
+23 -3
View File
@@ -11,13 +11,15 @@
struct Rect;
struct ThemeMetrics;
struct WifiCredential;
// Structure to hold WiFi network information
struct WifiNetworkInfo {
std::string ssid;
int32_t rssi;
bool isEncrypted;
bool hasSavedPassword; // Whether we have saved credentials for this network
bool hasSavedPassword; // Whether we have saved credentials for this network
bool isHiddenPlaceholder = false; // Synthetic "Add hidden network..." list entry
};
// WiFi selection states
@@ -25,6 +27,7 @@ enum class WifiSelectionState {
AUTO_CONNECTING, // Trying to connect to the last known network
SCANNING, // Scanning for networks
NETWORK_LIST, // Displaying available networks
HIDDEN_SSID_ENTRY, // Entering SSID for a hidden network
PASSWORD_ENTRY, // Entering password for selected network
CONNECTING, // Attempting to connect
CONNECTED, // Successfully connected
@@ -50,6 +53,8 @@ class WifiSelectionActivity final : public Activity {
WifiSelectionState state = WifiSelectionState::SCANNING;
size_t selectedNetworkIndex = 0;
std::vector<WifiNetworkInfo> networks;
// Number of real (scanned) networks, excluding the synthetic hidden-network entry
size_t realNetworkCount = 0;
// Selected network for connection
std::string selectedSSID;
@@ -71,15 +76,22 @@ class WifiSelectionActivity final : public Activity {
// Whether to attempt auto-connect on entry
const bool allowAutoConnect;
// Whether we are attempting to auto-connect
// Whether we are attempting to auto-connect or auto-scan saved networks.
bool autoConnecting = false;
// True when the user stopped auto-connect and asked to see the scan result.
bool manualNetworkListRequested = false;
// Saved SSIDs already attempted during the current auto-connect session.
std::vector<std::string> autoAttemptedSsids;
// Save/forget prompt selection (0 = Yes, 1 = No)
int savePromptSelection = 0;
int forgetPromptSelection = 0;
// Connection timeout
static constexpr unsigned long CONNECTION_TIMEOUT_MS = 15000;
static constexpr unsigned long AUTO_CONNECTION_TIMEOUT_MS = 7000;
unsigned long connectionStartTime = 0;
void renderNetworkList(const Rect* screen, const ThemeMetrics* metrics) const;
@@ -90,11 +102,19 @@ class WifiSelectionActivity final : public Activity {
void renderConnectionFailed(const Rect* screen, const ThemeMetrics* metrics) const;
void renderForgetPrompt(const Rect* screen, const ThemeMetrics* metrics) const;
void startWifiScan();
void startWifiScan(bool autoScan = false);
void processWifiScanResults();
void appendHiddenNetworkEntry();
void selectNetwork(int index);
void promptHiddenSsid();
void promptPasswordEntry();
void attemptConnection();
void checkConnectionStatus();
bool tryAutoConnectCredential(const WifiCredential& cred);
bool tryNextSavedNetworkFromScan();
void handleAutoConnectFailure();
void showNetworkListFromAutoConnect();
bool hasAttemptedAutoSsid(const std::string& ssid) const;
std::string getSignalStrengthIndicator(int32_t rssi) const;
void onComplete(bool connected);
+147 -58
View File
@@ -206,6 +206,8 @@ void EpubReaderActivity::onEnter() {
return;
}
ImageBlock::clearSessionRenderFailures();
// Configure screen orientation based on settings
// NOTE: This affects layout math and must be applied before any render calls.
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
@@ -339,14 +341,43 @@ void EpubReaderActivity::loop() {
return;
}
// Lazily resume a partial's extension build once the reader nears its watermark. Far from
// it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this
// session, so reopening a partial deliberately does NOT start it (see the deferral in
// render()); crossing this margin is the signal that the reader will actually need pages
// past the watermark soon. Uses the last render's viewport so pagination matches the
// partial being extended.
if (section && !section->isBuilding() && section->isPartial() && !RenderLock::peek() && buildViewportWidth > 0 &&
!partialRebuildStartFailed &&
section->currentPage + PARTIAL_REBUILD_START_MARGIN >= static_cast<int>(section->pageCount)) {
RenderLock lock;
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, buildViewportWidth,
buildViewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
// Not fatal: the partial keeps serving its pages; crossing the watermark falls back to
// the blocking extension in render(). Don't retry every tick.
partialRebuildStartFailed = true;
LOG_ERR("ERS", "Failed to start deferred partial extension build");
} else {
LOG_DBG("ERS", "Reader near partial watermark (%d/%d), resuming extension build", section->currentPage,
section->pageCount);
}
}
// Drive any in-progress incremental section build forward, off the page-turn critical path,
// but only within a small window ahead of the reader: an unbounded build monopolized the
// RenderLock and locked out page turns. The build follows the reader instead, and instant
// reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit.
// Skip while the render mutex is busy so we never delay a pending render; re-check
// isBuilding() under the lock since render() may have just finished it.
// While extending a partial (rebuild from a previous session), pageCount is pinned at the
// partial's watermark until the build catches up, so the window check would wrongly read
// "far enough ahead" and stall the build at 0 pages -- then the first turn past the
// watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes.
if (section && section->isBuilding() && !RenderLock::peek() &&
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && buildTickHeapGate()) {
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) &&
buildTickHeapGate()) {
RenderLock lock;
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
// build between the outer isBuilding() check and acquiring the lock here, in which case
@@ -1043,11 +1074,17 @@ void EpubReaderActivity::render(RenderLock&& lock) {
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
// Capture for loop()'s lazy partial-extension start (must match this render's layout params).
buildViewportWidth = viewportWidth;
buildViewportHeight = viewportHeight;
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
// Fresh section, fresh chance: a failed lazy extension start in a previous
// section must not suppress watermark-triggered rebuilds for this one.
partialRebuildStartFailed = false;
// A finalized cache serves every page as-is. A partial cache (suspended build from a
// previous session) serves its pages instantly too, but a build must still run to lay
@@ -1128,12 +1165,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
pagesUntilFullRefresh = 1;
// No popup redraws while the framebuffer is lent to the build below;
// the panel holds the popup displayed above (e-ink is persistent).
const auto popupFn = [this]() {
if (renderer.hasFrameBuffer()) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
}
if (renderer.hasFrameBuffer()) GUI.drawPopup(renderer, tr(STR_INDEXING));
};
buildLoan.release();
// Lend the framebuffer's 48 KB to the blocking full build; restored
// (white) at scope exit, and the page render below redraws everything.
GfxRenderer::FrameBufferLoan loan(renderer);
const auto buildSection = [&]() {
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
@@ -1148,9 +1187,11 @@ void EpubReaderActivity::render(RenderLock&& lock) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
loan.end(); // restore before anything draws
showBuildError();
return;
}
loan.end();
} else {
// Lay out just enough to show the landing page; loop() builds the rest behind it. Show the
// indexing popup up front only when the build will actually be slow: a large spine (its
@@ -1158,64 +1199,84 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections
// build in a blink and stay popup-free.
const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber);
const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) -
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
// Popup only when the build will actually be slow: a big spine whose HTML still needs
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
// A partial cache that already covers the target page shows it instantly: never popup.
const bool willInflate = !section->hasHtmlCache();
const bool anchorJump = !pendingAnchor.empty();
bool showPopup;
if (anchorJump) {
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
// Landing well inside a partial: the page (or anchor, via the on-disk map) is already
// servable, so don't restart the extension build now -- it re-lays out the WHOLE chapter
// from page 0 (minutes of background CPU + SD writes on a giant spine), pure waste when
// the reader never nears the watermark this session. loop() starts it lazily once the
// reader is within PARTIAL_REBUILD_START_MARGIN pages of the watermark.
if (section->isPartial() &&
(anchorJump ? section->getPageForAnchor(pendingAnchor).has_value()
: target + PARTIAL_REBUILD_START_MARGIN < static_cast<int>(section->pageCount))) {
LOG_DBG("ERS", "Partial covers target %d of %d; deferring extension build", target, section->pageCount);
} else {
const bool targetAvailable = target < static_cast<int>(section->pageCount);
showPopup = !targetAvailable &&
((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD);
}
if (showPopup) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
pagesUntilFullRefresh = 1;
}
buildLoan.release();
// startBuild does the zip inflate (the big contiguous allocation), so it gets
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
// retry safe.
const auto beginBuild = [&]() {
return section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
};
bool started = !heapTooLow && beginBuild();
if (!started && SETTINGS.bluetoothEnabled) {
started = retryWithBleFreed(beginBuild);
}
if (!started) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to start section build");
section.reset();
showBuildError();
return;
}
while (!section->isBuildComplete() &&
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
// Otherwise: build until the target page exists. loop() builds the rest behind it.
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
const size_t spineBytes =
epub->getCumulativeSpineItemSize(currentSpineIndex) -
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
// Popup only when the build will actually be slow: a big spine whose HTML still needs
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
// A partial cache that already covers the target page shows it instantly: never popup.
const bool willInflate = !section->hasHtmlCache();
bool showPopup;
if (anchorJump) {
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
} else {
const bool targetAvailable = target < static_cast<int>(section->pageCount);
showPopup = !targetAvailable && ((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) ||
target > BUILD_POPUP_PAGE_THRESHOLD);
}
if (showPopup) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
pagesUntilFullRefresh = 1;
}
// Lend the framebuffer's 48 KB to the blocking pre-render burst
// (startBuild inflates the whole spine HTML — the memory peak). The
// background buildSomeMore chunks in loop() do NOT get the loan: they
// deliberately interleave with page renders. Restored before render.
GfxRenderer::FrameBufferLoan loan(renderer);
// startBuild does the zip inflate (the big contiguous allocation), so it gets
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
// retry safe.
const auto beginBuild = [&]() {
return section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
};
bool started = !heapTooLow && beginBuild();
if (!started && SETTINGS.bluetoothEnabled) {
started = retryWithBleFreed(beginBuild);
}
if (!started) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to start section build");
section.reset();
loan.end(); // restore before anything draws (showBuildError renders a popup)
showBuildError();
return;
}
while (!section->isBuildComplete() &&
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
// Otherwise: build until the target page exists. loop() builds the rest behind it.
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
loan.end(); // restore before anything draws (showBuildError renders a popup)
showBuildError();
return;
}
}
loan.end();
}
}
} else {
@@ -1258,6 +1319,16 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// Extend the build to the requested page if needed (for partials and in-progress builds).
// This runs every render, so it covers both the first page and any forward turn that gets
// ahead of the background builder; pages already built do no work here.
//
// Crossing a partial's watermark before the extension rebuild has caught up means a
// synchronous wait spanning the remaining prefix re-layout -- potentially tens of
// seconds on a giant spine. Show the indexing popup so it isn't a silent freeze
// (the page that replaces it takes the HALF ghost-cleanup path). Ordinary window
// catch-ups on a non-partial build are a page or two and stay popup-free.
if (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
pagesUntilFullRefresh = 1;
}
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
// Start a build to extend a partial toward the requested page.
buildLoan.release();
@@ -1387,7 +1458,17 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// preceding lines show (mini rebuilds, kern reloads, BLE churn).
LOG_DBG("MEM", "post-render: free=%u maxAlloc=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
}
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
// Only persist when the position actually changed. render() also runs on menu,
// bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes.
// Every real page turn changes currentPage, so progress durability is unaffected.
if (currentSpineIndex != lastSavedSpineIndex || section->currentPage != lastSavedPage ||
section->pageCount != lastSavedPageCount) {
if (saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages())) {
lastSavedSpineIndex = currentSpineIndex;
lastSavedPage = section->currentPage;
lastSavedPageCount = section->estimatedTotalPages();
}
}
showPendingSyncSaveError();
@@ -1442,6 +1523,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const auto tPrewarm = millis();
const bool pageHasImages = page->hasImages();
const bool pageHasImagesNeedingDecode = pageHasImages && page->hasImagesNeedingDecode();
const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
auto renderGrayscalePass = [&]() {
@@ -1452,6 +1534,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
}
};
if (pageHasImagesNeedingDecode) {
page->renderWithImagePlaceholders(renderer, fontId, orientedMarginLeft, orientedMarginTop);
renderStatusBar();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
renderer.clearScreen();
}
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
renderStatusBar();
const auto tBwRender = millis();
@@ -78,6 +78,21 @@ class EpubReaderActivity final : public Activity {
// session with no shed ground to <2.2 KB free and aborted on a page load.
static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024;
// Viewport of the last render(), captured so loop()'s lazy partial-extension start
// builds with IDENTICAL layout parameters to the pages already rendered (a mismatch
// would paginate differently than the partial being extended). 0 = no render yet.
uint16_t buildViewportWidth = 0;
uint16_t buildViewportHeight = 0;
// Set when the lazy extension start failed, so loop() doesn't retry (and log) every
// tick; the blocking extension in render() remains the fallback past the watermark.
bool partialRebuildStartFailed = false;
// Last position persisted by render()'s saveProgress, used to skip redundant
// writeAtomic calls on no-op re-renders (menu/bookmark/screenshot).
int lastSavedSpineIndex = -1;
int lastSavedPage = -1;
int lastSavedPageCount = -1;
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const;
@@ -116,6 +131,13 @@ class EpubReaderActivity final : public Activity {
// in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages
// already laid out as a partial file on exit/sleep.
static constexpr int BUILD_WINDOW_AHEAD = 5;
// Reopening a partial does NOT immediately restart its extension build (a whole-chapter
// re-layout from page 0 -- minutes of background CPU + SD writes on a giant spine, wasted
// when the reader never crosses the watermark that session). Instead loop() starts it once
// the reader is within this many pages of the watermark: at ~30s per page read and ~100-300ms
// per page rebuilt, this margin gives the rebuild ample runway to catch up (and finalize)
// before the reader arrives.
static constexpr int PARTIAL_REBUILD_START_MARGIN = 15;
// Show the indexing popup when an initial build must lay out more than this many pages up front
// (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent
// of the small look-ahead window so ordinary landings stay popup-free.
@@ -155,6 +177,11 @@ class EpubReaderActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&& lock) override;
// Full CPU speed + fast loop ticks while a section build runs: at the low-power
// frequency a giant chapter's background rebuild stretches from ~40s to many
// minutes, so the reader exits before it can finalize and the next open restarts
// it from page 0. Reverts to normal power behavior the moment the build finishes.
bool skipLoopDelay() override { return section && section->isBuilding(); }
bool isReaderActivity() const override { return true; }
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
ScreenshotInfo getScreenshotInfo() const override;
@@ -206,6 +206,17 @@ void KOReaderSyncActivity::performUpload() {
progress.progress = localProgress.xpath;
progress.percentage = localProgress.percentage;
// Optionally include document metadata (KOReader PR #15306)
if (KOREADER_STORE.getSendMetadata()) {
KOReaderMetadata meta;
// Extract filename from path
const auto lastSlash = epubPath.rfind('/');
meta.filename = (lastSlash != std::string::npos) ? epubPath.substr(lastSlash + 1) : epubPath;
meta.title = epub->getTitle();
meta.authors = epub->getAuthor();
progress.metadata = std::move(meta);
}
const auto result = KOReaderSyncClient::updateProgress(progress);
// Drop the radio while user reads the result; full teardown happens at silent reboot.
+14 -2
View File
@@ -5,6 +5,8 @@
#include <I18n.h>
#include <Memory.h>
#include <optional>
#include "CrossPointSettings.h"
#include "Epub.h"
#include "EpubReaderActivity.h"
@@ -40,10 +42,20 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
// First open: building the spine/TOC index (book.bin) takes a couple of seconds. Show the
// indexing popup so it isn't a silent wait on the home screen. The cachePath/hash is known at
// construction, so this check is valid before load(); a cached open loads in a blink -> no popup.
if (!Storage.exists((epub->getCachePath() + "/book.bin").c_str())) {
const bool uncached = !Storage.exists((epub->getCachePath() + "/book.bin").c_str());
if (uncached) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
}
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
bool loaded;
{
// Lend the framebuffer's 48 KB to the container parse (expat + spine/TOC
// build). The popup just displayed stays on the panel; whichever reader
// activity follows redraws the full screen anyway.
std::optional<GfxRenderer::FrameBufferLoan> loan;
if (uncached) loan.emplace(renderer);
loaded = epub->load(true, SETTINGS.embeddedStyle == 0);
}
if (loaded) {
return epub;
}
@@ -13,9 +13,9 @@
#include "fontIds.h"
namespace {
constexpr int MENU_ITEMS = 5;
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
StrId::STR_DOCUMENT_MATCHING, StrId::STR_AUTHENTICATE};
constexpr int MENU_ITEMS = 6;
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_AUTHENTICATE};
} // namespace
void KOReaderSettingsActivity::onEnter() {
@@ -98,6 +98,11 @@ void KOReaderSettingsActivity::handleSelection() {
KOREADER_STORE.saveToFile();
requestUpdate();
} else if (selectedIndex == 4) {
// Send Metadata - toggle on/off
KOREADER_STORE.setSendMetadata(!KOREADER_STORE.getSendMetadata());
KOREADER_STORE.saveToFile();
requestUpdate();
} else if (selectedIndex == 5) {
// Authenticate
if (!KOREADER_STORE.hasCredentials()) {
// Can't authenticate without credentials - just show message briefly
@@ -136,6 +141,8 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME))
: std::string(tr(STR_BINARY));
} else if (index == 4) {
return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
} else if (index == 5) {
return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]";
}
return std::string(tr(STR_NOT_SET));
+9
View File
@@ -353,6 +353,15 @@ void CrossPointWebServer::handleJszip() const {
}
void CrossPointWebServer::handleNotFound() const {
// in AP mode, redirect unmatched browser/captive-portal requests to "/" so the OS auto-opens the browser
// API requests (/api/*) still return 404 so XHR errors surface correctly
// see https://en.wikipedia.org/wiki/Captive_portal#Detection
if (apMode && !server->uri().startsWith("/api/")) {
server->sendHeader("Location", "/", true);
server->send(302, "text/plain", "");
return;
}
String message = "404 Not Found\n\n";
message += "URI: " + server->uri() + "\n";
server->send(404, "text/plain", message);
+100 -16
View File
@@ -4,27 +4,34 @@
#include <Logging.h>
#include <Memory.h>
#include <base64.h>
#include <esp_crt_bundle.h>
#include <esp_http_client.h>
#include <cstring>
#include <functional>
#include <string>
#if defined(FREEINK_NET_WOLFSSL)
#include <SecureHttpClient.h>
extern "C" void wolfSSL_Arduino_Serial_Print(const char* const msg) { LOG_DBG("WOLFSSL", "%s", msg); }
#else
#include <esp_crt_bundle.h>
#include <esp_http_client.h>
#endif
namespace {
// RX holds the response headers. 4096 fits real OPDS servers; GitHub's release
// CDN sends more and logs HTTP_HEADER "Buffer length is small", but that's
// non-fatal: the headers we read (Location, Content-Length) come first and
// survive. Smaller keeps contiguous heap free while WiFi and TLS are up. TX
// only carries our GET; the body streams in READ_CHUNK pieces.
constexpr int HTTP_RX_BUF = 4096;
constexpr int HTTP_TX_BUF = 1024;
#if !defined(FREEINK_NET_WOLFSSL)
// RX holds the response headers. Smaller buffers leave enough contiguous heap
// for mbedTLS on redirect-heavy OPDS feeds while still preserving the headers
// we read directly (Location, Content-Length).
constexpr int HTTP_RX_BUF = 2048;
constexpr int HTTP_TX_BUF = 512;
#endif
// Per-socket-op timeout. Some OPDS download endpoints are slow to send headers
// (>15s) and chunked catalogs stall mid-body, so 15s killed them. 60s gives
// slow servers room. esp_http_client's timeout_ms is uint32, so unlike Arduino
// HTTPClient's uint16 setTimeout it doesn't silently truncate.
constexpr int HTTP_TIMEOUT_MS = 60000;
constexpr size_t READ_CHUNK = 2048;
constexpr size_t READ_CHUNK = 1024;
constexpr int MAX_REDIRECTS = 5;
struct Sink {
std::function<bool(const uint8_t*, size_t)> write; // returns false to abort the transfer
@@ -38,6 +45,68 @@ bool isRedirect(int status) {
return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
}
#if defined(FREEINK_NET_WOLFSSL)
HttpDownloader::DownloadError runGetWolf(const std::string& startUrl, const std::string& username,
const std::string& password, Sink& sink) {
std::string url = startUrl;
for (int hop = 0; hop <= MAX_REDIRECTS; ++hop) {
freeink::SecureHttpClient http;
http.setTimeout(HTTP_TIMEOUT_MS);
http.setInsecure();
if (!http.begin(url)) {
LOG_ERR("HTTP", "wolfSSL bad URL: %s", url.c_str());
return HttpDownloader::HTTP_ERROR;
}
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
if (!username.empty() && !password.empty()) {
const std::string credentials = username + ":" + password;
const String encoded = base64::encode(credentials.c_str());
http.addHeader("Authorization", std::string("Basic ") + encoded.c_str());
}
LOG_DBG("HTTP", "wolfSSL GET: %s", url.c_str());
const int status = http.GET(
[&http, &sink](const uint8_t* data, size_t len) {
if (http.getStatus() != 200) return true;
if (sink.total == 0 && http.hasContentLength()) sink.total = http.getContentLength();
if (!sink.write(data, len)) return false;
sink.downloaded += len;
if (sink.progress && sink.total > 0) sink.progress(sink.downloaded, sink.total);
return true;
},
[&sink]() { return sink.cancelFlag && *sink.cancelFlag; });
if (http.aborted()) return HttpDownloader::ABORTED;
if (status < 0) {
LOG_ERR("HTTP", "wolfSSL request failed: %s", url.c_str());
return HttpDownloader::HTTP_ERROR;
}
if (isRedirect(status)) {
const std::string location = http.getHeader("location");
if (location.empty() || !freeink::SecureHttpClient::resolveUrl(url, location, url)) {
LOG_ERR("HTTP", "wolfSSL bad redirect: %d", status);
return HttpDownloader::HTTP_ERROR;
}
continue;
}
if (status != 200) {
LOG_ERR("HTTP", "wolfSSL unexpected status: %d", status);
return HttpDownloader::HTTP_ERROR;
}
if (http.callbackAborted()) return HttpDownloader::FILE_ERROR;
if (!http.responseComplete()) {
LOG_ERR("HTTP", "wolfSSL incomplete: got %zu of %zu bytes", sink.downloaded, sink.total);
return HttpDownloader::HTTP_ERROR;
}
return HttpDownloader::OK;
}
LOG_ERR("HTTP", "too many redirects");
return HttpDownloader::HTTP_ERROR;
}
#endif
#if !defined(FREEINK_NET_WOLFSSL)
// Streams a GET body through sink.write in READ_CHUNK pieces. Uses the manual
// open/fetch_headers/read path rather than esp_http_client_perform(): perform()
// pushes the whole body through an event callback and reports a chunked body
@@ -84,8 +153,9 @@ HttpDownloader::DownloadError runGet(const std::string& url, const std::string&
}
int64_t contentLength = esp_http_client_fetch_headers(client);
int status = esp_http_client_get_status_code(client);
for (int hop = 0; isRedirect(status) && hop < 5; ++hop) {
for (int hop = 0; isRedirect(status) && hop < MAX_REDIRECTS; ++hop) {
if (esp_http_client_set_redirection(client) != ESP_OK) break;
esp_http_client_close(client);
err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
LOG_ERR("HTTP", "redirect open failed: %s", esp_err_to_name(err));
@@ -141,6 +211,20 @@ HttpDownloader::DownloadError runGet(const std::string& url, const std::string&
}
return HttpDownloader::OK;
}
#endif // !FREEINK_NET_WOLFSSL
// All HTTP(S) fetches go through wolfSSL when it is the active TLS stack: it
// speaks TLS 1.3 and reads large bodies from servers where the esp_http_client/
// mbedTLS path fails to connect or stalls mid-stream. Plain-http URLs still use a
// WiFiClient inside runGetWolf, so this is safe for non-TLS targets too.
HttpDownloader::DownloadError runGetSecure(const std::string& url, const std::string& username,
const std::string& password, Sink& sink) {
#if defined(FREEINK_NET_WOLFSSL)
return runGetWolf(url, username, password, sink);
#else
return runGet(url, username, password, sink);
#endif
}
} // namespace
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username,
@@ -148,7 +232,7 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const
LOG_DBG("HTTP", "Fetching: %s", url.c_str());
Sink sink;
sink.write = [&outContent](const uint8_t* data, size_t len) { return outContent.write(data, len) == len; };
return runGet(url, username, password, sink) == OK;
return runGetSecure(url, username, password, sink) == OK;
}
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username,
@@ -160,7 +244,7 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, c
outContent.append(reinterpret_cast<const char*>(data), len);
return true;
};
return runGet(url, username, password, sink) == OK;
return runGetSecure(url, username, password, sink) == OK;
}
bool HttpDownloader::fetchUrl(const std::string& url, const DataCallback& onData, const std::string& username,
@@ -168,7 +252,7 @@ bool HttpDownloader::fetchUrl(const std::string& url, const DataCallback& onData
LOG_DBG("HTTP", "Fetching: %s", url.c_str());
Sink sink;
sink.write = onData;
return runGet(url, username, password, sink) == OK;
return runGetSecure(url, username, password, sink) == OK;
}
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
@@ -190,7 +274,7 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
sink.cancelFlag = cancelFlag;
sink.write = [&file](const uint8_t* data, size_t len) { return file.write(data, len) == len; };
const DownloadError result = runGet(url, username, password, sink);
const DownloadError result = runGetSecure(url, username, password, sink);
// Close before any remove() on the same path; DESTRUCTOR_CLOSES_FILE would
// otherwise close only after the remove.
file.close();
+41 -53
View File
@@ -2,15 +2,12 @@
// clang-format off
// HttpDownloader.h pulls Arduino/SdFat, whose macros collide with lwip's
// ip4_addr.h unless seen before esp_http_client (which includes lwip). Pin this
// order; clang-format would otherwise sort the local header last and break the
// build.
// ip4_addr.h unless seen first. Pin this order; clang-format would otherwise sort
// the local header last and break the build.
#include "HttpDownloader.h"
#include <Logging.h>
#include <ReleaseJsonParser.h>
#include <esp_crt_bundle.h>
#include <esp_http_client.h>
#include <esp_https_ota.h>
#include <esp_ota_ops.h>
#include <esp_wifi.h>
// clang-format on
@@ -18,10 +15,6 @@
namespace {
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/latest";
esp_err_t http_client_set_header_cb(esp_http_client_handle_t http_client) {
return esp_http_client_set_header(http_client, "User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
}
} // namespace
OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
@@ -116,44 +109,39 @@ OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(ProgressCallback onProgres
return UPDATE_OLDER_ERROR;
}
esp_https_ota_handle_t ota_handle = NULL;
esp_err_t esp_err;
// esp_https_ota is hardwired to esp-tls/mbedTLS, whose precompiled build on this
// package can't negotiate TLS 1.3 (see SecureClient.h). Drive the OTA partition
// ourselves and stream the firmware through HttpDownloader, which runs over
// wolfSSL when FREEINK_NET_WOLFSSL is set, reusing its redirect handling for the
// GitHub -> CDN hop.
const esp_partition_t* updatePartition = esp_ota_get_next_update_partition(nullptr);
if (!updatePartition) {
LOG_ERR("OTA", "No OTA partition available");
return INTERNAL_UPDATE_ERROR;
}
esp_http_client_config_t client_config = {
.url = otaUrl.c_str(),
.timeout_ms = 15000,
// 4096 holds the github->CDN redirect headers (the 512 default truncates
// them); TX only carries our GET. Both are contiguous blocks contending
// with the TLS handshake on a tight internal arena, so keep them minimal.
.buffer_size = 4096,
.buffer_size_tx = 1024,
.skip_cert_common_name_check = true,
.crt_bundle_attach = esp_crt_bundle_attach,
.keep_alive_enable = true,
};
esp_https_ota_config_t ota_config = {
.http_config = &client_config,
.http_client_init_cb = http_client_set_header_cb,
};
esp_ota_handle_t otaHandle = 0;
esp_err_t esp_err = esp_ota_begin(updatePartition, OTA_SIZE_UNKNOWN, &otaHandle);
if (esp_err != ESP_OK) {
LOG_ERR("OTA", "esp_ota_begin failed: %s", esp_err_to_name(esp_err));
return INTERNAL_UPDATE_ERROR;
}
/* For better timing and connectivity, we disable power saving for WiFi */
esp_wifi_set_ps(WIFI_PS_NONE);
esp_err = esp_https_ota_begin(&ota_config, &ota_handle);
if (esp_err != ESP_OK) {
LOG_DBG("OTA", "HTTP OTA Begin Failed: %s", esp_err_to_name(esp_err));
return INTERNAL_UPDATE_ERROR;
}
processedSize = 0;
int lastReportedPct = -1;
do {
esp_err = esp_https_ota_perform(ota_handle);
processedSize = esp_https_ota_get_image_len_read(ota_handle);
// Fire the callback only on whole-percent change. Without this it fired
// every ~100ms perform iteration, waking the render task whose framebuffer
// work contends with TLS on the same internal arena. E-ink can't repaint
// faster than a percent tick anyway.
bool flashOk = true;
const bool fetchOk = HttpDownloader::fetchUrl(otaUrl, [&](const uint8_t* data, size_t len) {
if (esp_ota_write(otaHandle, data, len) != ESP_OK) {
flashOk = false;
return false; // abort the transfer
}
processedSize += len;
// Fire the callback only on whole-percent change. Per-chunk updates wake the
// render task, whose framebuffer work contends with TLS on the internal arena,
// and e-ink can't repaint faster than a percent tick anyway.
if (onProgress && totalSize > 0) {
const int pct = static_cast<int>(static_cast<uint64_t>(processedSize) * 100 / totalSize);
if (pct != lastReportedPct) {
@@ -161,27 +149,27 @@ OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(ProgressCallback onProgres
onProgress(ctx);
}
}
delay(100); // TODO: should we replace this with something better?
} while (esp_err == ESP_ERR_HTTPS_OTA_IN_PROGRESS);
return true;
});
/* Return back to default power saving for WiFi in case of failing */
esp_wifi_set_ps(WIFI_PS_MIN_MODEM);
if (esp_err != ESP_OK) {
LOG_ERR("OTA", "esp_https_ota_perform Failed: %s", esp_err_to_name(esp_err));
esp_https_ota_finish(ota_handle);
return HTTP_ERROR;
if (!fetchOk || !flashOk) {
LOG_ERR("OTA", "Firmware install failed (%s)", flashOk ? "download" : "flash write");
esp_ota_abort(otaHandle);
return flashOk ? HTTP_ERROR : INTERNAL_UPDATE_ERROR;
}
if (!esp_https_ota_is_complete_data_received(ota_handle)) {
LOG_ERR("OTA", "esp_https_ota_is_complete_data_received Failed: %s", esp_err_to_name(esp_err));
esp_https_ota_finish(ota_handle);
esp_err = esp_ota_end(otaHandle); // verifies the written image
if (esp_err != ESP_OK) {
LOG_ERR("OTA", "esp_ota_end failed: %s", esp_err_to_name(esp_err));
return INTERNAL_UPDATE_ERROR;
}
esp_err = esp_https_ota_finish(ota_handle);
esp_err = esp_ota_set_boot_partition(updatePartition);
if (esp_err != ESP_OK) {
LOG_ERR("OTA", "esp_https_ota_finish Failed: %s", esp_err_to_name(esp_err));
LOG_ERR("OTA", "esp_ota_set_boot_partition failed: %s", esp_err_to_name(esp_err));
return INTERNAL_UPDATE_ERROR;
}
+49 -5
View File
@@ -1,6 +1,29 @@
#include "UrlUtils.h"
#include <cstdio>
namespace UrlUtils {
namespace {
bool isHexDigit(const char c) { return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); }
bool shouldEncode(const unsigned char c) {
if (c <= 0x20 || c >= 0x7f) return true;
switch (c) {
case '"':
case '<':
case '>':
case '\\':
case '^':
case '`':
case '{':
case '|':
case '}':
return true;
default:
return false;
}
}
} // namespace
std::string ensureProtocol(const std::string& url) {
if (url.find("://") == std::string::npos) {
@@ -22,18 +45,39 @@ std::string extractHost(const std::string& url) {
return pathStart == std::string::npos ? url : url.substr(0, pathStart);
}
std::string encodeUnsafeUrlChars(const std::string& url) {
std::string out;
out.reserve(url.size());
for (size_t i = 0; i < url.size(); ++i) {
const unsigned char c = static_cast<unsigned char>(url[i]);
if (c == '%' && i + 2 < url.size() && isHexDigit(url[i + 1]) && isHexDigit(url[i + 2])) {
out += url[i];
out += url[i + 1];
out += url[i + 2];
i += 2;
} else if (c == '%' || shouldEncode(c)) {
char encoded[4];
snprintf(encoded, sizeof(encoded), "%%%02X", c);
out += encoded;
} else {
out += static_cast<char>(c);
}
}
return out;
}
std::string buildUrl(const std::string& serverUrl, const std::string& path) {
// If path is already an absolute URL (has protocol), use it directly
if (path.find("://") != std::string::npos) {
return path;
return encodeUnsafeUrlChars(path);
}
const std::string urlWithProtocol = ensureProtocol(serverUrl);
if (path.empty()) {
return urlWithProtocol;
return encodeUnsafeUrlChars(urlWithProtocol);
}
if (path[0] == '/') {
// Absolute path - use just the host
return extractHost(urlWithProtocol) + path;
return encodeUnsafeUrlChars(extractHost(urlWithProtocol) + path);
}
// Relative path - strip query string from base before appending
std::string base = urlWithProtocol;
@@ -42,9 +86,9 @@ std::string buildUrl(const std::string& serverUrl, const std::string& path) {
base.resize(queryPos);
}
if (base.back() == '/') {
return base + path;
return encodeUnsafeUrlChars(base + path);
}
return base + "/" + path;
return encodeUnsafeUrlChars(base + "/" + path);
}
} // namespace UrlUtils
+5
View File
@@ -13,6 +13,11 @@ std::string ensureProtocol(const std::string& url);
*/
std::string extractHost(const std::string& url);
/**
* Percent-encode raw characters that esp_http_client rejects in a URL.
*/
std::string encodeUnsafeUrlChars(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.