Merge branch 'develop' of https://github.com/crosspoint-reader/crosspoint-reader into feat-bluetooth

This commit is contained in:
Justin Mitchell
2026-07-08 22:13:16 -04:00
34 changed files with 980 additions and 824 deletions
+1 -22
View File
@@ -24,7 +24,7 @@ void readAndValidate(HalFile& file, uint8_t& member, const uint8_t maxValue) {
}
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
constexpr uint8_t SETTINGS_FILE_VERSION = 2;
constexpr char SETTINGS_FILE_BIN[] = "/.crosspoint/settings.bin";
constexpr char SETTINGS_FILE_JSON[] = "/.crosspoint/settings.json";
constexpr char SETTINGS_FILE_BAK[] = "/.crosspoint/settings.bin.bak";
@@ -229,13 +229,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT);
if (++settingsRead >= fileSettingsCount) break;
{
std::string urlStr;
serialization::readString(inputFile, urlStr);
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
}
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, textAntiAliasing);
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT);
@@ -244,20 +237,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hyphenationEnabled);
if (++settingsRead >= fileSettingsCount) break;
{
std::string usernameStr;
serialization::readString(inputFile, usernameStr);
strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1);
opdsUsername[sizeof(opdsUsername) - 1] = '\0';
}
if (++settingsRead >= fileSettingsCount) break;
{
std::string passwordStr;
serialization::readString(inputFile, passwordStr);
strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1);
opdsPassword[sizeof(opdsPassword) - 1] = '\0';
}
if (++settingsRead >= fileSettingsCount) break;
readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, uiTheme);
-4
View File
@@ -254,10 +254,6 @@ class CrossPointSettings {
// Reader screen margin settings
uint8_t screenMargin = 5;
// OPDS browser settings
char opdsServerUrl[128] = "";
char opdsUsername[64] = "";
char opdsPassword[64] = "";
// Hide battery percentage
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press page turn button behavior
-144
View File
@@ -295,150 +295,6 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
return true;
}
// ---- WifiCredentialStore ----
bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) {
JsonDocument doc;
doc["lastConnectedSsid"] = store.getLastConnectedSsid();
JsonArray arr = doc["credentials"].to<JsonArray>();
for (const auto& cred : store.getCredentials()) {
JsonObject obj = arr.add<JsonObject>();
obj["ssid"] = cred.ssid;
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
}
String json;
serializeJson(doc, json);
return Storage.writeFile(path, json);
}
bool JsonSettingsIO::loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave) {
if (needsResave) *needsResave = false;
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("WCS", "JSON parse error: %s", error.c_str());
return false;
}
store.lastConnectedSsid = doc["lastConnectedSsid"] | std::string("");
store.credentials.clear();
JsonArray arr = doc["credentials"].as<JsonArray>();
for (JsonObject obj : arr) {
if (store.credentials.size() >= store.MAX_NETWORKS) break;
WifiCredential cred;
cred.ssid = obj["ssid"] | std::string("");
bool ok = false;
cred.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
if (!ok || cred.password.empty()) {
cred.password = obj["password"] | std::string("");
if (!cred.password.empty() && needsResave) *needsResave = true;
}
store.credentials.push_back(cred);
}
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", store.credentials.size());
return true;
}
// ---- RecentBooksStore ----
bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char* path) {
JsonDocument doc;
JsonArray arr = doc["books"].to<JsonArray>();
for (const auto& book : store.getBooks()) {
JsonObject obj = arr.add<JsonObject>();
obj["path"] = book.path;
obj["title"] = book.title;
obj["author"] = book.author;
obj["coverBmpPath"] = book.coverBmpPath;
}
String json;
serializeJson(doc, json);
return Storage.writeFile(path, json);
}
bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) {
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("RBS", "JSON parse error: %s", error.c_str());
return false;
}
store.recentBooks.clear();
JsonArray arr = doc["books"].as<JsonArray>();
store.recentBooks.reserve(std::min(arr.size(), (size_t)10));
for (JsonObject obj : arr) {
if (store.getCount() >= 10) break;
RecentBook book;
book.path = obj["path"] | std::string("");
book.title = obj["title"] | std::string("");
book.author = obj["author"] | std::string("");
book.coverBmpPath = obj["coverBmpPath"] | std::string("");
store.recentBooks.push_back(book);
}
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount());
return true;
}
// ---- OpdsServerStore ----
// Follows the same save/load pattern as WifiCredentialStore above.
// Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key).
bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) {
JsonDocument doc;
JsonArray arr = doc["servers"].to<JsonArray>();
for (const auto& server : store.getServers()) {
JsonObject obj = arr.add<JsonObject>();
obj["name"] = server.name;
obj["url"] = server.url;
obj["username"] = server.username;
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
}
String json;
serializeJson(doc, json);
return Storage.writeFile(path, json);
}
bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) {
if (needsResave) *needsResave = false;
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
return false;
}
store.servers.clear();
JsonArray arr = doc["servers"].as<JsonArray>();
for (JsonObject obj : arr) {
if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break;
OpdsServer server;
server.name = obj["name"] | std::string("");
server.url = obj["url"] | std::string("");
server.username = obj["username"] | std::string("");
// Try the obfuscated key first; fall back to plaintext "password" for
// files written before obfuscation was added (or hand-edited JSON).
bool ok = false;
server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok);
if (!ok || server.password.empty()) {
server.password = obj["password"] | std::string("");
if (!server.password.empty() && needsResave) *needsResave = true;
}
store.servers.push_back(std::move(server));
}
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size());
return true;
}
// ---- Bookmarks ----
bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path) {
-12
View File
@@ -19,18 +19,6 @@ bool loadSettings(CrossPointSettings& s, const char* json, bool* needsResave = n
bool saveState(const CrossPointState& s, const char* path);
bool loadState(CrossPointState& s, const char* json);
// WifiCredentialStore
bool saveWifi(const WifiCredentialStore& store, const char* path);
bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave = nullptr);
// RecentBooksStore
bool saveRecentBooks(const RecentBooksStore& store, const char* path);
bool loadRecentBooks(RecentBooksStore& store, const char* json);
// OpdsServerStore
bool saveOpds(const OpdsServerStore& store, const char* path);
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr);
// Bookmarks
bool saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path);
bool loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json);
+36 -62
View File
@@ -1,74 +1,48 @@
#include "OpdsServerStore.h"
#include <HalStorage.h>
#include <JsonSettingsIO.h>
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <algorithm>
#include <cstring>
#include "CrossPointSettings.h"
OpdsServerStore OpdsServerStore::instance;
namespace {
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
} // namespace
bool OpdsServerStore::saveToFile() const {
Storage.mkdir("/.crosspoint");
return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON);
void OpdsServerStore::toJson(JsonDocument& doc) const {
JsonArray arr = doc["servers"].to<JsonArray>();
for (const auto& server : servers) {
JsonObject obj = arr.add<JsonObject>();
obj["name"] = server.name;
obj["url"] = server.url;
obj["username"] = server.username;
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
}
}
bool OpdsServerStore::loadFromFile() {
if (Storage.exists(OPDS_FILE_JSON)) {
String json = Storage.readFile(OPDS_FILE_JSON);
if (!json.isEmpty()) {
// resave flag is set when passwords were stored in plaintext and need re-obfuscation
bool resave = false;
bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave);
if (result && resave) {
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
saveToFile();
}
return result;
}
}
// No opds.json found — attempt one-time migration from the legacy single-server
// fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword).
if (migrateFromSettings()) {
LOG_DBG("OPS", "Migrated legacy OPDS settings");
return true;
}
return false;
}
bool OpdsServerStore::migrateFromSettings() {
if (strlen(SETTINGS.opdsServerUrl) == 0) {
return false;
}
OpdsServer server;
server.name = "OPDS Server";
server.url = SETTINGS.opdsServerUrl;
server.username = SETTINGS.opdsUsername;
server.password = SETTINGS.opdsPassword;
servers.push_back(std::move(server));
if (saveToFile()) {
// Clear legacy fields so migration won't run again on next boot
SETTINGS.opdsServerUrl[0] = '\0';
SETTINGS.opdsUsername[0] = '\0';
SETTINGS.opdsPassword[0] = '\0';
SETTINGS.saveToFile();
LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json");
return true;
}
// Save failed — roll back in-memory state so we don't have a partial migration
bool OpdsServerStore::fromJson(JsonVariantConst doc) {
// Tolerate a missing/invalid 'servers' key (treat as empty list); only a
// JSON parse error is fatal. A null JsonArray iterates zero times.
servers.clear();
return false;
JsonArrayConst arr = doc["servers"].as<JsonArrayConst>();
servers.reserve(std::min(arr.size(), MAX_SERVERS));
bool needsResave = false;
for (JsonObjectConst obj : arr) {
if (servers.size() >= OpdsServerStore::MAX_SERVERS) break;
OpdsServer server;
server.name = obj["name"] | "";
server.url = obj["url"] | "";
server.username = obj["username"] | "";
server.password = extractPassword(obj, needsResave);
servers.push_back(std::move(server));
}
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
if (needsResave) {
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
saveToFile();
}
return true;
}
bool OpdsServerStore::addServer(const OpdsServer& server) {
+8 -23
View File
@@ -1,4 +1,7 @@
#pragma once
#include <ArduinoJson.h>
#include <PersistableStore.h>
#include <string>
#include <vector>
@@ -9,37 +12,25 @@ struct OpdsServer {
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
};
class OpdsServerStore;
namespace JsonSettingsIO {
bool saveOpds(const OpdsServerStore& store, const char* path);
bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave);
} // namespace JsonSettingsIO
/**
* Singleton class for storing OPDS server configurations on the SD card.
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
* and base64-encoded before writing to JSON.
*/
class OpdsServerStore {
class OpdsServerStore : public PersistableStore<OpdsServerStore> {
private:
static OpdsServerStore instance;
std::vector<OpdsServer> servers;
static constexpr size_t MAX_SERVERS = 8;
OpdsServerStore() = default;
friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*);
friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*);
friend class PersistableStore<OpdsServerStore>;
public:
OpdsServerStore(const OpdsServerStore&) = delete;
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
static OpdsServerStore& getInstance() { return instance; }
bool saveToFile() const;
bool loadFromFile();
static const char* getFilePath() { return "/.crosspoint/opds.json"; }
void toJson(JsonDocument& doc) const;
bool fromJson(JsonVariantConst doc);
bool addServer(const OpdsServer& server);
bool updateServer(size_t index, const OpdsServer& server);
@@ -49,12 +40,6 @@ class OpdsServerStore {
const OpdsServer* getServer(size_t index) const;
size_t getCount() const { return servers.size(); }
bool hasServers() const { return !servers.empty(); }
/**
* Migrate from legacy single-server settings in CrossPointSettings.
* Called once during first load if no opds.json exists.
*/
bool migrateFromSettings();
};
#define OPDS_STORE OpdsServerStore::getInstance()
+29 -107
View File
@@ -3,23 +3,42 @@
#include <Epub.h>
#include <FsHelpers.h>
#include <HalStorage.h>
#include <JsonSettingsIO.h>
#include <Logging.h>
#include <Serialization.h>
#include <Xtc.h>
#include <algorithm>
#include <iterator>
namespace {
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3;
constexpr char RECENT_BOOKS_FILE_BIN[] = "/.crosspoint/recent.bin";
constexpr char RECENT_BOOKS_FILE_JSON[] = "/.crosspoint/recent.json";
constexpr char RECENT_BOOKS_FILE_BAK[] = "/.crosspoint/recent.bin.bak";
constexpr int MAX_RECENT_BOOKS = 10;
} // namespace
void RecentBooksStore::toJson(JsonDocument& doc) const {
JsonArray arr = doc["books"].to<JsonArray>();
for (const auto& book : recentBooks) {
JsonObject obj = arr.add<JsonObject>();
obj["path"] = book.path;
obj["title"] = book.title;
obj["author"] = book.author;
obj["coverBmpPath"] = book.coverBmpPath;
}
}
RecentBooksStore RecentBooksStore::instance;
bool RecentBooksStore::fromJson(JsonVariantConst doc) {
// Tolerate a missing/invalid 'books' key (treat as empty list); only a
// JSON parse error is fatal. A null JsonArray iterates zero times.
recentBooks.clear();
JsonArrayConst arr = doc["books"].as<JsonArrayConst>();
recentBooks.reserve(std::min(arr.size(), static_cast<size_t>(MAX_RECENT_BOOKS)));
for (JsonObjectConst obj : arr) {
if (getCount() >= MAX_RECENT_BOOKS) break;
RecentBook book;
book.path = obj["path"] | "";
book.title = obj["title"] | "";
book.author = obj["author"] | "";
book.coverBmpPath = obj["coverBmpPath"] | "";
recentBooks.push_back(book);
}
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", getCount());
return true;
}
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
@@ -92,11 +111,6 @@ bool RecentBooksStore::pruneMissing() {
return recentBooks.size() != before;
}
bool RecentBooksStore::saveToFile() const {
Storage.mkdir("/.crosspoint");
return JsonSettingsIO::saveRecentBooks(*this, RECENT_BOOKS_FILE_JSON);
}
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
std::string lastBookFileName = "";
const size_t lastSlash = path.find_last_of('/');
@@ -124,95 +138,3 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
}
return RecentBook{path, "", "", ""};
}
bool RecentBooksStore::loadFromFile() {
// Try JSON first
if (Storage.exists(RECENT_BOOKS_FILE_JSON)) {
String json = Storage.readFile(RECENT_BOOKS_FILE_JSON);
if (!json.isEmpty()) {
return JsonSettingsIO::loadRecentBooks(*this, json.c_str());
}
}
// Fall back to binary migration
if (Storage.exists(RECENT_BOOKS_FILE_BIN)) {
if (loadFromBinaryFile()) {
saveToFile();
Storage.rename(RECENT_BOOKS_FILE_BIN, RECENT_BOOKS_FILE_BAK);
LOG_DBG("RBS", "Migrated recent.bin to recent.json");
return true;
}
}
return false;
}
bool RecentBooksStore::loadFromBinaryFile() {
HalFile inputFile;
if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE_BIN, inputFile)) {
return false;
}
uint8_t version;
serialization::readPod(inputFile, version);
if (version == 1 || version == 2) {
// Old version, just read paths
uint8_t count;
serialization::readPod(inputFile, count);
recentBooks.clear();
recentBooks.reserve(count);
for (uint8_t i = 0; i < count; i++) {
std::string path;
serialization::readString(inputFile, path);
// load book to get missing data
RecentBook book = getDataFromBook(path);
if (book.title.empty() && book.author.empty() && version == 2) {
// Fall back to loading what we can from the store
std::string title, author;
serialization::readString(inputFile, title);
serialization::readString(inputFile, author);
recentBooks.push_back({path, title, author, ""});
} else {
recentBooks.push_back(book);
}
}
} else if (version == 3) {
uint8_t count;
serialization::readPod(inputFile, count);
recentBooks.clear();
recentBooks.reserve(count);
uint8_t omitted = 0;
for (uint8_t i = 0; i < count; i++) {
std::string path, title, author, coverBmpPath;
serialization::readString(inputFile, path);
serialization::readString(inputFile, title);
serialization::readString(inputFile, author);
serialization::readString(inputFile, coverBmpPath);
// Omit books with missing title (e.g. saved before metadata was available)
if (title.empty()) {
omitted++;
continue;
}
recentBooks.push_back({path, title, author, coverBmpPath});
}
if (omitted > 0) {
// Explicitly close() file before saveToFile() rewrites the same file
inputFile.close();
saveToFile();
LOG_DBG("RBS", "Omitted %u recent book(s) with missing title", omitted);
return true;
}
} else {
LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version);
return false;
}
LOG_DBG("RBS", "Recent books loaded from binary file (%d entries)", static_cast<int>(recentBooks.size()));
return true;
}
+13 -19
View File
@@ -1,4 +1,7 @@
#pragma once
#include <ArduinoJson.h>
#include <PersistableStore.h>
#include <string>
#include <vector>
@@ -11,24 +14,21 @@ struct RecentBook {
bool operator==(const RecentBook& other) const { return path == other.path; }
};
class RecentBooksStore;
namespace JsonSettingsIO {
bool loadRecentBooks(RecentBooksStore& store, const char* json);
} // namespace JsonSettingsIO
class RecentBooksStore {
// Static instance
static RecentBooksStore instance;
class RecentBooksStore : public PersistableStore<RecentBooksStore> {
private:
std::vector<RecentBook> recentBooks;
friend bool JsonSettingsIO::loadRecentBooks(RecentBooksStore&, const char*);
static constexpr int MAX_RECENT_BOOKS = 10;
public:
RecentBooksStore() = default;
~RecentBooksStore() = default;
// Get singleton instance
static RecentBooksStore& getInstance() { return instance; }
friend class PersistableStore<RecentBooksStore>;
public:
static const char* getFilePath() { return "/.crosspoint/recent.json"; }
void toJson(JsonDocument& doc) const;
bool fromJson(JsonVariantConst doc);
// Add a book to the recent list (moves to front if already exists)
void addBook(const std::string& path, const std::string& title, const std::string& author,
@@ -61,13 +61,7 @@ class RecentBooksStore {
// Get the count of recent books
int getCount() const { return static_cast<int>(recentBooks.size()); }
bool saveToFile() const;
bool loadFromFile();
RecentBook getDataFromBook(std::string path) const;
private:
bool loadFromBinaryFile();
};
// Helper macro to access recent books store
+26 -86
View File
@@ -1,106 +1,46 @@
#include "WifiCredentialStore.h"
#include <HalStorage.h>
#include <JsonSettingsIO.h>
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <Serialization.h>
#include <algorithm>
// Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance;
void WifiCredentialStore::toJson(JsonDocument& doc) const {
doc["lastConnectedSsid"] = lastConnectedSsid;
namespace {
// File format version (for binary migration)
constexpr uint8_t WIFI_FILE_VERSION = 2;
// File paths
constexpr char WIFI_FILE_BIN[] = "/.crosspoint/wifi.bin";
constexpr char WIFI_FILE_JSON[] = "/.crosspoint/wifi.json";
constexpr char WIFI_FILE_BAK[] = "/.crosspoint/wifi.bin.bak";
// Legacy obfuscation key - "CrossPoint" in ASCII (only used for binary migration)
constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x43, 0x72, 0x6F, 0x73, 0x73, 0x50, 0x6F, 0x69, 0x6E, 0x74};
constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY);
void legacyDeobfuscate(std::string& data) {
for (size_t i = 0; i < data.size(); i++) {
data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH];
JsonArray arr = doc["credentials"].to<JsonArray>();
for (const auto& cred : credentials) {
JsonObject obj = arr.add<JsonObject>();
obj["ssid"] = cred.ssid;
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
}
}
} // namespace
bool WifiCredentialStore::saveToFile() const {
Storage.mkdir("/.crosspoint");
return JsonSettingsIO::saveWifi(*this, WIFI_FILE_JSON);
}
bool WifiCredentialStore::loadFromFile() {
// Try JSON first
if (Storage.exists(WIFI_FILE_JSON)) {
String json = Storage.readFile(WIFI_FILE_JSON);
if (!json.isEmpty()) {
bool resave = false;
bool result = JsonSettingsIO::loadWifi(*this, json.c_str(), &resave);
if (result && resave) {
LOG_DBG("WCS", "Resaving JSON with obfuscated passwords");
saveToFile();
}
return result;
}
}
// Fall back to binary migration
if (Storage.exists(WIFI_FILE_BIN)) {
if (loadFromBinaryFile()) {
if (saveToFile()) {
Storage.rename(WIFI_FILE_BIN, WIFI_FILE_BAK);
LOG_DBG("WCS", "Migrated wifi.bin to wifi.json");
return true;
} else {
LOG_ERR("WCS", "Failed to save wifi during migration");
return false;
}
}
}
return false;
}
bool WifiCredentialStore::loadFromBinaryFile() {
HalFile file;
if (!Storage.openFileForRead("WCS", WIFI_FILE_BIN, file)) {
return false;
}
uint8_t version;
serialization::readPod(file, version);
if (version > WIFI_FILE_VERSION) {
LOG_DBG("WCS", "Unknown file version: %u", version);
return false;
}
if (version >= 2) {
serialization::readString(file, lastConnectedSsid);
} else {
lastConnectedSsid.clear();
}
uint8_t count;
serialization::readPod(file, count);
bool WifiCredentialStore::fromJson(JsonVariantConst doc) {
lastConnectedSsid = doc["lastConnectedSsid"] | "";
// Tolerate a missing/invalid 'credentials' key (treat as empty list); only
// a JSON parse error is fatal. A null JsonArray iterates zero times.
credentials.clear();
credentials.reserve(std::min<size_t>(count, MAX_NETWORKS));
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
JsonArrayConst arr = doc["credentials"].as<JsonArrayConst>();
credentials.reserve(std::min(arr.size(), MAX_NETWORKS));
bool needsResave = false;
for (JsonObjectConst obj : arr) {
if (credentials.size() >= MAX_NETWORKS) break;
WifiCredential cred;
serialization::readString(file, cred.ssid);
serialization::readString(file, cred.password);
legacyDeobfuscate(cred.password);
cred.ssid = obj["ssid"] | "";
cred.password = extractPassword(obj, needsResave);
credentials.push_back(cred);
}
// LOG_DBG("WCS", "Loaded %zu WiFi credentials from binary file", credentials.size());
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size());
if (needsResave) {
LOG_DBG("WCS", "Resaving JSON with obfuscated passwords");
saveToFile();
}
return true;
}
+8 -22
View File
@@ -1,4 +1,7 @@
#pragma once
#include <ArduinoJson.h>
#include <PersistableStore.h>
#include <string>
#include <vector>
@@ -7,21 +10,14 @@ struct WifiCredential {
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
};
class WifiCredentialStore;
namespace JsonSettingsIO {
bool saveWifi(const WifiCredentialStore& store, const char* path);
bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave);
} // namespace JsonSettingsIO
/**
* Singleton class for storing WiFi credentials on the SD card.
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
* and base64-encoded before writing to JSON (not cryptographically secure,
* but prevents casual reading and ties credentials to the specific device).
*/
class WifiCredentialStore {
class WifiCredentialStore : public PersistableStore<WifiCredentialStore> {
private:
static WifiCredentialStore instance;
std::vector<WifiCredential> credentials;
std::string lastConnectedSsid;
@@ -30,22 +26,12 @@ class WifiCredentialStore {
// Private constructor for singleton
WifiCredentialStore() = default;
bool loadFromBinaryFile();
friend bool JsonSettingsIO::saveWifi(const WifiCredentialStore&, const char*);
friend bool JsonSettingsIO::loadWifi(WifiCredentialStore&, const char*, bool*);
friend class PersistableStore<WifiCredentialStore>;
public:
// Delete copy constructor and assignment
WifiCredentialStore(const WifiCredentialStore&) = delete;
WifiCredentialStore& operator=(const WifiCredentialStore&) = delete;
// Get singleton instance
static WifiCredentialStore& getInstance() { return instance; }
// Save/load from SD card
bool saveToFile() const;
bool loadFromFile();
static const char* getFilePath() { return "/.crosspoint/wifi.json"; }
void toJson(JsonDocument& doc) const;
bool fromJson(JsonVariantConst doc);
// Credential management
bool addCredential(const std::string& ssid, const std::string& password);
+29 -8
View File
@@ -939,6 +939,15 @@ void EpubReaderActivity::render(RenderLock&& lock) {
GUI.drawPopup(renderer, tr(STR_SAVE_PROGRESS_FAILED));
};
// A section build failure (e.g. an invalid/corrupt EPUB that fails XML parsing) leaves the
// "Indexing" popup on screen with no way forward. Surface an explicit error instead of hanging.
// clearScreen first so the error popup doesn't overlay the stale "Indexing" popup.
const auto showBuildError = [this]() {
renderer.clearScreen();
GUI.drawPopup(renderer, tr(STR_INDEX_FAILED));
automaticPageTurnActive = false;
};
// edge case handling for sub-zero spine index
if (currentSpineIndex < 0) {
currentSpineIndex = 0;
@@ -1086,7 +1095,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
showPendingSyncSaveError();
showBuildError();
return;
}
} else {
@@ -1139,7 +1148,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to start section build");
section.reset();
showPendingSyncSaveError();
showBuildError();
return;
}
while (!section->isBuildComplete() &&
@@ -1150,7 +1159,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
showPendingSyncSaveError();
showBuildError();
return;
}
}
@@ -1204,7 +1213,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
SETTINGS.focusReadingEnabled)) {
LOG_ERR("ERS", "Failed to start partial extension build");
section.reset();
showPendingSyncSaveError();
showBuildError();
return;
}
// Extend until either the target page exists or the build completes.
@@ -1212,7 +1221,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
showPendingSyncSaveError();
showBuildError();
return;
}
}
@@ -1223,7 +1232,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
showPendingSyncSaveError();
showBuildError();
return;
}
}
@@ -1273,17 +1282,29 @@ void EpubReaderActivity::render(RenderLock&& lock) {
auto p = section->loadPage(section->currentPage);
if (!p) {
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
automaticPageTurnActive = false;
// Retrying rebuilds a transiently corrupt section and usually recovers, but a page that keeps
// failing would loop forever on a blank screen, so bound the retries before giving up.
const bool giveUp = ++pageLoadRetryCount > MAX_PAGE_LOAD_RETRIES;
// Abandon (not suspend) any active build BEFORE clearing: clearCache deletes the files,
// and the destructor's suspend would otherwise commit tables into a deleted handle.
section->abandonBuild();
section->clearCache();
section.reset();
if (giveUp) {
LOG_ERR("ERS", "Page load retry limit reached, aborting");
pageLoadRetryCount = 0; // Reset so a later user-initiated navigation can try afresh
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD);
renderer.displayBuffer();
showPendingSyncSaveError();
return;
}
requestUpdate(); // Try again after clearing cache
// TODO: prevent infinite loop if the page keeps failing to load for some reason
automaticPageTurnActive = false;
showPendingSyncSaveError();
return;
}
pageLoadRetryCount = 0; // Reset the retry counter once a page loads cleanly
// Collect footnotes from the loaded page
currentPageFootnotes = std::move(p->footnotes);
@@ -32,6 +32,10 @@ class EpubReaderActivity final : public Activity {
float pendingSpineProgress = 0.0f;
bool pendingScreenshot = false;
bool pendingSyncSaveError = false;
// Consecutive page-load failures. Each failure drops the section and rebuilds on the next render,
// which recovers a transiently corrupt cache; capped so a persistently bad page can't spin forever.
uint8_t pageLoadRetryCount = 0;
static constexpr uint8_t MAX_PAGE_LOAD_RETRIES = 3;
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
bool automaticPageTurnActive = false;
bool showBookmarkMessage = false;
+76 -2
View File
@@ -169,6 +169,27 @@
.modal.picker-mode {
max-width: 920px;
}
.modal.image-preview-mode {
max-width: 640px;
}
.image-preview-stage {
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
margin-bottom: 15px;
border-radius: 6px;
}
.image-preview-stage img {
max-width: 100%;
max-height: 65vh;
object-fit: contain;
}
#imagePreviewDownload {
display: inline-block;
width: auto;
text-decoration: none;
}
.picker-columns.picker-active {
display: flex;
flex-direction: row;
@@ -1779,6 +1800,21 @@
</div>
</div>
<!-- Image Preview Modal -->
<div class="modal-overlay" id="imagePreviewModal">
<div class="modal image-preview-mode">
<button class="modal-close" onclick="closeImagePreview()">&times;</button>
<h3>🖼️ Preview</h3>
<div class="folder-form">
<p class="file-info"><strong id="imagePreviewName"></strong></p>
<div class="image-preview-stage">
<img id="imagePreviewImg" alt="">
</div>
<a id="imagePreviewDownload" class="move-btn-confirm" rel="noopener noreferrer" target="_blank" href="#">Download</a>
</div>
</div>
</div>
<script>
// get current path from query parameter
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
@@ -1886,6 +1922,7 @@
if (overlay.id === 'deleteModal') return closeDeleteModal();
if (overlay.id === 'renameModal') return closeRenameModal();
if (overlay.id === 'moveModal') return closeMoveModal();
if (overlay.id === 'imagePreviewModal') return closeImagePreview();
overlay.classList.remove('open');
}
});
@@ -1998,8 +2035,13 @@
// Checkbox cell + file row
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
fileTableContent += `<td><input type="checkbox" class="select-item" data-path="${encodeURIComponent(filePath)}" data-name="${escapeHtml(file.name)}" data-type="file"></td>`;
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>`;
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="/download?path=${encodeURIComponent(filePath)}" class="file-link">${escapeHtml(file.name)}</a>`;
const fileIsImage = isImageFile(file.name);
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : (fileIsImage ? '🖼️' : '📄')}</span>`;
if (fileIsImage) {
fileTableContent += `<a href="${downloadUrl(filePath)}" class="file-link image-preview-link">${escapeHtml(file.name)}</a>`;
} else {
fileTableContent += `<a rel="noopener noreferrer" target="_blank" href="${downloadUrl(filePath)}" class="file-link">${escapeHtml(file.name)}</a>`;
}
fileTableContent += '</td>';
fileTableContent += file.isEpub ? '<td><span class="epub-badge">EPUB</span></td>' : `<td>${escapeHtml(file.name.split('.').pop().toUpperCase())}</td>`;
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
@@ -2017,6 +2059,30 @@
}
}
// Image preview
function isImageFile(name) {
return /\.(png|jpe?g|bmp|gif|webp)$/i.test(name);
}
function downloadUrl(filePath) {
return `/download?path=${encodeURIComponent(filePath)}`;
}
function openImagePreview(url, name) {
const img = document.getElementById('imagePreviewImg');
document.getElementById('imagePreviewName').textContent = name;
img.src = url;
img.alt = name;
document.getElementById('imagePreviewDownload').href = url;
document.getElementById('imagePreviewModal').classList.add('open');
}
function closeImagePreview() {
document.getElementById('imagePreviewModal').classList.remove('open');
// Clear src to free the decoded image and avoid showing the stale one on reopen.
document.getElementById('imagePreviewImg').src = '';
}
// Modal functions
function openUploadModal() {
// Reset converter variables to defaults
@@ -2892,6 +2958,14 @@
// Initialize quality settings handlers
document.addEventListener('DOMContentLoaded', function() {
// Delegated so it survives file-table re-renders (avoids inline onclick with untrusted names).
document.getElementById('file-table').addEventListener('click', function(e) {
const link = e.target.closest('.image-preview-link');
if (!link) return;
e.preventDefault();
openImagePreview(link.getAttribute('href'), link.textContent);
});
const qualitySlider = document.getElementById('qualitySlider');
const qualityInput = document.getElementById('qualityInput');