Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Fixed

- Manage Fonts no longer crashes (abort) while loading the font list on devices with many SD-card font families installed. The font registry is now released before the network request, and the manifest is parsed without keeping the parsed JSON and the font registry in memory at the same time.

## [v1.4.0] - 2026-07-04

### Added
Expand Down
125 changes: 71 additions & 54 deletions src/activities/settings/FontDownloadActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,12 @@ FontDownloadActivity::FontDownloadActivity(GfxRenderer& renderer, MappedInputMan

void FontDownloadActivity::onEnter() {
Activity::onEnter();
sdFontSystem.releaseLoadedFont(renderer);
// Free the whole SD font registry, not just the active glyph font, before the
// network + manifest work. With many families installed the registry, the
// parsed manifest, and the families_ list would otherwise all be resident at
// once and exhaust the heap, aborting during manifest parse. Matches the
// pre-network release done by the KOReader sync/auth activities.
sdFontSystem.releaseForNetwork(renderer);
WiFi.mode(WIFI_STA);
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
Expand Down Expand Up @@ -196,74 +201,86 @@ bool FontDownloadActivity::fetchAndParseManifest() {
return false;
}

JsonDocument doc;
DeserializationError err = deserializeJson(doc, manifestFile);
manifestFile.close();
Storage.remove(MANIFEST_TMP);

if (err) {
LOG_ERR("FONT", "Manifest parse error: %s", err.c_str());
errorMessage_ = "Invalid font manifest";
return false;
}
// The parsed manifest and the installed-font registry are each large when many
// families are installed. Keep the JsonDocument in its own scope and defer
// loading the registry until after it is freed (see second pass below), so the
// two are never resident at the same time. Their coexistence here is what
// aborted during parse on low-heap devices with many SD fonts installed.
{
JsonDocument doc;
DeserializationError err = deserializeJson(doc, manifestFile);
manifestFile.close();
Storage.remove(MANIFEST_TMP);

int version = doc["version"] | 0;
if (version != FONTS_MANIFEST_VERSION) {
LOG_ERR("FONT", "Unsupported manifest version: %d", version);
errorMessage_ = "Unsupported manifest version";
return false;
}
if (err) {
LOG_ERR("FONT", "Manifest parse error: %s", err.c_str());
errorMessage_ = "Invalid font manifest";
return false;
}

baseUrl_ = doc["baseUrl"] | "";
families_.clear();
fontInstaller_.refreshRegistry();
int version = doc["version"] | 0;
if (version != FONTS_MANIFEST_VERSION) {
LOG_ERR("FONT", "Unsupported manifest version: %d", version);
errorMessage_ = "Unsupported manifest version";
return false;
}

JsonArray familiesArr = doc["families"].as<JsonArray>();
families_.reserve(familiesArr.size());
baseUrl_ = doc["baseUrl"] | "";
families_.clear();

for (JsonObject fObj : familiesArr) {
ManifestFamily family;
family.name = fObj["name"] | "";
family.description = fObj["description"] | "";
family.languages = fObj["languages"] | "";
JsonArray familiesArr = doc["families"].as<JsonArray>();
families_.reserve(familiesArr.size());

for (JsonVariant s : fObj["styles"].as<JsonArray>()) {
family.styles.push_back(s.as<std::string>());
}
for (JsonObject fObj : familiesArr) {
ManifestFamily family;
family.name = fObj["name"] | "";
family.description = fObj["description"] | "";
family.languages = fObj["languages"] | "";

family.totalSize = 0;
for (JsonObject fileObj : fObj["files"].as<JsonArray>()) {
ManifestFile file;
file.name = fileObj["name"] | "";
file.size = fileObj["size"] | 0;
if (!parseManifestPointSize(file.name.c_str(), file.pointSize)) {
LOG_ERR("FONT", "Malformed manifest file entry: invalid filename %s", file.name.c_str());
errorMessage_ = "Invalid font manifest";
return false;
for (JsonVariant s : fObj["styles"].as<JsonArray>()) {
family.styles.push_back(s.as<std::string>());
}

if (!CrossPointSettings::isSdFontPointSizeAllowedForRange(file.pointSize, SETTINGS.sdFontSizeRange)) {
continue;
}
family.totalSize = 0;
for (JsonObject fileObj : fObj["files"].as<JsonArray>()) {
ManifestFile file;
file.name = fileObj["name"] | "";
file.size = fileObj["size"] | 0;
if (!parseManifestPointSize(file.name.c_str(), file.pointSize)) {
LOG_ERR("FONT", "Malformed manifest file entry: invalid filename %s", file.name.c_str());
errorMessage_ = "Invalid font manifest";
return false;
}

if (!CrossPointSettings::isSdFontPointSizeAllowedForRange(file.pointSize, SETTINGS.sdFontSizeRange)) {
continue;
}

if (!fileObj["crc32"].is<uint32_t>()) {
LOG_ERR("FONT", "Malformed manifest file entry: missing or invalid crc32 for %s", file.name.c_str());
errorMessage_ = "Invalid font manifest";
return false;
}
file.crc32 = fileObj["crc32"].as<uint32_t>();

if (!fileObj["crc32"].is<uint32_t>()) {
LOG_ERR("FONT", "Malformed manifest file entry: missing or invalid crc32 for %s", file.name.c_str());
errorMessage_ = "Invalid font manifest";
return false;
family.totalSize += file.size;
family.files.push_back(std::move(file));
}
file.crc32 = fileObj["crc32"].as<uint32_t>();

family.totalSize += file.size;
family.files.push_back(std::move(file));
}
if (family.files.empty()) {
continue;
}

if (family.files.empty()) {
continue;
families_.push_back(std::move(family));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid exposing unresolved families after parse errors

When a later manifest entry fails validation after at least one family has been pushed here, fetchAndParseManifest() returns before the second pass fills installName. The ERROR screen still offers Retry/Back for non-empty families_, and downloadFamily() uses family.installName for ensureFamilyDir()/buildFontPath(), so a staged row can be downloaded with an empty install name and write files under /.fonts//... instead of a family directory. Stage parsed families in a local vector until the whole manifest validates, or clear/resolve them before any error return.

Useful? React with 👍 / 👎.

}
} // JsonDocument freed here, before the registry is loaded below.

// Second pass: load the installed-font registry and resolve installed/update
// state now that the manifest JsonDocument has been released, keeping peak
// heap usage down on devices with many SD fonts installed.
fontInstaller_.refreshRegistry();
for (auto& family : families_) {
resolveInstalledFamilyName(family);

families_.push_back(std::move(family));
}

LOG_DBG("FONT", "Manifest loaded: %zu families", families_.size());
Expand Down
Loading