#include "JsonSettingsIO.h" #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "CrossPointState.h" #include "KOReaderCredentialStore.h" #include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "SettingsList.h" #include "WifiCredentialStore.h" // Convert legacy settings. void applyLegacyStatusBarSettings(CrossPointSettings& settings) { switch (static_cast(settings.statusBar)) { case CrossPointSettings::NONE: settings.statusBarChapterPageCount = 0; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarLowerProgressBar = 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.statusBarLowerProgressBar = 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.statusBarLowerProgressBar = 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.statusBarLowerProgressBar = 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.statusBarLowerProgressBar = 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.statusBarLowerProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarBattery = 1; break; } settings.statusBarUpperProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarUpperProgressBarThickness = CrossPointSettings::PROGRESS_BAR_NORMAL; settings.statusBarLowerProgressBarThickness = settings.statusBarProgressBarThickness; settings.statusBarItemsPosition = CrossPointSettings::STATUS_BAR_ITEMS_BOTTOM; } // ---- CrossPointState ---- bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { JsonDocument doc; doc["openEpubPath"] = s.openEpubPath; doc["lastSleepImage"] = s.lastSleepImage; doc["readerActivityLoadCount"] = s.readerActivityLoadCount; doc["lastSleepFromReader"] = s.lastSleepFromReader; // Information about a pending KOReader sync session JsonObject sync = doc["koReaderSyncSession"].to(); sync["active"] = s.koReaderSyncSession.active; sync["epubPath"] = s.koReaderSyncSession.epubPath; sync["spineIndex"] = s.koReaderSyncSession.spineIndex; sync["page"] = s.koReaderSyncSession.page; sync["totalPagesInSpine"] = s.koReaderSyncSession.totalPagesInSpine; sync["paragraphIndex"] = s.koReaderSyncSession.paragraphIndex; sync["hasParagraphIndex"] = s.koReaderSyncSession.hasParagraphIndex; sync["xhtmlSeekHint"] = s.koReaderSyncSession.xhtmlSeekHint; sync["intent"] = static_cast(s.koReaderSyncSession.intent); sync["outcome"] = static_cast(s.koReaderSyncSession.outcome); sync["resultSpineIndex"] = s.koReaderSyncSession.resultSpineIndex; sync["resultPage"] = s.koReaderSyncSession.resultPage; sync["resultParagraphIndex"] = s.koReaderSyncSession.resultParagraphIndex; sync["resultHasParagraphIndex"] = s.koReaderSyncSession.resultHasParagraphIndex; sync["exitToHomeAfterSync"] = s.koReaderSyncSession.exitToHomeAfterSync; sync["autoPullEpubPath"] = s.koReaderSyncSession.autoPullEpubPath; // Information about a pending bookmark jump JsonObject jump = doc["pendingBookmarkJump"].to(); jump["active"] = s.pendingBookmarkJump.active; jump["bookPath"] = s.pendingBookmarkJump.bookPath; jump["spineIndex"] = s.pendingBookmarkJump.spineIndex; jump["pageNumber"] = s.pendingBookmarkJump.pageNumber; if (doc.overflowed()) { LOG_ERR("CPS", "JSON document overflowed while building state"); return false; } FsFile file; if (!Storage.openFileForWrite("CPS", path, file)) { return false; } const size_t expected = measureJson(doc); const size_t written = serializeJson(doc, file); file.flush(); if (!file.close()) { return false; } return written == expected; } 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(""); s.lastSleepImage = doc["lastSleepImage"] | SIZE_MAX; s.readerActivityLoadCount = doc["readerActivityLoadCount"] | (uint8_t)0; s.lastSleepFromReader = doc["lastSleepFromReader"] | false; JsonObject sync = doc["koReaderSyncSession"].as(); s.koReaderSyncSession.active = sync["active"] | false; s.koReaderSyncSession.epubPath = sync["epubPath"] | std::string(""); s.koReaderSyncSession.spineIndex = sync["spineIndex"] | 0; s.koReaderSyncSession.page = sync["page"] | 0; s.koReaderSyncSession.totalPagesInSpine = sync["totalPagesInSpine"] | 0; s.koReaderSyncSession.paragraphIndex = sync["paragraphIndex"] | (uint16_t)0; s.koReaderSyncSession.hasParagraphIndex = sync["hasParagraphIndex"] | false; s.koReaderSyncSession.xhtmlSeekHint = sync["xhtmlSeekHint"] | (uint32_t)0; s.koReaderSyncSession.intent = static_cast(sync["intent"] | static_cast(KOReaderSyncIntentState::COMPARE)); s.koReaderSyncSession.outcome = static_cast(sync["outcome"] | static_cast(KOReaderSyncOutcomeState::NONE)); s.koReaderSyncSession.resultSpineIndex = sync["resultSpineIndex"] | 0; s.koReaderSyncSession.resultPage = sync["resultPage"] | 0; s.koReaderSyncSession.resultParagraphIndex = sync["resultParagraphIndex"] | (uint16_t)0; s.koReaderSyncSession.resultHasParagraphIndex = sync["resultHasParagraphIndex"] | false; s.koReaderSyncSession.exitToHomeAfterSync = sync["exitToHomeAfterSync"] | false; s.koReaderSyncSession.autoPullEpubPath = sync["autoPullEpubPath"] | std::string(""); if (s.koReaderSyncSession.autoPullEpubPath.empty() && (sync["autoPullOnOpen"] | false)) { LOG_DBG("CPS", "Legacy autoPullOnOpen state found without epubPath - ignoring"); } JsonObject jump = doc["pendingBookmarkJump"].as(); s.pendingBookmarkJump.active = jump["active"] | false; s.pendingBookmarkJump.bookPath = jump["bookPath"] | std::string(""); s.pendingBookmarkJump.spineIndex = jump["spineIndex"] | (uint16_t)0; s.pendingBookmarkJump.pageNumber = jump["pageNumber"] | (uint16_t)0; 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 a DynamicEnumCtx in SettingsList (no valuePtr) so the generic // loop above skips it. Save manually. doc["fontFamily"] = s.fontFamily; if (s.sdFontFamilyName[0] != '\0') { doc["sdFontFamilyName"] = s.sdFontFamilyName; } 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()) { s.statusBar = clamp(doc["statusBar"] | s.statusBar, CrossPointSettings::STATUS_BAR_MODE_COUNT, s.statusBar); applyLegacyStatusBarSettings(s); if (needsResave) *needsResave = true; } auto migrateMissingStatusSetting = [&doc, &needsResave, &clamp](const char* newKey, uint8_t& value, const char* legacyKey, uint8_t defaultValue, uint8_t count) { if (!doc[newKey].isNull()) { return; } value = clamp(doc[legacyKey] | defaultValue, count, defaultValue); if (needsResave) *needsResave = true; }; migrateMissingStatusSetting("statusBarUpperProgressBar", s.statusBarUpperProgressBar, "statusBarUpperProgressBar", CrossPointSettings::HIDE_PROGRESS, CrossPointSettings::STATUS_BAR_PROGRESS_BAR_COUNT); migrateMissingStatusSetting("statusBarUpperProgressBarThickness", s.statusBarUpperProgressBarThickness, "statusBarUpperProgressBarThickness", CrossPointSettings::PROGRESS_BAR_NORMAL, CrossPointSettings::STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT); migrateMissingStatusSetting("statusBarLowerProgressBar", s.statusBarLowerProgressBar, "statusBarProgressBar", s.statusBarProgressBar, CrossPointSettings::STATUS_BAR_PROGRESS_BAR_COUNT); migrateMissingStatusSetting("statusBarLowerProgressBarThickness", s.statusBarLowerProgressBarThickness, "statusBarProgressBarThickness", s.statusBarProgressBarThickness, CrossPointSettings::STATUS_BAR_PROGRESS_BAR_THICKNESS_COUNT); migrateMissingStatusSetting("statusBarItemsPosition", s.statusBarItemsPosition, "statusBarItemsPosition", CrossPointSettings::STATUS_BAR_ITEMS_BOTTOM, CrossPointSettings::STATUS_BAR_ITEMS_POSITION_COUNT); 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; } } // 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 a DynamicEnumCtx in SettingsList (no valuePtr) so the generic // loop above skips it. Load manually. s.fontFamily = clamp(doc["fontFamily"] | (uint8_t)CrossPointSettings::BOOKERLY, CrossPointSettings::BUILTIN_FONT_COUNT, CrossPointSettings::BOOKERLY); const char* sfn = doc["sdFontFamilyName"] | ""; strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1); s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0'; LOG_DBG("CPS", "Settings loaded from file"); return true; } // ---- KOReaderCredentialStore ---- bool JsonSettingsIO::saveKOReader(const KOReaderCredentialStore& store, const char* path) { JsonDocument doc; doc["username"] = store.getUsername(); doc["password_obf"] = obfuscation::obfuscateToBase64(store.getPassword()); doc["serverUrl"] = store.getServerUrl(); doc["matchMethod"] = static_cast(store.getMatchMethod()); doc["sendMetadata"] = store.getSendMetadata(); String json; serializeJson(doc, json); return Storage.writeFile(path, json); } bool JsonSettingsIO::loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave) { if (needsResave) *needsResave = false; JsonDocument doc; auto error = deserializeJson(doc, json); if (error) { LOG_ERR("KRS", "JSON parse error: %s", error.c_str()); return false; } store.username = doc["username"] | std::string(""); bool ok = false; store.password = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok); if (!ok || store.password.empty()) { store.password = doc["password"] | std::string(""); if (!store.password.empty() && needsResave) *needsResave = true; } store.serverUrl = doc["serverUrl"] | std::string(""); uint8_t method = doc["matchMethod"] | (uint8_t)0; store.matchMethod = static_cast(method); store.sendMetadata = doc["sendMetadata"] | false; LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", store.username.c_str()); return true; } // ---- WifiCredentialStore ---- bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) { JsonDocument doc; doc["lastConnectedSsid"] = store.getLastConnectedSsid(); doc["lastKnownMacAddress"] = store.getLastKnownMacAddress(); JsonArray arr = doc["credentials"].to(); for (const auto& cred : store.getCredentials()) { JsonObject obj = arr.add(); 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(""); const auto isValidDashedMac = [](const std::string& value) -> bool { if (value.empty()) { return true; } if (value.size() != 17) { return false; } for (size_t i = 0; i < value.size(); i++) { if (i == 2 || i == 5 || i == 8 || i == 11 || i == 14) { if (value[i] != '-') { return false; } } else if (!std::isxdigit(static_cast(value[i]))) { return false; } } return true; }; store.lastKnownMacAddress = doc["lastKnownMacAddress"] | std::string(""); if (!isValidDashedMac(store.lastKnownMacAddress)) { store.lastKnownMacAddress.clear(); if (needsResave) { *needsResave = true; } } store.credentials.clear(); JsonArray arr = doc["credentials"].as(); 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(); for (const auto& book : store.getBooks()) { JsonObject obj = arr.add(); obj["path"] = book.path; obj["title"] = book.title; obj["author"] = book.author; obj["series"] = book.series; obj["coverBmpPath"] = book.coverBmpPath; obj["embeddedStyleOverride"] = book.embeddedStyleOverride; obj["imageRenderingOverride"] = book.imageRenderingOverride; obj["fontFamilyOverride"] = book.fontFamilyOverride; if (!book.sdFontFamilyOverride.empty()) { obj["sdFontFamilyOverride"] = book.sdFontFamilyOverride; } obj["fontSizeOverride"] = book.fontSizeOverride; obj["bionicReadingOverride"] = book.bionicReadingOverride; } 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(); auto clampInt8 = [](int value, int minValue, int maxValue, int8_t fallback) -> int8_t { if (value < minValue || value > maxValue) { return fallback; } return static_cast(value); }; 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.series = obj["series"] | std::string(""); book.coverBmpPath = obj["coverBmpPath"] | std::string(""); book.embeddedStyleOverride = clampInt8(obj["embeddedStyleOverride"] | -1, -1, 1, -1); book.imageRenderingOverride = clampInt8(obj["imageRenderingOverride"] | -1, -1, 2, -1); book.fontFamilyOverride = clampInt8(obj["fontFamilyOverride"] | -1, -1, CrossPointSettings::FONT_FAMILY_COUNT - 1, -1); book.sdFontFamilyOverride = obj["sdFontFamilyOverride"] | std::string(""); if (!book.sdFontFamilyOverride.empty()) { // Keep built-in and SD font overrides mutually exclusive. book.fontFamilyOverride = -1; } book.fontSizeOverride = clampInt8(obj["fontSizeOverride"] | -1, -1, CrossPointSettings::FONT_SIZE_COUNT - 1, -1); book.bionicReadingOverride = clampInt8(obj["bionicReadingOverride"] | -1, -1, 1, -1); 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(); for (const auto& server : store.getServers()) { JsonObject obj = arr.add(); 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(); 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; }