#include "FontDownloadActivity.h" #include #include #include #include #include #include #include #include "MappedInputManager.h" #include "SdCardFontSystem.h" #include "SilentRestart.h" #include "activities/network/WifiSelectionActivity.h" #include "activities/util/ConfirmationActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" FontDownloadActivity::FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) : Activity("FontDownload", renderer, mappedInput), fontInstaller_(sdFontSystem.registry()) {} // --- Lifecycle --- void FontDownloadActivity::onEnter() { Activity::onEnter(); WiFi.mode(WIFI_STA); startActivityForResult(std::make_unique(renderer, mappedInput), [this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); }); } void FontDownloadActivity::onExit() { Activity::onExit(); if (WiFi.getMode() != WIFI_MODE_NULL) { WiFi.disconnect(false); delay(30); silentRestart(); } } void FontDownloadActivity::onWifiSelectionComplete(const bool success) { if (!success) { finish(); return; } { RenderLock lock(*this); state_ = LOADING_MANIFEST; } requestUpdateAndWait(); if (!fetchAndParseManifest()) { { RenderLock lock(*this); state_ = ERROR; } return; } { RenderLock lock(*this); state_ = FAMILY_LIST; selectedIndex_ = 0; } } // --- Manifest fetching --- bool FontDownloadActivity::fetchAndParseManifest() { // Download manifest to a temp file on SD card to avoid holding both // TLS buffers and the full JSON string in RAM simultaneously. static constexpr const char* MANIFEST_TMP = "/fonts_manifest.tmp"; auto result = HttpDownloader::downloadToFile(FONT_MANIFEST_URL, MANIFEST_TMP, nullptr); if (result != HttpDownloader::OK) { LOG_ERR("FONT", "Failed to fetch manifest from %s", FONT_MANIFEST_URL); errorMessage_ = "Failed to fetch font list"; Storage.remove(MANIFEST_TMP); return false; } // HTTP client is now closed — TLS buffers freed. Parse JSON from file. HalFile manifestFile; if (!Storage.openFileForRead("FONT", MANIFEST_TMP, manifestFile)) { LOG_ERR("FONT", "Failed to open temp manifest"); Storage.remove(MANIFEST_TMP); errorMessage_ = "Failed to read font list"; 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; } int version = doc["version"] | 0; if (version != FONTS_MANIFEST_VERSION) { LOG_ERR("FONT", "Unsupported manifest version: %d", version); errorMessage_ = "Unsupported manifest version"; return false; } baseUrl_ = doc["baseUrl"] | ""; families_.clear(); fontInstaller_.refreshRegistry(); JsonArray familiesArr = doc["families"].as(); families_.reserve(familiesArr.size()); for (JsonObject fObj : familiesArr) { ManifestFamily family; family.name = fObj["name"] | ""; family.description = fObj["description"] | ""; for (JsonVariant s : fObj["styles"].as()) { family.styles.push_back(s.as()); } family.totalSize = 0; for (JsonObject fileObj : fObj["files"].as()) { ManifestFile file; file.name = fileObj["name"] | ""; file.size = fileObj["size"] | 0; if (!fileObj["crc32"].is()) { 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(); family.totalSize += file.size; family.files.push_back(std::move(file)); } family.installed = fontInstaller_.isFamilyInstalled(family.name.c_str()); // Detect updates by comparing manifest file sizes with files on disk. // Not a checksum, but a size mismatch reliably indicates a rebuild in practice. if (family.installed) { for (const auto& file : family.files) { char path[128]; FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), path, sizeof(path)); HalFile f; if (Storage.openFileForRead("FONT", path, f)) { size_t actual = f.fileSize(); f.close(); if (actual != file.size) { family.hasUpdate = true; break; } } else { // File missing on disk but family dir exists — treat as update family.hasUpdate = true; break; } } } families_.push_back(std::move(family)); } LOG_DBG("FONT", "Manifest loaded: %zu families", families_.size()); return true; } // --- Download --- void FontDownloadActivity::downloadAll() { cancelRequested_ = false; for (size_t i = 0; i < families_.size(); i++) { if (families_[i].installed) continue; downloadFamily(families_[i]); if (state_ == ERROR || cancelRequested_) return; } { RenderLock lock(*this); state_ = COMPLETE; } } void FontDownloadActivity::updateAll() { cancelRequested_ = false; for (size_t i = 0; i < families_.size(); i++) { if (!families_[i].hasUpdate) continue; downloadFamily(families_[i]); if (state_ == ERROR || cancelRequested_) return; } { RenderLock lock(*this); state_ = COMPLETE; } } bool FontDownloadActivity::showDownloadAllRow() const { for (const auto& f : families_) { if (!f.installed) return true; } return false; } bool FontDownloadActivity::showUpdateAllRow() const { for (const auto& f : families_) { if (f.hasUpdate) return true; } return false; } int FontDownloadActivity::specialRowCount() const { return (showDownloadAllRow() ? 1 : 0) + (showUpdateAllRow() ? 1 : 0); } bool FontDownloadActivity::isDownloadAllRow(int index) const { return showDownloadAllRow() && index == 0; } bool FontDownloadActivity::isUpdateAllRow(int index) const { return showUpdateAllRow() && index == (showDownloadAllRow() ? 1 : 0); } int FontDownloadActivity::listItemCount() const { return families_.empty() ? 0 : static_cast(families_.size()) + specialRowCount(); } size_t FontDownloadActivity::totalDownloadSize() const { size_t total = 0; for (const auto& f : families_) { if (!f.installed) total += f.totalSize; } return total; } size_t FontDownloadActivity::totalUpdateSize() const { size_t total = 0; for (const auto& f : families_) { if (f.hasUpdate) total += f.totalSize; } return total; } // Standard CRC32 matching zlib/Python zlib.crc32(). bool FontDownloadActivity::computeFileCrc32(const char* path, uint32_t& outCrc) { HalFile f; if (!Storage.openFileForRead("FONT", path, f)) { return false; } constexpr size_t BUF_SIZE = 128; uint8_t buf[BUF_SIZE]; uint32_t crc = 0; while (f.available()) { const int n = f.read(buf, BUF_SIZE); if (n <= 0) break; crc = esp_rom_crc32_le(crc, buf, static_cast(n)); } outCrc = crc; return true; } void FontDownloadActivity::downloadFamily(ManifestFamily& family) { { RenderLock lock(*this); state_ = DOWNLOADING; downloadingFamilyIndex_ = static_cast(&family - families_.data()); fileProgress_ = 0; fileTotal_ = 0; cancelRequested_ = false; } requestUpdateAndWait(); if (!fontInstaller_.ensureFamilyDir(family.name.c_str())) { RenderLock lock(*this); state_ = ERROR; errorMessage_ = "Failed to create font directory"; return; } for (size_t i = 0; i < family.files.size(); i++) { const auto& file = family.files[i]; { RenderLock lock(*this); fileProgress_ = 0; fileTotal_ = file.size; } requestUpdateAndWait(); char destPath[128]; FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), destPath, sizeof(destPath)); std::string url = baseUrl_ + file.name; auto result = HttpDownloader::downloadToFile( url, destPath, [this](size_t downloaded, size_t total) { fileProgress_ = downloaded; fileTotal_ = total; mappedInput.update(); if (mappedInput.isPressed(MappedInputManager::Button::Back) || mappedInput.wasPressed(MappedInputManager::Button::Back)) { cancelRequested_ = true; } requestUpdate(true); }, &cancelRequested_); if (result == HttpDownloader::ABORTED) { fontInstaller_.deleteFamily(family.name.c_str()); family.installed = false; family.hasUpdate = false; { RenderLock lock(*this); state_ = FAMILY_LIST; } return; } if (result != HttpDownloader::OK) { LOG_ERR("FONT", "Download failed: %s (%d)", file.name.c_str(), result); fontInstaller_.deleteFamily(family.name.c_str()); family.installed = false; family.hasUpdate = false; RenderLock lock(*this); state_ = ERROR; errorMessage_ = "Download failed: " + file.name; return; } uint32_t actualCrc = 0; if (!computeFileCrc32(destPath, actualCrc)) { LOG_ERR("FONT", "Failed to open file for CRC check: %s", destPath); fontInstaller_.deleteFamily(family.name.c_str()); family.installed = false; family.hasUpdate = false; RenderLock lock(*this); state_ = ERROR; errorMessage_ = "Failed to compute checksum: " + file.name; return; } if (actualCrc != file.crc32) { LOG_ERR("FONT", "CRC32 mismatch for %s: got %08x expected %08x", file.name.c_str(), actualCrc, file.crc32); fontInstaller_.deleteFamily(family.name.c_str()); family.installed = false; family.hasUpdate = false; RenderLock lock(*this); state_ = ERROR; errorMessage_ = "Checksum mismatch: " + file.name; return; } LOG_DBG("FONT", "Downloaded %s (size=%zu crc32=%08x)", file.name.c_str(), file.size, actualCrc); if (!fontInstaller_.validateCpfontFile(destPath)) { LOG_ERR("FONT", "Invalid .cpfont: %s", destPath); fontInstaller_.deleteFamily(family.name.c_str()); family.installed = false; family.hasUpdate = false; RenderLock lock(*this); state_ = ERROR; errorMessage_ = "Invalid font file: " + file.name; return; } currentFileIndex_++; } fontInstaller_.refreshRegistry(); family.installed = true; family.hasUpdate = false; { RenderLock lock(*this); state_ = COMPLETE; } } void FontDownloadActivity::promptDeleteSelectedFamily() { const int pendingDeleteFamilyIndex = familyIndexFromList(selectedIndex_); if (pendingDeleteFamilyIndex < 0 || pendingDeleteFamilyIndex >= static_cast(families_.size())) { return; } std::string heading = tr(STR_DELETE); const auto& family = families_[pendingDeleteFamilyIndex]; std::string body = family.name; startActivityForResult(std::make_unique(renderer, mappedInput, heading, body), [this](const ActivityResult& result) { onDeleteConfirmationResult(result); }); } void FontDownloadActivity::onDeleteConfirmationResult(const ActivityResult& result) { if (result.isCancelled) { requestUpdate(); return; } auto& family = families_[familyIndexFromList(selectedIndex_)]; if (fontInstaller_.deleteFamily(family.name.c_str()) != FontInstaller::Error::OK) { RenderLock lock(*this); state_ = ERROR; errorMessage_ = "Failed to delete font"; } else { fontInstaller_.refreshRegistry(); family.installed = false; family.hasUpdate = false; } requestUpdate(); } bool FontDownloadActivity::isSelectedFamilyDeletable() const { if (isDownloadAllRow(selectedIndex_) || isUpdateAllRow(selectedIndex_)) return false; if (selectedIndex_ < specialRowCount() || selectedIndex_ >= listItemCount()) return false; const auto& family = families_[familyIndexFromList(selectedIndex_)]; return family.installed && !family.hasUpdate; } // --- Input handling --- void FontDownloadActivity::loop() { if (state_ == FAMILY_LIST) { auto activateSelected = [this] { if (families_.empty()) return; if (isDownloadAllRow(selectedIndex_)) { currentFileIndex_ = 0; currentFileTotal_ = 0; for (const auto& f : families_) { if (!f.installed) currentFileTotal_ += f.files.size(); } downloadAll(); } else if (isUpdateAllRow(selectedIndex_)) { currentFileIndex_ = 0; currentFileTotal_ = 0; for (const auto& f : families_) { if (f.hasUpdate) currentFileTotal_ += f.files.size(); } updateAll(); } else { auto& family = families_[familyIndexFromList(selectedIndex_)]; if (!family.installed || family.hasUpdate) { currentFileIndex_ = 0; currentFileTotal_ = family.files.size(); downloadFamily(family); } else { promptDeleteSelectedFamily(); return; } } requestUpdateAndWait(); }; if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { finish(); return; } const int listSize = listItemCount(); const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false); if (!families_.empty()) { const auto& metrics = UITheme::getInstance().getMetrics(); const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const int contentHeight = renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; switch (handleListTouch(selectedIndex_, listSize, contentTop, contentHeight, true)) { case ListTouchResult::Activated: activateSelected(); return; case ListTouchResult::Consumed: return; case ListTouchResult::None: break; } const auto swipe = mappedInput.wasSwipe(); if (swipe == MappedInputManager::SwipeDir::Up) { selectedIndex_ = ButtonNavigator::nextPageIndex(selectedIndex_, listSize, pageItems); requestUpdate(); return; } if (swipe == MappedInputManager::SwipeDir::Down) { selectedIndex_ = ButtonNavigator::previousPageIndex(selectedIndex_, listSize, pageItems); requestUpdate(); return; } } buttonNavigator_.onNextRelease([this, listSize] { selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize); requestUpdate(); }); buttonNavigator_.onPreviousRelease([this, listSize] { selectedIndex_ = ButtonNavigator::previousIndex(selectedIndex_, listSize); requestUpdate(); }); buttonNavigator_.onNextContinuous([this, listSize, pageItems] { selectedIndex_ = ButtonNavigator::nextPageIndex(selectedIndex_, listSize, pageItems); requestUpdate(); }); buttonNavigator_.onPreviousContinuous([this, listSize, pageItems] { selectedIndex_ = ButtonNavigator::previousPageIndex(selectedIndex_, listSize, pageItems); requestUpdate(); }); if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { activateSelected(); return; } } else if (state_ == COMPLETE) { int x = 0; int y = 0; if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasPressed(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) { { RenderLock lock(*this); state_ = FAMILY_LIST; } requestUpdate(); } } else if (state_ == ERROR) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { { RenderLock lock(*this); state_ = FAMILY_LIST; } requestUpdate(); } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast(families_.size())) { downloadFamily(families_[downloadingFamilyIndex_]); requestUpdateAndWait(); return; } else { { RenderLock lock(*this); state_ = FAMILY_LIST; } requestUpdate(); } } else { int x = 0; int y = 0; if (mappedInput.wasScreenTapped(x, y)) { if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast(families_.size())) { downloadFamily(families_[downloadingFamilyIndex_]); requestUpdateAndWait(); return; } { RenderLock lock(*this); state_ = FAMILY_LIST; } requestUpdate(); } } } } // --- Rendering --- std::string FontDownloadActivity::formatSize(size_t bytes) { char buf[32]; if (bytes >= 1024 * 1024) { snprintf(buf, sizeof(buf), "%.1f MB", static_cast(bytes) / (1024.0 * 1024.0)); } else if (bytes >= 1024) { snprintf(buf, sizeof(buf), "%.0f KB", static_cast(bytes) / 1024.0); } else { snprintf(buf, sizeof(buf), "%zu B", bytes); } return buf; } void FontDownloadActivity::render(RenderLock&&) { const auto& metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_BROWSER)); const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID); const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const auto centerY = (pageHeight - lineHeight) / 2; if (state_ == LOADING_MANIFEST) { renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_LOADING_FONT_LIST)); } else if (state_ == FAMILY_LIST) { if (families_.empty()) { renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_NO_FONTS_AVAILABLE)); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else { GUI.drawList( renderer, Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing}, listItemCount(), selectedIndex_, [this](int index) -> std::string { if (isDownloadAllRow(index)) { return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalDownloadSize()) + ")"; } if (isUpdateAllRow(index)) { return std::string(tr(STR_UPDATE_ALL)) + " (" + formatSize(totalUpdateSize()) + ")"; } return families_[familyIndexFromList(index)].name; }, [this](int index) -> std::string { if (isDownloadAllRow(index) || isUpdateAllRow(index)) return ""; return families_[familyIndexFromList(index)].description; }, nullptr, [this](int index) -> std::string { if (isDownloadAllRow(index) || isUpdateAllRow(index)) return ""; const auto& f = families_[familyIndexFromList(index)]; if (f.hasUpdate) return tr(STR_UPDATE_AVAILABLE); if (f.installed) return tr(STR_INSTALLED); return ""; }, true, [this](int index) -> bool { if (isDownloadAllRow(index) || isUpdateAllRow(index)) return false; const auto& f = families_[familyIndexFromList(index)]; return f.installed && !f.hasUpdate; }); const auto labels = mappedInput.mapLabels(tr(STR_BACK), isSelectedFamilyDeletable() ? tr(STR_DELETE) : isUpdateAllRow(selectedIndex_) ? tr(STR_UPDATE) : tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } } else if (state_ == DOWNLOADING) { const auto& family = families_[downloadingFamilyIndex_]; std::string statusText = std::string(tr(STR_DOWNLOADING)) + " " + family.name + " (" + std::to_string(currentFileIndex_ + 1) + "/" + std::to_string(currentFileTotal_) + ")"; renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, statusText.c_str()); float progress = 0; if (fileTotal_ > 0) { progress = static_cast(fileProgress_) / static_cast(fileTotal_); } int barY = centerY + metrics.verticalSpacing; GUI.drawProgressBar( renderer, Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight}, static_cast(progress * 100), 100); const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state_ == COMPLETE) { renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_FONT_INSTALLED), true, EpdFontFamily::BOLD); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state_ == ERROR) { renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_FONT_INSTALL_FAILED), true, EpdFontFamily::BOLD); if (!errorMessage_.empty()) { renderer.drawCenteredText(UI_10_FONT_ID, centerY + metrics.verticalSpacing, errorMessage_.c_str()); } const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } renderer.displayBuffer(); }