Add SD theme system
Adds installable SD-card themes with manifest downloads, theme registry parsing, themed home/chrome/settings/file browser support, FreeInk layout integration, theme documentation, and layout tests.
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
#include "SdFirmwareUpdateActivity.h"
|
||||
#include "SettingsList.h"
|
||||
#include "StatusBarSettingsActivity.h"
|
||||
#include "ThemeDownloadActivity.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/IntervalSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -38,8 +39,9 @@ void SettingsActivity::rebuildSettingsLists() {
|
||||
// Pick up any fonts uploaded/deleted over the web server since the last
|
||||
// reader activity ran — otherwise the font-family picker shows stale list.
|
||||
sdFontSystem.refreshIfDirty();
|
||||
UITheme::getInstance().refreshRegistry();
|
||||
|
||||
for (auto& setting : getSettingsList(&sdFontSystem.registry())) {
|
||||
for (auto& setting : getSettingsList(&sdFontSystem.registry(), &UITheme::getInstance().registry())) {
|
||||
if (setting.category == StrId::STR_NONE_OPT) continue;
|
||||
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
||||
displaySettings.push_back(setting);
|
||||
@@ -55,6 +57,11 @@ void SettingsActivity::rebuildSettingsLists() {
|
||||
systemSettings.push_back(setting);
|
||||
}
|
||||
}
|
||||
// getSettingsList copies the SD theme names/ids into the UI theme setting.
|
||||
// Keeping the full parsed SD theme registry alive while child activities
|
||||
// like the font downloader run leaves less contiguous heap for TLS/header
|
||||
// parsing.
|
||||
UITheme::getInstance().registry().clear();
|
||||
|
||||
// Append device-only ACTION items
|
||||
controlsSettings.insert(controlsSettings.begin(),
|
||||
@@ -66,6 +73,10 @@ void SettingsActivity::rebuildSettingsLists() {
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
|
||||
auto themeSettingIt = std::find_if(displaySettings.begin(), displaySettings.end(),
|
||||
[](const SettingInfo& setting) { return setting.nameId == StrId::STR_UI_THEME; });
|
||||
displaySettings.insert(themeSettingIt == displaySettings.end() ? displaySettings.end() : themeSettingIt + 1,
|
||||
SettingInfo::Action(StrId::STR_MANAGE_THEMES, SettingAction::DownloadThemes));
|
||||
// Insert "Manage Fonts" right after the font family setting so users discover it naturally
|
||||
readerSettings.insert(readerSettings.begin() + 1,
|
||||
SettingInfo::Action(StrId::STR_MANAGE_FONTS, SettingAction::DownloadFonts));
|
||||
@@ -89,6 +100,19 @@ void SettingsActivity::rebuildSettingsLists() {
|
||||
settingsCount = static_cast<int>(currentSettings->size());
|
||||
}
|
||||
|
||||
void SettingsActivity::releaseSettingsLists() {
|
||||
displaySettings.clear();
|
||||
readerSettings.clear();
|
||||
controlsSettings.clear();
|
||||
systemSettings.clear();
|
||||
displaySettings.shrink_to_fit();
|
||||
readerSettings.shrink_to_fit();
|
||||
controlsSettings.shrink_to_fit();
|
||||
systemSettings.shrink_to_fit();
|
||||
currentSettings = nullptr;
|
||||
settingsCount = 0;
|
||||
}
|
||||
|
||||
void SettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
@@ -191,6 +215,7 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
const auto& setting = (*currentSettings)[selectedSetting];
|
||||
const bool sleepScreenChanged = setting.valuePtr == &CrossPointSettings::sleepScreen;
|
||||
const bool quickResumeTimeoutChanged = setting.valuePtr == &CrossPointSettings::quickResumeSleepScreen;
|
||||
const bool themeChanged = setting.nameId == StrId::STR_UI_THEME;
|
||||
|
||||
if (setting.nameId == StrId::STR_TIME_TO_SLEEP) {
|
||||
openSleepTimeoutPicker();
|
||||
@@ -255,9 +280,22 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
|
||||
break;
|
||||
case SettingAction::DownloadFonts:
|
||||
releaseSettingsLists();
|
||||
UITheme::getInstance().releaseSdThemeAssetMemory();
|
||||
startActivityForResult(std::make_unique<FontDownloadActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult&) {
|
||||
SETTINGS.saveToFile();
|
||||
UITheme::getInstance().reload();
|
||||
rebuildSettingsLists();
|
||||
});
|
||||
break;
|
||||
case SettingAction::DownloadThemes:
|
||||
releaseSettingsLists();
|
||||
UITheme::getInstance().releaseSdThemeAssetMemory();
|
||||
startActivityForResult(std::make_unique<ThemeDownloadActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult&) {
|
||||
SETTINGS.saveToFile();
|
||||
UITheme::getInstance().reload();
|
||||
rebuildSettingsLists();
|
||||
});
|
||||
break;
|
||||
@@ -275,8 +313,14 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
|
||||
syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged);
|
||||
SETTINGS.saveToFile();
|
||||
if (themeChanged) {
|
||||
UITheme::getInstance().reload();
|
||||
}
|
||||
rebuildSettingsLists();
|
||||
selectedSettingIndex = std::min(selectedSettingIndex, settingsCount);
|
||||
if (themeChanged) {
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsActivity::syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged) {
|
||||
@@ -326,58 +370,90 @@ void SettingsActivity::render(RenderLock&&) {
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SETTINGS_TITLE),
|
||||
CROSSPOINT_VERSION);
|
||||
const ThemeScreenSpec* screenSpec = UITheme::getInstance().getScreenSpec(ThemeScreenKind::Settings);
|
||||
ThemeLayoutSlots slots;
|
||||
Rect headerRect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
|
||||
Rect tabsRect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight};
|
||||
Rect listRect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing,
|
||||
pageWidth,
|
||||
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight +
|
||||
metrics.buttonHintsHeight + metrics.verticalSpacing * 2)};
|
||||
Rect buttonsRect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
|
||||
|
||||
if (screenSpec != nullptr) {
|
||||
layoutThemeSlots(screenSpec->layout, Rect{0, 0, pageWidth, pageHeight}, metrics, slots);
|
||||
headerRect = normalizeThemeHeaderSlot(findThemeSlot(slots, "header"), metrics);
|
||||
tabsRect = findThemeSlot(slots, "tabs");
|
||||
listRect = findThemeSlot(slots, "list");
|
||||
buttonsRect = findThemeSlot(slots, "buttons");
|
||||
if (listRect.width <= 0 || listRect.height <= 0) {
|
||||
LOG_ERR("Settings", "Invalid SD settings layout: slots=%d; using built-in layout",
|
||||
static_cast<int>(slots.size()));
|
||||
screenSpec = nullptr;
|
||||
}
|
||||
}
|
||||
if (screenSpec == nullptr) {
|
||||
headerRect = Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight};
|
||||
tabsRect = Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight};
|
||||
listRect =
|
||||
Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth,
|
||||
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight +
|
||||
metrics.buttonHintsHeight + metrics.verticalSpacing * 2)};
|
||||
buttonsRect = Rect{0, pageHeight - metrics.buttonHintsHeight, pageWidth, metrics.buttonHintsHeight};
|
||||
}
|
||||
|
||||
if (headerRect.width > 0 && headerRect.height > 0) {
|
||||
GUI.drawHeader(renderer, headerRect, tr(STR_SETTINGS_TITLE), CROSSPOINT_VERSION);
|
||||
}
|
||||
|
||||
std::vector<TabInfo> tabs;
|
||||
tabs.reserve(categoryCount);
|
||||
for (int i = 0; i < categoryCount; i++) {
|
||||
tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});
|
||||
}
|
||||
GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs,
|
||||
selectedSettingIndex == 0);
|
||||
if (tabsRect.width > 0 && tabsRect.height > 0) {
|
||||
GUI.drawTabBar(renderer, tabsRect, tabs, selectedSettingIndex == 0);
|
||||
}
|
||||
|
||||
const auto& settings = *currentSettings;
|
||||
GUI.drawList(
|
||||
renderer,
|
||||
Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth,
|
||||
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight +
|
||||
metrics.verticalSpacing * 2)},
|
||||
settingsCount, selectedSettingIndex - 1,
|
||||
[&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,
|
||||
[&settings](int i) {
|
||||
const auto& setting = settings[i];
|
||||
std::string valueText = "";
|
||||
if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
|
||||
const bool value = SETTINGS.*(setting.valuePtr);
|
||||
valueText = value ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t value = SETTINGS.*(setting.valuePtr);
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
|
||||
const uint8_t value = setting.valueGetter();
|
||||
if (!setting.enumStringValues.empty() && value < setting.enumStringValues.size()) {
|
||||
valueText = setting.enumStringValues[value];
|
||||
} else if (value < setting.enumValues.size()) {
|
||||
if (listRect.width > 0 && listRect.height > 0) {
|
||||
GUI.drawList(
|
||||
renderer, listRect, settingsCount, selectedSettingIndex - 1,
|
||||
[&settings](int index) { return std::string(I18N.get(settings[index].nameId)); }, nullptr, nullptr,
|
||||
[&settings](int i) {
|
||||
const auto& setting = settings[i];
|
||||
std::string valueText = "";
|
||||
if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
|
||||
const bool value = SETTINGS.*(setting.valuePtr);
|
||||
valueText = value ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t value = SETTINGS.*(setting.valuePtr);
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
}
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
if (setting.nameId == StrId::STR_TIME_TO_SLEEP) {
|
||||
char valueBuffer[32];
|
||||
if (SETTINGS.sleepTimeoutMinutes >= CrossPointSettings::SLEEP_TIMEOUT_NEVER_MINUTES) {
|
||||
valueText = tr(STR_SLEEP_NEVER);
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
|
||||
const uint8_t value = setting.valueGetter();
|
||||
if (!setting.enumStringValues.empty() && value < setting.enumStringValues.size()) {
|
||||
valueText = setting.enumStringValues[value];
|
||||
} else if (value < setting.enumValues.size()) {
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
}
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
if (setting.nameId == StrId::STR_TIME_TO_SLEEP) {
|
||||
char valueBuffer[32];
|
||||
if (SETTINGS.sleepTimeoutMinutes >= CrossPointSettings::SLEEP_TIMEOUT_NEVER_MINUTES) {
|
||||
valueText = tr(STR_SLEEP_NEVER);
|
||||
} else {
|
||||
snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT),
|
||||
static_cast<unsigned int>(SETTINGS.*(setting.valuePtr)));
|
||||
valueText = valueBuffer;
|
||||
}
|
||||
} else {
|
||||
snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT),
|
||||
static_cast<unsigned int>(SETTINGS.*(setting.valuePtr)));
|
||||
valueText = valueBuffer;
|
||||
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
|
||||
}
|
||||
} else {
|
||||
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
|
||||
}
|
||||
}
|
||||
return valueText;
|
||||
},
|
||||
true);
|
||||
return valueText;
|
||||
},
|
||||
true);
|
||||
}
|
||||
|
||||
// Draw help text
|
||||
const auto confirmLabel =
|
||||
@@ -387,7 +463,9 @@ void SettingsActivity::render(RenderLock&&) {
|
||||
? tr(STR_SELECT)
|
||||
: tr(STR_TOGGLE));
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
if (buttonsRect.width > 0 && buttonsRect.height > 0) {
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
// Always use standard refresh for settings screen
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -23,6 +23,7 @@ enum class SettingAction {
|
||||
SdFirmwareUpdate,
|
||||
Language,
|
||||
DownloadFonts,
|
||||
DownloadThemes,
|
||||
};
|
||||
|
||||
struct SettingInfo {
|
||||
@@ -166,6 +167,7 @@ class SettingsActivity final : public Activity {
|
||||
void toggleCurrentSetting();
|
||||
void openSleepTimeoutPicker();
|
||||
void rebuildSettingsLists();
|
||||
void releaseSettingsLists();
|
||||
void syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged);
|
||||
|
||||
public:
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
#include "ThemeDownloadActivity.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_rom_crc.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "MappedInputManager.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"
|
||||
|
||||
ThemeDownloadActivity::ThemeDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("ThemeDownload", renderer, mappedInput), themeInstaller_(UITheme::getInstance().registry()) {}
|
||||
|
||||
void ThemeDownloadActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
WiFi.mode(WIFI_STA);
|
||||
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::onExit() {
|
||||
Activity::onExit();
|
||||
if (WiFi.getMode() != WIFI_MODE_NULL) {
|
||||
WiFi.disconnect(false);
|
||||
delay(30);
|
||||
silentRestart();
|
||||
}
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::onWifiSelectionComplete(const bool success) {
|
||||
if (!success) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = LOADING_MANIFEST;
|
||||
downloadingThemeIndex_ = -1;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
if (!fetchAndParseManifest()) {
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = THEME_LIST;
|
||||
selectedIndex_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool ThemeDownloadActivity::fetchAndParseManifest() {
|
||||
static constexpr const char* MANIFEST_TMP = "/themes_manifest.tmp";
|
||||
|
||||
auto result = HttpDownloader::downloadToFile(THEME_MANIFEST_URL, MANIFEST_TMP, nullptr);
|
||||
if (result != HttpDownloader::OK) {
|
||||
LOG_ERR("THEME", "Failed to fetch manifest from %s", THEME_MANIFEST_URL);
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
return false;
|
||||
}
|
||||
|
||||
HalFile manifestFile;
|
||||
if (!Storage.openFileForRead("THEME", MANIFEST_TMP, manifestFile)) {
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, manifestFile);
|
||||
manifestFile.close();
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
|
||||
if (err) {
|
||||
LOG_ERR("THEME", "Manifest parse error: %s", err.c_str());
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
const int version = doc["version"] | 0;
|
||||
if (version != THEMES_MANIFEST_VERSION) {
|
||||
LOG_ERR("THEME", "Unsupported manifest version: %d", version);
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
baseUrl_ = doc["baseUrl"] | "";
|
||||
while (!baseUrl_.empty() && baseUrl_.back() == '/') {
|
||||
baseUrl_.pop_back();
|
||||
}
|
||||
if (!baseUrl_.empty()) {
|
||||
baseUrl_ += "/";
|
||||
}
|
||||
themes_.clear();
|
||||
themeInstaller_.refreshRegistry();
|
||||
|
||||
JsonArray themesArr = doc["themes"].as<JsonArray>();
|
||||
themes_.reserve(themesArr.size());
|
||||
|
||||
for (JsonObject tObj : themesArr) {
|
||||
ManifestTheme theme;
|
||||
theme.id = tObj["id"] | "";
|
||||
theme.name = tObj["name"] | theme.id;
|
||||
theme.description = tObj["description"] | "";
|
||||
theme.version = tObj["version"] | 0;
|
||||
|
||||
if (!ThemeInstaller::isValidThemeId(theme.id.c_str())) {
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonArray filesArr = tObj["files"].as<JsonArray>();
|
||||
theme.files.reserve(filesArr.size());
|
||||
for (JsonObject fileObj : filesArr) {
|
||||
ManifestFile file;
|
||||
file.path = fileObj["path"] | fileObj["name"] | "";
|
||||
file.url = fileObj["url"] | file.path;
|
||||
file.size = fileObj["size"] | 0;
|
||||
if (!ThemeInstaller::isValidRelativePath(file.path.c_str()) ||
|
||||
!ThemeInstaller::isValidRelativePath(file.url.c_str()) || !fileObj["crc32"].is<uint32_t>()) {
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
file.crc32 = fileObj["crc32"].as<uint32_t>();
|
||||
theme.totalSize += file.size;
|
||||
theme.files.push_back(std::move(file));
|
||||
}
|
||||
|
||||
theme.installed = themeInstaller_.isThemeInstalled(theme.id.c_str());
|
||||
if (theme.installed) {
|
||||
// Primary update signal: the manifest declares a newer theme version than
|
||||
// the copy installed on the SD card.
|
||||
const auto* installed = UITheme::getInstance().registry().findTheme(theme.id);
|
||||
const int installedVersion = installed != nullptr ? installed->version : 0;
|
||||
if (theme.version > installedVersion) {
|
||||
theme.hasUpdate = true;
|
||||
} else {
|
||||
// Safety net: even at the same version, re-offer if a file is missing or
|
||||
// its size no longer matches (catches a corrupted or partial install).
|
||||
for (const auto& file : theme.files) {
|
||||
char path[180];
|
||||
if (!ThemeInstaller::buildThemePath(theme.id.c_str(), file.path.c_str(), path, sizeof(path))) {
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return false;
|
||||
}
|
||||
HalFile f;
|
||||
if (Storage.openFileForRead("THEME", path, f)) {
|
||||
const size_t actual = f.fileSize();
|
||||
f.close();
|
||||
if (actual != file.size) {
|
||||
theme.hasUpdate = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
theme.hasUpdate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
themes_.push_back(std::move(theme));
|
||||
}
|
||||
UITheme::getInstance().registry().clear();
|
||||
|
||||
LOG_DBG("THEME", "Manifest loaded: %zu themes", themes_.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::downloadAll() {
|
||||
cancelRequested_ = false;
|
||||
for (auto& theme : themes_) {
|
||||
if (theme.installed) continue;
|
||||
downloadTheme(theme);
|
||||
if (state_ == ERROR || cancelRequested_) return;
|
||||
}
|
||||
RenderLock lock(*this);
|
||||
state_ = COMPLETE;
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::updateAll() {
|
||||
cancelRequested_ = false;
|
||||
for (auto& theme : themes_) {
|
||||
if (!theme.hasUpdate) continue;
|
||||
downloadTheme(theme);
|
||||
if (state_ == ERROR || cancelRequested_) return;
|
||||
}
|
||||
RenderLock lock(*this);
|
||||
state_ = COMPLETE;
|
||||
}
|
||||
|
||||
bool ThemeDownloadActivity::showDownloadAllRow() const {
|
||||
for (const auto& t : themes_) {
|
||||
if (!t.installed) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ThemeDownloadActivity::showUpdateAllRow() const {
|
||||
for (const auto& t : themes_) {
|
||||
if (t.hasUpdate) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int ThemeDownloadActivity::specialRowCount() const {
|
||||
return (showDownloadAllRow() ? 1 : 0) + (showUpdateAllRow() ? 1 : 0);
|
||||
}
|
||||
|
||||
bool ThemeDownloadActivity::isDownloadAllRow(int index) const { return showDownloadAllRow() && index == 0; }
|
||||
|
||||
bool ThemeDownloadActivity::isUpdateAllRow(int index) const {
|
||||
return showUpdateAllRow() && index == (showDownloadAllRow() ? 1 : 0);
|
||||
}
|
||||
|
||||
int ThemeDownloadActivity::listItemCount() const {
|
||||
return themes_.empty() ? 0 : static_cast<int>(themes_.size()) + specialRowCount();
|
||||
}
|
||||
|
||||
size_t ThemeDownloadActivity::totalDownloadSize() const {
|
||||
size_t total = 0;
|
||||
for (const auto& t : themes_) {
|
||||
if (!t.installed) total += t.totalSize;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
size_t ThemeDownloadActivity::totalUpdateSize() const {
|
||||
size_t total = 0;
|
||||
for (const auto& t : themes_) {
|
||||
if (t.hasUpdate) total += t.totalSize;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
bool ThemeDownloadActivity::computeFileCrc32(const char* path, uint32_t& outCrc) {
|
||||
HalFile f;
|
||||
if (!Storage.openFileForRead("THEME", 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<uint32_t>(n));
|
||||
}
|
||||
f.close();
|
||||
outCrc = crc;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::downloadTheme(ManifestTheme& theme) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = DOWNLOADING;
|
||||
downloadingThemeIndex_ = static_cast<int>(&theme - themes_.data());
|
||||
fileProgress_ = 0;
|
||||
fileTotal_ = 0;
|
||||
cancelRequested_ = false;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
if (!themeInstaller_.ensureThemeDir(theme.id.c_str())) {
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < theme.files.size(); i++) {
|
||||
const auto& file = theme.files[i];
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
fileProgress_ = 0;
|
||||
fileTotal_ = file.size;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
char destPath[180];
|
||||
if (!ThemeInstaller::buildThemePath(theme.id.c_str(), file.path.c_str(), destPath, sizeof(destPath))) {
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return;
|
||||
}
|
||||
if (!themeInstaller_.ensureParentDirs(destPath)) {
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return;
|
||||
}
|
||||
|
||||
std::string url = baseUrl_ + file.url;
|
||||
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) {
|
||||
themeInstaller_.deleteTheme(theme.id.c_str());
|
||||
theme.installed = false;
|
||||
theme.hasUpdate = false;
|
||||
RenderLock lock(*this);
|
||||
state_ = THEME_LIST;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result != HttpDownloader::OK) {
|
||||
themeInstaller_.deleteTheme(theme.id.c_str());
|
||||
theme.installed = false;
|
||||
theme.hasUpdate = false;
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = std::string(tr(STR_DOWNLOAD_FAILED)) + ": " + file.path;
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t actualCrc = 0;
|
||||
if (!computeFileCrc32(destPath, actualCrc) || actualCrc != file.crc32 ||
|
||||
!themeInstaller_.validateThemeFile(destPath)) {
|
||||
themeInstaller_.deleteTheme(theme.id.c_str());
|
||||
theme.installed = false;
|
||||
theme.hasUpdate = false;
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
return;
|
||||
}
|
||||
currentFileIndex_++;
|
||||
}
|
||||
|
||||
theme.installed = true;
|
||||
theme.hasUpdate = false;
|
||||
RenderLock lock(*this);
|
||||
state_ = COMPLETE;
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::promptDeleteSelectedTheme() {
|
||||
const int pendingDeleteThemeIndex = themeIndexFromList(selectedIndex_);
|
||||
if (pendingDeleteThemeIndex < 0 || pendingDeleteThemeIndex >= static_cast<int>(themes_.size())) return;
|
||||
|
||||
const auto& theme = themes_[pendingDeleteThemeIndex];
|
||||
startActivityForResult(std::make_unique<ConfirmationActivity>(renderer, mappedInput, tr(STR_DELETE), theme.name),
|
||||
[this](const ActivityResult& result) { onDeleteConfirmationResult(result); });
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::onDeleteConfirmationResult(const ActivityResult& result) {
|
||||
if (result.isCancelled) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
auto& theme = themes_[themeIndexFromList(selectedIndex_)];
|
||||
if (themeInstaller_.deleteTheme(theme.id.c_str()) != ThemeInstaller::Error::OK) {
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = tr(STR_DOWNLOAD_FAILED);
|
||||
} else {
|
||||
theme.installed = false;
|
||||
theme.hasUpdate = false;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
bool ThemeDownloadActivity::isSelectedThemeDeletable() const {
|
||||
if (isDownloadAllRow(selectedIndex_) || isUpdateAllRow(selectedIndex_)) return false;
|
||||
if (selectedIndex_ < specialRowCount() || selectedIndex_ >= listItemCount()) return false;
|
||||
const auto& theme = themes_[themeIndexFromList(selectedIndex_)];
|
||||
return theme.installed && !theme.hasUpdate;
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::loop() {
|
||||
if (state_ == THEME_LIST) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
const int listSize = listItemCount();
|
||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
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) && !themes_.empty()) {
|
||||
if (isDownloadAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& t : themes_) {
|
||||
if (!t.installed) currentFileTotal_ += t.files.size();
|
||||
}
|
||||
downloadAll();
|
||||
} else if (isUpdateAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& t : themes_) {
|
||||
if (t.hasUpdate) currentFileTotal_ += t.files.size();
|
||||
}
|
||||
updateAll();
|
||||
} else {
|
||||
auto& theme = themes_[themeIndexFromList(selectedIndex_)];
|
||||
if (!theme.installed || theme.hasUpdate) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = theme.files.size();
|
||||
downloadTheme(theme);
|
||||
} else {
|
||||
promptDeleteSelectedTheme();
|
||||
return;
|
||||
}
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
}
|
||||
} else if (state_ == COMPLETE) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
RenderLock lock(*this);
|
||||
state_ = THEME_LIST;
|
||||
requestUpdate();
|
||||
}
|
||||
} else if (state_ == ERROR) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
RenderLock lock(*this);
|
||||
state_ = THEME_LIST;
|
||||
requestUpdate();
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (downloadingThemeIndex_ >= 0 && downloadingThemeIndex_ < static_cast<int>(themes_.size())) {
|
||||
downloadTheme(themes_[downloadingThemeIndex_]);
|
||||
requestUpdateAndWait();
|
||||
} else {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = LOADING_MANIFEST;
|
||||
errorMessage_.clear();
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
if (!fetchAndParseManifest()) {
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
} else {
|
||||
RenderLock lock(*this);
|
||||
state_ = THEME_LIST;
|
||||
selectedIndex_ = 0;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string ThemeDownloadActivity::formatSize(size_t bytes) {
|
||||
char buf[32];
|
||||
if (bytes >= 1024 * 1024) {
|
||||
snprintf(buf, sizeof(buf), "%.1f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
|
||||
} else if (bytes >= 1024) {
|
||||
snprintf(buf, sizeof(buf), "%.0f KB", static_cast<double>(bytes) / 1024.0);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%zu B", bytes);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
void ThemeDownloadActivity::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_MANAGE_THEMES));
|
||||
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));
|
||||
} else if (state_ == THEME_LIST) {
|
||||
if (themes_.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_DOWNLOAD_FAILED));
|
||||
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 themes_[themeIndexFromList(index)].name;
|
||||
},
|
||||
[this](int index) -> std::string {
|
||||
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return "";
|
||||
return themes_[themeIndexFromList(index)].description;
|
||||
},
|
||||
nullptr,
|
||||
[this](int index) -> std::string {
|
||||
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return "";
|
||||
const auto& t = themes_[themeIndexFromList(index)];
|
||||
if (t.hasUpdate) return tr(STR_UPDATE_AVAILABLE);
|
||||
if (t.installed) return tr(STR_INSTALLED);
|
||||
return "";
|
||||
},
|
||||
true,
|
||||
[this](int index) -> bool {
|
||||
if (isDownloadAllRow(index) || isUpdateAllRow(index)) return false;
|
||||
const auto& t = themes_[themeIndexFromList(index)];
|
||||
return t.installed && !t.hasUpdate;
|
||||
});
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK),
|
||||
isSelectedThemeDeletable() ? 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& theme = themes_[downloadingThemeIndex_];
|
||||
std::string statusText = std::string(tr(STR_DOWNLOADING)) + " " + theme.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<float>(fileProgress_) / static_cast<float>(fileTotal_);
|
||||
GUI.drawProgressBar(renderer,
|
||||
Rect{metrics.contentSidePadding, centerY + metrics.verticalSpacing,
|
||||
pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
|
||||
static_cast<int>(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_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_DOWNLOAD_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();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "ThemeInstaller.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
#define THEMES_MANIFEST_VERSION 1
|
||||
#define THEME_ROOT_URL "http://crosspointreader.com/themes"
|
||||
|
||||
#ifndef THEME_MANIFEST_URL
|
||||
#define THEME_MANIFEST_URL THEME_ROOT_URL "/themes.json"
|
||||
#endif
|
||||
|
||||
class ThemeDownloadActivity : public Activity {
|
||||
public:
|
||||
explicit ThemeDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput);
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool preventAutoSleep() override {
|
||||
return state_ == LOADING_MANIFEST || state_ == DOWNLOADING || state_ == COMPLETE || state_ == ERROR;
|
||||
}
|
||||
bool skipLoopDelay() override { return true; }
|
||||
|
||||
private:
|
||||
enum State {
|
||||
WIFI_SELECTION,
|
||||
LOADING_MANIFEST,
|
||||
THEME_LIST,
|
||||
DOWNLOADING,
|
||||
COMPLETE,
|
||||
ERROR,
|
||||
};
|
||||
|
||||
struct ManifestFile {
|
||||
std::string path;
|
||||
std::string url;
|
||||
size_t size = 0;
|
||||
uint32_t crc32 = 0;
|
||||
};
|
||||
|
||||
struct ManifestTheme {
|
||||
std::string id;
|
||||
std::string name;
|
||||
std::string description;
|
||||
int version = 0;
|
||||
std::vector<ManifestFile> files;
|
||||
size_t totalSize = 0;
|
||||
bool installed = false;
|
||||
bool hasUpdate = false;
|
||||
};
|
||||
|
||||
State state_ = WIFI_SELECTION;
|
||||
ThemeInstaller themeInstaller_;
|
||||
ButtonNavigator buttonNavigator_;
|
||||
|
||||
std::string baseUrl_;
|
||||
std::vector<ManifestTheme> themes_;
|
||||
int selectedIndex_ = 0;
|
||||
|
||||
size_t currentFileIndex_ = 0;
|
||||
size_t currentFileTotal_ = 0;
|
||||
size_t fileProgress_ = 0;
|
||||
size_t fileTotal_ = 0;
|
||||
int downloadingThemeIndex_ = -1;
|
||||
std::string errorMessage_;
|
||||
bool cancelRequested_ = false;
|
||||
|
||||
void onWifiSelectionComplete(bool success);
|
||||
bool fetchAndParseManifest();
|
||||
void downloadTheme(ManifestTheme& theme);
|
||||
void downloadAll();
|
||||
void updateAll();
|
||||
static bool computeFileCrc32(const char* path, uint32_t& outCrc);
|
||||
bool showDownloadAllRow() const;
|
||||
bool showUpdateAllRow() const;
|
||||
int specialRowCount() const;
|
||||
bool isDownloadAllRow(int index) const;
|
||||
bool isUpdateAllRow(int index) const;
|
||||
bool isSelectedThemeDeletable() const;
|
||||
void promptDeleteSelectedTheme();
|
||||
void onDeleteConfirmationResult(const ActivityResult& result);
|
||||
int themeIndexFromList(int listIndex) const { return listIndex - specialRowCount(); }
|
||||
int listItemCount() const;
|
||||
size_t totalDownloadSize() const;
|
||||
size_t totalUpdateSize() const;
|
||||
static std::string formatSize(size_t bytes);
|
||||
};
|
||||
Reference in New Issue
Block a user