Files
Crosspoint/src/JsonSettingsIO.cpp
T
WuTofuandJulia ebf0413fe1 feat: add custom sleep timer picker (#2206)
## Summary

* **What is the goal of this PR?**
Mainly fixes #2196, but also allows users to set a sleep time of 1~30
min or to never sleep.

* **What changes are included?**

1. Revert changes from #1948 and #2137 (Sorry @Uri-Tauber)
2. Cherrypick changes from
https://github.com/uxjulia/CrossInk/commit/0cb91c3dd1c63f0559d4392fadc4a8340af5bdde
and
https://github.com/uxjulia/CrossInk/commit/4e6bd634b6ede51ff1a7107ba3e8efcd152bc6ee
for a timeout interval picker UI, the custom sleep time feature, and
migrate the existing setting in v1.3.0, `sleepTimeout`, to
`sleepTimeoutMinutes` (Thanks! @uxjulia)
3. Add a "Never" at the far right of the timeout interval picker

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_

---------

Co-authored-by: Julia <julia@uxj.io>
2026-05-30 08:28:07 -04:00

446 lines
17 KiB
C++

#include "JsonSettingsIO.h"
#include <ArduinoJson.h>
#include <HalStorage.h>
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <cstring>
#include <string>
#include "BookmarkEntry.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "OpdsServerStore.h"
#include "RecentBooksStore.h"
#include "SettingsList.h"
#include "WifiCredentialStore.h"
// Convert legacy settings.
void applyLegacyStatusBarSettings(CrossPointSettings& settings) {
switch (static_cast<CrossPointSettings::STATUS_BAR_MODE>(settings.statusBar)) {
case CrossPointSettings::NONE:
settings.statusBarChapterPageCount = 0;
settings.statusBarBookProgressPercentage = 0;
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
settings.statusBarBattery = 0;
break;
case CrossPointSettings::NO_PROGRESS:
settings.statusBarChapterPageCount = 0;
settings.statusBarBookProgressPercentage = 0;
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
settings.statusBarBattery = 1;
break;
case CrossPointSettings::BOOK_PROGRESS_BAR:
settings.statusBarChapterPageCount = 1;
settings.statusBarBookProgressPercentage = 0;
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
settings.statusBarBattery = 1;
break;
case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR:
settings.statusBarChapterPageCount = 1;
settings.statusBarBookProgressPercentage = 0;
settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS;
settings.statusBarTitle = CrossPointSettings::HIDE_TITLE;
settings.statusBarBattery = 0;
break;
case CrossPointSettings::CHAPTER_PROGRESS_BAR:
settings.statusBarChapterPageCount = 0;
settings.statusBarBookProgressPercentage = 1;
settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS;
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
settings.statusBarBattery = 1;
break;
case CrossPointSettings::FULL:
default:
settings.statusBarChapterPageCount = 1;
settings.statusBarBookProgressPercentage = 1;
settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS;
settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE;
settings.statusBarBattery = 1;
break;
}
}
// ---- CrossPointState ----
bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) {
JsonDocument doc;
doc["openEpubPath"] = s.openEpubPath;
JsonArray recentArr = doc["recentSleepImages"].to<JsonArray>();
for (int i = 0; i < CrossPointState::SLEEP_RECENT_COUNT; i++) recentArr.add(s.recentSleepImages[i]);
doc["recentSleepPos"] = s.recentSleepPos;
doc["recentSleepFill"] = s.recentSleepFill;
doc["readerActivityLoadCount"] = s.readerActivityLoadCount;
doc["lastSleepFromReader"] = s.lastSleepFromReader;
doc["showBootScreen"] = s.showBootScreen;
String json;
serializeJson(doc, json);
return Storage.writeFile(path, json);
}
bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) {
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("CPS", "JSON parse error: %s", error.c_str());
return false;
}
s.openEpubPath = doc["openEpubPath"] | std::string("");
memset(s.recentSleepImages, 0, sizeof(s.recentSleepImages));
JsonArrayConst recentArr = doc["recentSleepImages"];
const int actualCount = recentArr.isNull() ? 0
: std::min(static_cast<int>(recentArr.size()),
static_cast<int>(CrossPointState::SLEEP_RECENT_COUNT));
for (int i = 0; i < actualCount; i++) s.recentSleepImages[i] = recentArr[i] | static_cast<uint16_t>(0);
s.recentSleepPos = doc["recentSleepPos"] | static_cast<uint8_t>(0);
if (s.recentSleepPos >= CrossPointState::SLEEP_RECENT_COUNT)
s.recentSleepPos = actualCount > 0 ? s.recentSleepPos % CrossPointState::SLEEP_RECENT_COUNT : 0;
s.recentSleepFill = doc["recentSleepFill"] | static_cast<uint8_t>(0);
s.recentSleepFill = static_cast<uint8_t>(std::min(static_cast<int>(s.recentSleepFill), actualCount));
// Migrate legacy single-image field from old state.json (pre-recency-buffer).
// Only seeds the buffer if the new buffer is empty (fresh migration, not a resave).
if (s.recentSleepFill == 0 && !doc["lastSleepImage"].isNull()) {
const uint8_t legacy = doc["lastSleepImage"] | static_cast<uint8_t>(UINT8_MAX);
if (legacy != UINT8_MAX) s.pushRecentSleep(static_cast<uint16_t>(legacy));
}
s.readerActivityLoadCount = doc["readerActivityLoadCount"] | static_cast<uint8_t>(0);
s.lastSleepFromReader = doc["lastSleepFromReader"] | false;
s.showBootScreen = doc["showBootScreen"] | true;
return true;
}
// ---- CrossPointSettings ----
bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) {
JsonDocument doc;
for (const auto& info : getSettingsList()) {
if (!info.key) continue;
// Dynamic entries (KOReader etc.) are stored in their own files — skip.
if (!info.valuePtr && !info.stringOffset) continue;
if (info.stringOffset) {
const char* strPtr = (const char*)&s + info.stringOffset;
if (info.obfuscated) {
doc[std::string(info.key) + "_obf"] = obfuscation::obfuscateToBase64(strPtr);
} else {
doc[info.key] = strPtr;
}
} else {
doc[info.key] = s.*(info.valuePtr);
}
}
// Front button remap — managed by RemapFrontButtons sub-activity, not in SettingsList.
doc["frontButtonBack"] = s.frontButtonBack;
doc["frontButtonConfirm"] = s.frontButtonConfirm;
doc["frontButtonLeft"] = s.frontButtonLeft;
doc["frontButtonRight"] = s.frontButtonRight;
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
doc["fontFamily"] = s.fontFamily;
// SD card font family name — not in SettingsList, save manually
if (s.sdFontFamilyName[0] != '\0') {
doc["sdFontFamilyName"] = s.sdFontFamilyName;
}
// Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
// Language -- managed by LanguageSelectActivity, not in SettingsList.
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
String json;
serializeJson(doc, json);
return Storage.writeFile(path, json);
}
bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* needsResave) {
if (needsResave) *needsResave = false;
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("CPS", "JSON parse error: %s", error.c_str());
return false;
}
auto clamp = [](uint8_t val, uint8_t maxVal, uint8_t def) -> uint8_t { return val < maxVal ? val : def; };
// Legacy migration: if statusBarChapterPageCount is absent this is a pre-refactor settings file.
// Populate s with migrated values now so the generic loop below picks them up as defaults and clamps them.
if (doc["statusBarChapterPageCount"].isNull()) {
applyLegacyStatusBarSettings(s);
}
for (const auto& info : getSettingsList()) {
if (!info.key) continue;
// Dynamic entries (KOReader etc.) are stored in their own files — skip.
if (!info.valuePtr && !info.stringOffset) continue;
if (info.stringOffset) {
const char* strPtr = (const char*)&s + info.stringOffset;
const std::string fieldDefault = strPtr; // current buffer = struct-initializer default
std::string val;
if (info.obfuscated) {
bool ok = false;
val = obfuscation::deobfuscateFromBase64(doc[std::string(info.key) + "_obf"] | "", &ok);
if (!ok || val.empty()) {
val = doc[info.key] | fieldDefault;
if (val != fieldDefault && needsResave) *needsResave = true;
}
} else {
val = doc[info.key] | fieldDefault;
}
char* destPtr = (char*)&s + info.stringOffset;
if (info.stringMaxLen == 0) {
LOG_ERR("CPS", "Misconfigured SettingInfo: stringMaxLen is 0 for key '%s'", info.key);
destPtr[0] = '\0';
if (needsResave) *needsResave = true;
continue;
}
strncpy(destPtr, val.c_str(), info.stringMaxLen - 1);
destPtr[info.stringMaxLen - 1] = '\0';
} else {
const uint8_t fieldDefault = s.*(info.valuePtr); // struct-initializer default, read before we overwrite it
uint8_t v = doc[info.key] | fieldDefault;
if (info.type == SettingType::ENUM) {
v = clamp(v, (uint8_t)info.enumValues.size(), fieldDefault);
} else if (info.type == SettingType::TOGGLE) {
v = clamp(v, (uint8_t)2, fieldDefault);
} else if (info.type == SettingType::VALUE) {
if (v < info.valueRange.min)
v = info.valueRange.min;
else if (v > info.valueRange.max)
v = info.valueRange.max;
}
s.*(info.valuePtr) = v;
}
}
if (doc["sleepTimeoutMinutes"].isNull() && !doc["sleepTimeout"].isNull()) {
const uint8_t legacyValue =
clamp(doc["sleepTimeout"] | (uint8_t)CrossPointSettings::SLEEP_10_MIN, CrossPointSettings::SLEEP_TIMEOUT_COUNT,
(uint8_t)CrossPointSettings::SLEEP_10_MIN);
s.sleepTimeoutMinutes = CrossPointSettings::sleepTimeoutEnumToMinutes(legacyValue);
if (needsResave) *needsResave = true;
}
// Front button remap — managed by RemapFrontButtons sub-activity, not in SettingsList.
using S = CrossPointSettings;
s.frontButtonBack =
clamp(doc["frontButtonBack"] | (uint8_t)S::FRONT_HW_BACK, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_BACK);
s.frontButtonConfirm = clamp(doc["frontButtonConfirm"] | (uint8_t)S::FRONT_HW_CONFIRM, S::FRONT_BUTTON_HARDWARE_COUNT,
S::FRONT_HW_CONFIRM);
s.frontButtonLeft =
clamp(doc["frontButtonLeft"] | (uint8_t)S::FRONT_HW_LEFT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_LEFT);
s.frontButtonRight =
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
CrossPointSettings::validateFrontButtonMapping(s);
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
s.fontFamily = clamp(doc["fontFamily"] | (uint8_t)0, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
// SD card font family name — not in SettingsList, load manually
const char* sfn = doc["sdFontFamilyName"] | "";
strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1);
s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0';
// Language -- stored as code string for stability across enum reorders.
if (doc["language"].is<const char*>()) {
s.language = static_cast<uint8_t>(I18n::languageFromCode(doc["language"].as<const char*>()));
}
LOG_DBG("CPS", "Settings loaded from file");
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>();
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) {
JsonDocument doc;
JsonArray arr = doc["bookmarks"].to<JsonArray>();
LOG_DBG("BKM", "Saving %zu bookmarks to file", bookmarks.size());
for (const auto& bookmark : bookmarks) {
JsonObject obj = arr.add<JsonObject>();
obj["xpath"] = bookmark.xpath;
obj["percentage"] = bookmark.percentage;
obj["summary"] = bookmark.summary;
}
String json;
serializeJson(doc, json);
return Storage.writeFile(path, json);
}
bool JsonSettingsIO::loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json) {
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("BKM", "JSON parse error: %s", error.c_str());
return false;
}
JsonArray arr = doc["bookmarks"].as<JsonArray>();
bookmarks.clear();
bookmarks.reserve(arr.size());
for (JsonObject obj : arr) {
bookmarks.emplace_back();
auto& bookmark = bookmarks.back();
bookmark.xpath = obj["xpath"] | std::string("");
bookmark.percentage = obj["percentage"] | static_cast<float>(0);
bookmark.summary = obj["summary"] | std::string("");
}
LOG_DBG("BKM", "Loaded %zu bookmarks from file", bookmarks.size());
return true;
}