#include "CrossPointSettings.h" #include #include #include #include #include #include #include #include #include "I18nKeys.h" #include "ReaderFontSizes.h" #include "SettingsList.h" #include "fontIds.h" namespace { // Stack buffer for "_obf" key construction — avoids a std::string // allocation per obfuscated setting on every save and load. constexpr size_t OBF_KEY_BUF = 64; // Null-terminated copy into a fixed-size settings field. void copyToField(char* dest, const char* src, const size_t maxLen) { strncpy(dest, src, maxLen - 1); dest[maxLen - 1] = '\0'; } } // namespace void CrossPointSettings::validateFrontButtonMapping(CrossPointSettings& settings) { const uint8_t mapping[] = {settings.frontButtonBack, settings.frontButtonConfirm, settings.frontButtonLeft, settings.frontButtonRight}; for (size_t i = 0; i < 4; i++) { for (size_t j = i + 1; j < 4; j++) { if (mapping[i] == mapping[j]) { settings.frontButtonBack = FRONT_HW_BACK; settings.frontButtonConfirm = FRONT_HW_CONFIRM; settings.frontButtonLeft = FRONT_HW_LEFT; settings.frontButtonRight = FRONT_HW_RIGHT; return; } } } } uint8_t CrossPointSettings::sleepTimeoutEnumToMinutes(const uint8_t legacyValue) { switch (legacyValue) { case SLEEP_1_MIN: return 1; case SLEEP_5_MIN: return 5; case SLEEP_15_MIN: return 15; case SLEEP_30_MIN: return 30; case SLEEP_10_MIN: default: return 10; } } void CrossPointSettings::toJson(JsonDocument& doc) const { const CrossPointSettings& s = *this; 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) { char obfKey[OBF_KEY_BUF]; snprintf(obfKey, sizeof(obfKey), "%s_obf", info.key); doc[obfKey] = 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"] = frontButtonBack; doc["frontButtonConfirm"] = frontButtonConfirm; doc["frontButtonLeft"] = frontButtonLeft; doc["frontButtonRight"] = frontButtonRight; // Font family and size — both use dynamic getter/setters in SettingsList (the // option lists depend on the SD font registry), so the generic loop skips them. doc["fontFamily"] = fontFamily; doc["fontSize"] = fontPointSize; // SD card font family name — not in SettingsList, save manually if (sdFontFamilyName[0] != '\0') { doc["sdFontFamilyName"] = sdFontFamilyName; } // Dictionary folder name — uses dynamic getter/setter in SettingsList, save manually if (dictionaryName[0] != '\0') { doc["dictionaryName"] = dictionaryName; } // Language -- managed by LanguageSelectActivity, not in SettingsList. // Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders. doc["language"] = (language < getLanguageCount()) ? LANGUAGE_CODES[language] : "EN"; // A uint16_t mask, so it does not fit the uint8_t generic loop. Omitted while // unconfigured, so the default keeps following the UI language. if (keyboardLayouts != 0) { doc["keyboardLayouts"] = keyboardLayouts; } } bool CrossPointSettings::fromJson(JsonVariantConst doc) { CrossPointSettings& s = *this; bool needsResave = false; auto clamp = [](uint8_t val, uint8_t maxVal, uint8_t def) -> uint8_t { return val < maxVal ? val : def; }; 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) { // destPtr starts out holding the struct-initializer default; it stays that // way unless the document actually carries a value for this key. 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'; needsResave = true; continue; } bool loaded = false; if (info.obfuscated) { char obfKey[OBF_KEY_BUF]; snprintf(obfKey, sizeof(obfKey), "%s_obf", info.key); bool ok = false; bool tooLong = false; const std::string decoded = obfuscation::deobfuscateFromBase64(doc[obfKey] | "", info.stringMaxLen - 1, &ok, &tooLong); if (tooLong) { LOG_ERR("CPS", "Oversized obfuscated value for key '%s'", info.key); needsResave = true; } if (ok && !decoded.empty()) { copyToField(destPtr, decoded.c_str(), info.stringMaxLen); loaded = true; } } if (!loaded) { // Read as const char*, never `| std::string(...)`: ArduinoJson's // std::string converter drags a per-TU copy of the serializer into // flash. See the note in PersistableStore.h. const char* raw = doc[info.key].is() ? doc[info.key].as() : nullptr; if (raw) { // Obfuscated field recovered from a legacy plaintext value -> resave. if (info.obfuscated && strcmp(raw, destPtr) != 0) needsResave = true; copyToField(destPtr, raw, info.stringMaxLen); } } } 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.enumLabels().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; } } // Older files stored one combined touch mode under "touchReaderControls": // 0=off, 1=tap, 2=swipe, 3=inverted tap. Split it into the master toggle // plus the per-direction gesture pair (the generic loop above already folded // out-of-range toggle values back to the On default). if (doc["pageTurnGesture"].isNull() && doc["previousPageGesture"].isNull() && doc["touchReaderControls"].is()) { const uint8_t mode = doc["touchReaderControls"].as(); if (mode >= 1 && mode <= 3) { touchReaderControls = TOUCH_READER_ON; pageTurnGesture = mode == 1 ? TAP_ONLY : mode == 2 ? SWIPE_ONLY : INVERTED_TAP; previousPageGesture = pageTurnGesture; needsResave = true; } } if (doc["sleepTimeoutMinutes"].isNull() && !doc["sleepTimeout"].isNull()) { const uint8_t legacyValue = clamp(doc["sleepTimeout"] | (uint8_t)SLEEP_10_MIN, SLEEP_TIMEOUT_COUNT, (uint8_t)SLEEP_10_MIN); sleepTimeoutMinutes = sleepTimeoutEnumToMinutes(legacyValue); needsResave = true; } // Front button remap — managed by RemapFrontButtons sub-activity, not in SettingsList. frontButtonBack = clamp(doc["frontButtonBack"] | (uint8_t)FRONT_HW_BACK, FRONT_BUTTON_HARDWARE_COUNT, FRONT_HW_BACK); frontButtonConfirm = clamp(doc["frontButtonConfirm"] | (uint8_t)FRONT_HW_CONFIRM, FRONT_BUTTON_HARDWARE_COUNT, FRONT_HW_CONFIRM); frontButtonLeft = clamp(doc["frontButtonLeft"] | (uint8_t)FRONT_HW_LEFT, FRONT_BUTTON_HARDWARE_COUNT, FRONT_HW_LEFT); frontButtonRight = clamp(doc["frontButtonRight"] | (uint8_t)FRONT_HW_RIGHT, FRONT_BUTTON_HARDWARE_COUNT, FRONT_HW_RIGHT); validateFrontButtonMapping(s); // Reader font size — an actual point size since 1.5. Files written by 1.4 and // earlier hold the old SMALL/MEDIUM/LARGE/EXTRA_LARGE slot in 0..3; no font is // renderable at those sizes, so the range is unambiguous and folds to the // point sizes those slots used to mean. Drop this once 1.4 upgrades are done. uint8_t storedFontSize = doc["fontSize"] | DEFAULT_FONT_POINT_SIZE; if (storedFontSize <= LEGACY_FONT_SIZE_MAX) { storedFontSize = 12 + storedFontSize * 2; // 0,1,2,3 -> 12,14,16,18 needsResave = true; } fontPointSize = storedFontSize; // Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it. const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0; fontFamily = clamp(storedFontFamily, BUILTIN_FONT_COUNT, 0); if (BoardConfig::hasHomeKey() && doc["homeButtonLongPressAction"].isNull() && !doc["longPressMenuFunction"].isNull()) { static constexpr HomeButtonAction LEGACY[] = {HomeButtonAction::Sync, HomeButtonAction::Ignore, HomeButtonAction::Bookmark, HomeButtonAction::Dictionary, HomeButtonAction::ReaderMenu}; if (s.longPressMenuFunction < sizeof(LEGACY) / sizeof(LEGACY[0])) { s.homeButtonLongPressAction = static_cast(LEGACY[s.longPressMenuFunction]); needsResave = true; } } // SD card font family name — not in SettingsList, load manually const char* sfn = doc["sdFontFamilyName"] | ""; strncpy(sdFontFamilyName, sfn, sizeof(sdFontFamilyName) - 1); sdFontFamilyName[sizeof(sdFontFamilyName) - 1] = '\0'; if (storedFontFamily == LEGACY_OPENDYSLEXIC && sdFontFamilyName[0] == '\0') { fontFamily = NOTOSERIF; strncpy(sdFontFamilyName, "OpenDyslexic", sizeof(sdFontFamilyName) - 1); sdFontFamilyName[sizeof(sdFontFamilyName) - 1] = '\0'; needsResave = true; } else if (storedFontFamily >= BUILTIN_FONT_COUNT) { needsResave = true; } // Dictionary folder name — uses dynamic getter/setter in SettingsList, load manually copyToField(dictionaryName, doc["dictionaryName"] | "", sizeof(dictionaryName)); // Language -- stored as code string for stability across enum reorders. if (doc["language"].is()) { language = static_cast(I18n::languageFromCode(doc["language"].as())); } // Absent means unconfigured, which is the default. if (doc["keyboardLayouts"].is()) { keyboardLayouts = doc["keyboardLayouts"].as(); } if (needsResave) { LOG_DBG("CPS", "Resaving settings to update format"); requestResave(); } LOG_DBG("CPS", "Settings loaded from file"); return true; } CrossPointSettings::StatusBarSpec CrossPointSettings::statusBarSpec() const { StatusBarSpec spec; spec.showChapterPageCount = statusBarChapterPageCount != 0; spec.showBookProgressPercent = statusBarBookProgressPercentage != 0; spec.titleMode = statusBarTitle; spec.showBattery = statusBarBattery != 0; spec.showBatteryPercent = hideBatteryPercentage == HIDE_NEVER; spec.clockMode = statusBarClock; spec.clock12h = clockFormat == 1; spec.progressBarMode = statusBarProgressBar; spec.progressBarHeightPx = statusBarProgressBar != HIDE_PROGRESS ? static_cast((statusBarProgressBarThickness + 1) * 2) : 0; spec.xtcMode = xtcStatusBarMode; return spec; } ReaderRenderSpec CrossPointSettings::readerRenderSpec(const uint16_t viewportWidth, const uint16_t viewportHeight) const { ReaderRenderSpec spec; spec.fontId = getReaderFontId(); spec.lineCompression = getReaderLineCompression(); spec.characterSpacing = getCharacterSpacing(); spec.wordSpacingPercent = wordSpacing; spec.extraParagraphSpacing = extraParagraphSpacing != 0; spec.paragraphAlignment = paragraphAlignment; spec.viewportWidth = viewportWidth; spec.viewportHeight = viewportHeight; spec.hyphenationEnabled = hyphenationEnabled != 0; spec.embeddedStyle = embeddedStyle != 0; spec.imageRendering = imageRendering; spec.focusReadingEnabled = focusReadingEnabled != 0; return spec; } float CrossPointSettings::getReaderLineCompression() const { // SD card and vector fonts get a wider scale than the built-ins: their // faces carry their own (often generous) natural line height, so the old // Bookerly-tuned 1.1/1.2 steps were visually near-indistinguishable. At // 12pt in portrait (~760px viewport) this scale spans ~26/24/19/15 lines // per page — each step reads as a clearly different density. if (sdFontFamilyName[0] != '\0') { switch (lineSpacing) { case TIGHT: return 0.95f; case NORMAL: default: return 1.0f; case WIDE: return 1.3f; case EXTRA_WIDE: return 1.6f; } } switch (fontFamily) { case NOTOSERIF: default: switch (lineSpacing) { case TIGHT: return 0.95f; case NORMAL: default: return 1.0f; case WIDE: return 1.1f; case EXTRA_WIDE: return 1.2f; } case NOTOSANS: switch (lineSpacing) { case TIGHT: return 0.90f; case NORMAL: default: return 0.95f; case WIDE: return 1.0f; case EXTRA_WIDE: return 1.05f; } } } unsigned long CrossPointSettings::getSleepTimeoutMs() const { if (sleepTimeoutMinutes >= SLEEP_TIMEOUT_NEVER_MINUTES) return 0UL; const uint8_t minutes = std::clamp(sleepTimeoutMinutes, MIN_SLEEP_TIMEOUT_MINUTES, static_cast(SLEEP_TIMEOUT_NEVER_MINUTES - 1)); return static_cast(minutes) * 60UL * 1000UL; } int CrossPointSettings::getRefreshFrequency() const { switch (refreshFrequency) { case REFRESH_1: return 1; case REFRESH_5: return 5; case REFRESH_10: return 10; case REFRESH_15: default: return 15; case REFRESH_30: return 30; case REFRESH_NEVER: // Effectively disables the periodic full refresh; the page counter // counts down from here and never reaches the threshold in practice. return std::numeric_limits::max(); } } void CrossPointSettings::clearSdFontFamily() { sdFontFamilyName[0] = '\0'; fontPointSize = snapToNearestPointSize(BUILTIN_READER_POINT_SIZES, std::size(BUILTIN_READER_POINT_SIZES), fontPointSize); saveToFile(); } int CrossPointSettings::getReaderFontId() const { // Check SD card font first if (sdFontFamilyName[0] != '\0' && sdFontIdResolver) { int id = sdFontIdResolver(sdFontResolverCtx, sdFontFamilyName, fontPointSize); if (id != 0) return id; // Fall through to built-in if SD font not found } // A built-in family only exists at BUILTIN_READER_POINT_SIZES, so a size // carried over from an SD family may not be one of them. ensureLoaded() // normally persists the snap; snap again here (without allocating — this runs // in the page render loop) so rendering is correct even before it has run. const uint8_t pt = snapToNearestPointSize(BUILTIN_READER_POINT_SIZES, std::size(BUILTIN_READER_POINT_SIZES), fontPointSize); const bool sans = (fontFamily == NOTOSANS); switch (pt) { case 12: return sans ? NOTOSANS_12_FONT_ID : NOTOSERIF_12_FONT_ID; case 16: return sans ? NOTOSANS_16_FONT_ID : NOTOSERIF_16_FONT_ID; case 18: return sans ? NOTOSANS_18_FONT_ID : NOTOSERIF_18_FONT_ID; case 14: default: return sans ? NOTOSANS_14_FONT_ID : NOTOSERIF_14_FONT_ID; } }