Merge remote-tracking branch 'origin/develop' into feat-bluetooth
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user