Merge remote-tracking branch 'origin/main' into main

This commit is contained in:
Adolfo Reyna
2026-07-04 15:39:52 -04:00
396 changed files with 114753 additions and 2051 deletions
+1 -9
View File
@@ -4,7 +4,6 @@
#include <Tactility/app/AppRegistration.h>
#include <Tactility/file/File.h>
#include <Tactility/file/FileLock.h>
#include <Tactility/file/PropertiesFile.h>
#include <tactility/hal/Device.h>
#include <Tactility/Logger.h>
#include <Tactility/Paths.h>
@@ -136,15 +135,8 @@ bool install(const std::string& path) {
return false;
}
std::map<std::string, std::string> properties;
if (!file::loadPropertiesFile(manifest_path, properties)) {
LOGGER.error("Failed to load manifest at {}", manifest_path);
cleanupInstallDirectory(app_target_path);
return false;
}
AppManifest manifest;
if (!parseManifest(properties, manifest)) {
if (!parseManifest(manifest_path, manifest)) {
LOGGER.warn("Invalid manifest");
cleanupInstallDirectory(app_target_path);
return false;
+34 -66
View File
@@ -1,8 +1,12 @@
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/app/AppManifestParsingInternal.h>
#include <Tactility/Logger.h>
#include <Tactility/StringUtils.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <algorithm>
#include <regex>
namespace tt::app {
@@ -12,7 +16,7 @@ constexpr bool validateString(const std::string& value, const std::function<bool
return std::ranges::all_of(value, isValidChar);
}
static bool getValueFromManifest(const std::map<std::string, std::string>& map, const std::string& key, std::string& output) {
bool getValueFromManifest(const std::map<std::string, std::string>& map, const std::string& key, std::string& output) {
const auto iterator = map.find(key);
if (iterator == map.end()) {
LOGGER.error("Failed to find {} in manifest", key);
@@ -28,98 +32,62 @@ bool isValidId(const std::string& id) {
});
}
static bool isValidManifestVersion(const std::string& version) {
bool isValidManifestVersion(const std::string& version) {
return !version.empty() && validateString(version, [](const char c) {
return std::isalnum(c) != 0 || c == '.';
});
}
static bool isValidAppVersionName(const std::string& version) {
bool isValidAppVersionName(const std::string& version) {
return !version.empty() && validateString(version, [](const char c) {
return std::isalnum(c) != 0 || c == '.' || c == '-' || c == '_';
});
}
static bool isValidAppVersionCode(const std::string& version) {
bool isValidAppVersionCode(const std::string& version) {
return !version.empty() && validateString(version, [](const char c) {
return std::isdigit(c) != 0;
});
}
static bool isValidName(const std::string& name) {
bool isValidName(const std::string& name) {
return name.size() >= 2 && validateString(name, [](const char c) {
return std::isalnum(c) != 0 || c == ' ' || c == '-';
});
}
bool parseManifest(const std::map<std::string, std::string>& map, AppManifest& manifest) {
LOGGER.info("Parsing manifest");
/** The V1 format's first line is always the literal "[manifest]" section header; V2 files are flat from the first line onward. */
static bool detectIsV1Format(const std::string& filePath) {
std::string first_line;
bool got_first_line = false;
file::readLines(filePath, true, [&first_line, &got_first_line](const char* line) {
if (!got_first_line) {
first_line = string::trim(std::string(line), " \t\r\n");
got_first_line = true;
}
});
return first_line == "[manifest]";
}
// [manifest]
bool parseManifest(const std::string& filePath, AppManifest& manifest) {
LOGGER.info("Parsing manifest {}", filePath);
std::string manifest_version;
if (!getValueFromManifest(map, "[manifest]version", manifest_version)) {
bool is_v1_format = detectIsV1Format(filePath);
std::map<std::string, std::string> properties;
if (!file::loadPropertiesFile(filePath, properties)) {
LOGGER.error("Failed to load manifest at {}", filePath);
return false;
}
if (!isValidManifestVersion(manifest_version)) {
LOGGER.error("Invalid version");
bool success = is_v1_format
? parseManifestV1(properties, manifest)
: parseManifestV2(properties, manifest);
if (!success) {
return false;
}
// [app]
if (!getValueFromManifest(map, "[app]id", manifest.appId)) {
return false;
}
if (!isValidId(manifest.appId)) {
LOGGER.error("Invalid app id");
return false;
}
if (!getValueFromManifest(map, "[app]name", manifest.appName)) {
return false;
}
if (!isValidName(manifest.appName)) {
LOGGER.error("Invalid app name");
return false;
}
if (!getValueFromManifest(map, "[app]versionName", manifest.appVersionName)) {
return false;
}
if (!isValidAppVersionName(manifest.appVersionName)) {
LOGGER.error("Invalid app version name");
return false;
}
std::string version_code_string;
if (!getValueFromManifest(map, "[app]versionCode", version_code_string)) {
return false;
}
if (!isValidAppVersionCode(version_code_string)) {
LOGGER.error("Invalid app version code");
return false;
}
manifest.appVersionCode = std::stoull(version_code_string);
// [target]
if (!getValueFromManifest(map, "[target]sdk", manifest.targetSdk)) {
return false;
}
if (!getValueFromManifest(map, "[target]platforms", manifest.targetPlatforms)) {
return false;
}
// Defaults
manifest.appCategory = Category::User;
manifest.appLocation = Location::external("");
@@ -0,0 +1,77 @@
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/app/AppManifestParsingInternal.h>
#include <Tactility/Logger.h>
namespace tt::app {
static const auto LOGGER = Logger("AppManifestV1");
bool parseManifestV1(const std::map<std::string, std::string>& map, AppManifest& manifest) {
// [manifest]
std::string manifest_version;
if (!getValueFromManifest(map, "[manifest]version", manifest_version)) {
return false;
}
if (!isValidManifestVersion(manifest_version)) {
LOGGER.error("Invalid version");
return false;
}
// [app]
if (!getValueFromManifest(map, "[app]id", manifest.appId)) {
return false;
}
if (!isValidId(manifest.appId)) {
LOGGER.error("Invalid app id");
return false;
}
if (!getValueFromManifest(map, "[app]name", manifest.appName)) {
return false;
}
if (!isValidName(manifest.appName)) {
LOGGER.error("Invalid app name");
return false;
}
if (!getValueFromManifest(map, "[app]versionName", manifest.appVersionName)) {
return false;
}
if (!isValidAppVersionName(manifest.appVersionName)) {
LOGGER.error("Invalid app version name");
return false;
}
std::string version_code_string;
if (!getValueFromManifest(map, "[app]versionCode", version_code_string)) {
return false;
}
if (!isValidAppVersionCode(version_code_string)) {
LOGGER.error("Invalid app version code");
return false;
}
manifest.appVersionCode = std::stoull(version_code_string);
// [target]
if (!getValueFromManifest(map, "[target]sdk", manifest.targetSdk)) {
return false;
}
if (!getValueFromManifest(map, "[target]platforms", manifest.targetPlatforms)) {
return false;
}
return true;
}
}
@@ -0,0 +1,77 @@
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/app/AppManifestParsingInternal.h>
#include <Tactility/Logger.h>
namespace tt::app {
static const auto LOGGER = Logger("AppManifestV2");
bool parseManifestV2(const std::map<std::string, std::string>& map, AppManifest& manifest) {
// manifest
std::string manifest_version;
if (!getValueFromManifest(map, "manifest.version", manifest_version)) {
return false;
}
if (!isValidManifestVersion(manifest_version)) {
LOGGER.error("Invalid version");
return false;
}
// app
if (!getValueFromManifest(map, "app.id", manifest.appId)) {
return false;
}
if (!isValidId(manifest.appId)) {
LOGGER.error("Invalid app id");
return false;
}
if (!getValueFromManifest(map, "app.name", manifest.appName)) {
return false;
}
if (!isValidName(manifest.appName)) {
LOGGER.error("Invalid app name");
return false;
}
if (!getValueFromManifest(map, "app.version.name", manifest.appVersionName)) {
return false;
}
if (!isValidAppVersionName(manifest.appVersionName)) {
LOGGER.error("Invalid app version name");
return false;
}
std::string version_code_string;
if (!getValueFromManifest(map, "app.version.code", version_code_string)) {
return false;
}
if (!isValidAppVersionCode(version_code_string)) {
LOGGER.error("Invalid app version code");
return false;
}
manifest.appVersionCode = std::stoull(version_code_string);
// target
if (!getValueFromManifest(map, "target.sdk", manifest.targetSdk)) {
return false;
}
if (!getValueFromManifest(map, "target.platforms", manifest.targetPlatforms)) {
return false;
}
return true;
}
}
+28 -1
View File
@@ -2,6 +2,7 @@
#include <Tactility/TactilityCore.h>
#include <Tactility/TactilityPrivate.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppPaths.h>
#include <Tactility/CpuAffinity.h>
@@ -10,6 +11,7 @@
#include <Tactility/kernel/SystemEvents.h>
#include <Tactility/Logger.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/Paths.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/BootSettings.h>
#include <Tactility/settings/DisplaySettings.h>
@@ -44,6 +46,10 @@ class BootApp : public App {
// onShow() reads this instead of the live flag to avoid a race between the two.
static std::atomic<bool> isUsbBootSplash;
// Set by bootThreadCallback() when CONFIG_TT_USER_DATA_LOCATION_SD is defined but no SD card is mounted.
// onShow() reads this to show an error instead of the normal splash, and boot halts instead of starting the launcher.
static std::atomic<bool> sdCardMissing;
Thread thread = Thread(
"boot",
5120,
@@ -122,11 +128,20 @@ class BootApp : public App {
setupDisplay(); // Set backlight
prepareFileSystems();
#ifdef CONFIG_TT_USER_DATA_LOCATION_SD
std::string sd_path;
if (!findFirstMountedSdCardPath(sd_path)) {
LOGGER.error("SD card not found");
sdCardMissing = true;
}
#endif
if (!setupUsbBootMode()) {
LOGGER.info("initFromBootApp");
registerApps();
waitForMinimalSplashDuration(start_time);
stop(manifest.appId);
// When SD card is missing, wait for dialog result
if (!sdCardMissing) stop(manifest.appId);
startNextApp();
}
@@ -162,6 +177,11 @@ class BootApp : public App {
}
static void startNextApp() {
if (sdCardMissing) {
alertdialog::start("Error", "SD card not found.\nPlease insert one and reboot.", std::vector<const char*> { "Reboot" });
return;
}
#ifdef ESP_PLATFORM
if (esp_reset_reason() == ESP_RST_PANIC) {
crashdiagnostics::start();
@@ -195,6 +215,12 @@ public:
thread.join();
}
void onResult(AppContext& /*app*/, LaunchId /*launchId*/, Result /*result*/, std::unique_ptr<Bundle> /*bundle*/) override {
#ifdef ESP_PLATFORM
esp_restart();
#endif
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lvgl::obj_set_style_bg_blacken(parent);
lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT);
@@ -232,6 +258,7 @@ public:
};
std::atomic<bool> BootApp::isUsbBootSplash = false;
std::atomic<bool> BootApp::sdCardMissing = false;
extern const AppManifest manifest = {
.appId = "Boot",
+14 -7
View File
@@ -16,14 +16,21 @@ static const auto LOGGER = Logger("BtManage");
extern const AppManifest manifest;
static void onBtToggled(bool enabled) {
struct Device* dev = bluetooth::findFirstDevice();
static void onBtToggled(bool requestOn) {
#if defined(CONFIG_BT_NIMBLE_ENABLED)
Device* dev = device_find_first_by_type(&BLUETOOTH_TYPE);
if (!dev) return;
bluetooth_set_radio_enabled(dev, enabled);
bool radio_on = bluetooth::isRadioOnOrPending(dev);
if (requestOn && !radio_on) {
bluetooth::start(dev);
} else if (!requestOn && radio_on) {
bluetooth::stop(dev);
}
#endif
}
static void onScanToggled(bool enabled) {
struct Device* dev = bluetooth::findFirstDevice();
Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE);
if (!dev) return;
if (enabled) {
bluetooth_scan_start(dev);
@@ -108,7 +115,7 @@ void BtManage::onBtEvent(const struct BtEvent& event) {
case BT_EVENT_RADIO_STATE_CHANGED:
if (event.radio_state == BT_RADIO_STATE_ON) {
getState().updatePairedPeers();
struct Device* dev = bluetooth::findFirstDevice();
Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE);
if (dev && !bluetooth_is_scanning(dev)) {
bluetooth_scan_start(dev);
}
@@ -121,7 +128,7 @@ void BtManage::onBtEvent(const struct BtEvent& event) {
requestViewUpdate();
}
static void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent event) {
static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event) {
// BT event callbacks can fire from the NimBLE host task (e.g. DISCONNECT during
// nimble_port_stop shutdown). Calling onBtEvent() synchronously from the NimBLE
// task would block it on the LVGL mutex (held by the LVGL task waiting in
@@ -137,7 +144,7 @@ void BtManage::onShow(AppContext& app, lv_obj_t* parent) {
// Initialise state and view before subscribing to avoid incoming events
// racing with state initialisation.
state.setRadioState(bluetooth::getRadioState());
struct Device* dev = bluetooth::findFirstDevice();
Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE);
state.setScanning(dev ? bluetooth_is_scanning(dev) : false);
state.updateScanResults();
state.updatePairedPeers();
+2 -5
View File
@@ -16,8 +16,6 @@
namespace tt::app::btmanage {
static const auto LOGGER = Logger("BtManageView");
static void onEnableSwitchChanged(lv_event_t* event) {
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
@@ -49,7 +47,7 @@ static void onEnableOnBootParentClicked(lv_event_t* event) {
static void onScanButtonClicked(lv_event_t* event) {
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
struct Device* dev = bluetooth::findFirstDevice();
Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE);
bool scanning = dev ? bluetooth_is_scanning(dev) : false;
bt->getBindings().onScanToggled(!scanning);
}
@@ -57,7 +55,6 @@ static void onScanButtonClicked(lv_event_t* event) {
// region Peer list callbacks
struct PeerListItemData {
View* view;
size_t index;
bool isPaired;
};
@@ -102,7 +99,7 @@ void View::createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired
auto* button = lv_list_add_button(peers_list, nullptr, label.c_str());
auto* item_data = new PeerListItemData { this, index, isPaired };
auto* item_data = new PeerListItemData { index, isPaired };
lv_obj_set_user_data(button, item_data);
lv_obj_add_event_cb(button, onConnect, LV_EVENT_SHORT_CLICKED, item_data);
lv_obj_add_event_cb(button, [](lv_event_t* e) {
@@ -1,16 +1,18 @@
#include <Tactility/app/btpeersettings/BtPeerSettings.h>
#include <Tactility/Logger.h>
#include "tactility/device.h"
#include <Tactility/LogMessages.h>
#include <Tactility/Logger.h>
#include <Tactility/app/App.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <tactility/check.h>
#include <tactility/drivers/bluetooth.h>
@@ -124,7 +126,7 @@ public:
}
void onShow(AppContext& app, lv_obj_t* parent) override {
if (struct Device* dev = bluetooth::findFirstDevice()) {
if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) {
bluetooth_add_event_callback(dev, this, onKernelBtEvent);
}
@@ -192,7 +194,7 @@ public:
}
void onHide(AppContext& app) override {
if (struct Device* dev = bluetooth::findFirstDevice()) {
if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) {
bluetooth_remove_event_callback(dev, onKernelBtEvent);
}
viewEnabled = false;
+24 -4
View File
@@ -8,8 +8,10 @@
#include <Tactility/app/chat/ChatProtocol.h>
#include <Tactility/crypt/Crypt.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Logger.h>
#include <Tactility/Paths.h>
#include <esp_random.h>
@@ -24,11 +26,17 @@ namespace tt::app::chat {
static const auto LOGGER = Logger("ChatSettings");
static std::string getSettingsFilePath() {
return getUserDataPath() + "/settings/chat.properties";
}
constexpr auto* KEY_SENDER_ID = "senderId";
constexpr auto* KEY_NICKNAME = "nickname";
constexpr auto* KEY_ENCRYPTION_KEY = "encryptionKey";
constexpr auto* KEY_CHAT_CHANNEL = "chatChannel";
uint32_t defaultSenderId = 0;
// IV_SEED provides basic obfuscation for stored encryption keys, not strong encryption.
// The device master key (from crypt::getIv) provides the actual security.
static constexpr auto* IV_SEED = "chat_key";
@@ -107,8 +115,9 @@ static uint32_t generateSenderId() {
}
ChatSettingsData getDefaultSettings() {
if (defaultSenderId == 0) defaultSenderId = generateSenderId();
return ChatSettingsData{
.senderId = 0,
.senderId = defaultSenderId,
.nickname = "Device",
.encryptionKey = {},
.hasEncryptionKey = false,
@@ -119,8 +128,14 @@ ChatSettingsData getDefaultSettings() {
ChatSettingsData loadSettings() {
ChatSettingsData settings = getDefaultSettings();
auto settings_path = getSettingsFilePath();
if (!file::isFile(settings_path)) {
settings.senderId = generateSenderId();
return settings;
}
std::map<std::string, std::string> map;
if (!file::loadPropertiesFile(CHAT_SETTINGS_FILE, map)) {
if (!file::loadPropertiesFile(settings_path, map)) {
settings.senderId = generateSenderId();
return settings;
}
@@ -171,11 +186,16 @@ bool saveSettings(const ChatSettingsData& settings) {
map[KEY_ENCRYPTION_KEY] = "";
}
return file::savePropertiesFile(CHAT_SETTINGS_FILE, map);
auto settings_path = getSettingsFilePath();
if (!file::findOrCreateParentDirectory(settings_path, 0755)) {
LOGGER.error("Failed to create parent dir for {}", settings_path);
return false;
}
return file::savePropertiesFile(settings_path, map);
}
bool settingsFileExists() {
return access(CHAT_SETTINGS_FILE, F_OK) == 0;
return access(getSettingsFilePath().c_str(), F_OK) == 0;
}
} // namespace tt::app::chat
+16 -11
View File
@@ -3,6 +3,7 @@
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppPaths.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/app/setup/Setup.h>
#include <Tactility/hal/power/PowerDevice.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/BootSettings.h>
@@ -137,22 +138,26 @@ public:
void onCreate(AppContext& app) override {
settings::BootSettings boot_properties;
if (settings::loadBootSettings(boot_properties)) {
if (
!boot_properties.autoStartAppId.empty() &&
findAppManifestById(boot_properties.autoStartAppId) != nullptr
) {
LOGGER.info("Starting {}", boot_properties.autoStartAppId);
start(boot_properties.autoStartAppId);
} else {
LOGGER.info("No auto-start app configured. Skipping default auto-start due to boot.properties presence.");
}
} else if (
if (
// Auto-start due to built-in requirement
strcmp(CONFIG_TT_AUTO_START_APP_ID, "") != 0 &&
findAppManifestById(CONFIG_TT_AUTO_START_APP_ID) != nullptr
) {
LOGGER.info("Starting {}", CONFIG_TT_AUTO_START_APP_ID);
start(CONFIG_TT_AUTO_START_APP_ID);
} else if (
// Auto-start due to user configuration
settings::loadBootSettings(boot_properties) &&
!boot_properties.autoStartAppId.empty() &&
findAppManifestById(boot_properties.autoStartAppId) != nullptr
) {
LOGGER.info("Starting {}", boot_properties.autoStartAppId);
start(boot_properties.autoStartAppId);
} else {
// No auto-start, consider running system setup
if (!setup::isCompleted()) {
setup::start();
}
}
}
@@ -28,7 +28,6 @@ extern const AppManifest manifest;
class LocaleSettingsApp final : public App {
tt::i18n::TextResources textResources = tt::i18n::TextResources(TEXT_RESOURCE_PATH);
RecursiveMutex mutex;
lv_obj_t* regionTextArea = nullptr;
lv_obj_t* languageDropdown = nullptr;
bool settingsUpdated = false;
@@ -98,33 +97,6 @@ public:
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Region
auto* region_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(region_wrapper, LV_PCT(100));
lv_obj_set_height(region_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(region_wrapper, 8, 0);
lv_obj_set_style_border_width(region_wrapper, 0, 0);
auto* region_label = lv_label_create(region_wrapper);
lv_label_set_text(region_label, textResources[i18n::Text::REGION].c_str());
lv_obj_align(region_label, LV_ALIGN_LEFT_MID, 4, 0);
// Region text area for user input (e.g., US, EU, JP)
regionTextArea = lv_textarea_create(region_wrapper);
lv_obj_set_width(regionTextArea, 120);
lv_textarea_set_one_line(regionTextArea, true);
lv_textarea_set_max_length(regionTextArea, 50);
lv_textarea_set_placeholder_text(regionTextArea, "e.g. US, EU");
// Load current region from settings
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
lv_textarea_set_text(regionTextArea, sysSettings.region.c_str());
}
lv_obj_add_event_cb(regionTextArea, onRegionChanged, LV_EVENT_VALUE_CHANGED, this);
lv_obj_align(regionTextArea, LV_ALIGN_RIGHT_MID, 0, 0);
// Language
auto* language_wrapper = lv_obj_create(main_wrapper);
@@ -145,16 +117,6 @@ public:
lv_dropdown_set_selected(languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
lv_obj_add_event_cb(languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, this);
}
void onHide(AppContext& app) override {
if (settingsUpdated && regionTextArea) {
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
sysSettings.region = lv_textarea_get_text(regionTextArea);
settings::saveSystemSettings(sysSettings);
}
}
}
};
extern const AppManifest manifest = {
+216
View File
@@ -0,0 +1,216 @@
#include <tactility/lvgl_fonts.h>
#include <tactility/lvgl_module.h>
#include <Tactility/app/App.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/setup/Setup.h>
#include <Tactility/Preferences.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/app/wifimanage/WifiManage.h>
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/service/wifi/Wifi.h>
#include <lvgl.h>
#include <functional>
#include <vector>
namespace tt::app::setup {
extern const AppManifest manifest;
constexpr auto* PREFERENCES_NAMESPACE = "setup";
constexpr auto* PREFERENCES_KEY_COMPLETED = "completed";
bool isCompleted() {
Preferences preferences(PREFERENCES_NAMESPACE);
bool completed = false;
preferences.optBool(PREFERENCES_KEY_COMPLETED, completed);
return completed;
}
static void markCompleted() {
Preferences preferences(PREFERENCES_NAMESPACE);
preferences.putBool(PREFERENCES_KEY_COMPLETED, true);
}
struct StepConfiguration {
std::string title;
std::string description;
std::function<void()> run;
};
class SetupApp final : public App {
enum class Phase {
Welcome,
StepIntro,
Done
};
Phase phase = Phase::Welcome;
size_t stepIndex = 0;
std::vector<StepConfiguration> steps;
bool isShown = false;
lv_obj_t* titleLabel = nullptr;
lv_obj_t* descriptionLabel = nullptr;
lv_obj_t* skipButton = nullptr;
lv_obj_t* continueButton = nullptr;
static void onSkipClickedCallback(lv_event_t* e) {
auto* app = (SetupApp*)lv_event_get_user_data(e);
app->onSkipClicked();
}
static void onContinueClickedCallback(lv_event_t* e) {
auto* app = (SetupApp*)lv_event_get_user_data(e);
app->onContinueClicked();
}
void renderCurrent() {
switch (phase) {
case Phase::Welcome: {
lv_label_set_text(titleLabel, "Welcome");
auto device_names = string::split(std::string(CONFIG_TT_DEVICE_NAME_SIMPLE), ",");
lv_label_set_text_fmt(descriptionLabel, "It's time to set up your %s!", device_names.front().c_str());
lv_obj_add_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Continue");
break;
}
case Phase::StepIntro: {
const auto& step = steps[stepIndex];
lv_label_set_text(titleLabel, step.title.c_str());
lv_label_set_text(descriptionLabel, step.description.c_str());
lv_obj_remove_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(skipButton, 0), "Skip");
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Continue");
break;
}
case Phase::Done:
lv_label_set_text(titleLabel, "Setup Complete");
lv_label_set_text(descriptionLabel, "You're all set.");
lv_obj_add_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Finish");
break;
}
}
void advanceTo(size_t index) {
if (index < steps.size()) {
stepIndex = index;
phase = Phase::StepIntro;
} else {
phase = Phase::Done;
}
// Widgets may not exist yet: onShow() runs asynchronously on the GUI task and
// may not have (re)created them by the time onResult() advances the state.
// onShow() calls renderCurrent() itself once the widgets are ready.
if (isShown) {
renderCurrent();
}
}
void onSkipClicked() {
if (phase == Phase::StepIntro) {
advanceTo(stepIndex + 1);
}
}
void onContinueClicked() {
switch (phase) {
case Phase::Welcome:
advanceTo(0);
break;
case Phase::StepIntro:
steps[stepIndex].run();
break;
case Phase::Done:
markCompleted();
stop(manifest.appId);
break;
}
}
public:
void onCreate(AppContext& app) override {
steps = {
{
.title = "Time Zone Setup",
.description = "Let's set the time zone.",
.run = [] { timezone::start(true); }
},
{
.title = "Wi-Fi Setup",
.description = "Let's connect to a Wi-Fi access point.",
.run = [] {
service::wifi::setEnabled(true);
wifimanage::start();
}
}
};
}
void onShow(AppContext& app, lv_obj_t* parent) override {
titleLabel = lv_label_create(parent);
lv_obj_set_width(titleLabel, LV_PCT(80));
lv_obj_set_style_text_align(titleLabel, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(titleLabel, LV_LABEL_LONG_WRAP);
auto* font = lvgl_get_text_font(FONT_SIZE_LARGE);
lv_obj_set_style_text_font(titleLabel, font, 0);
descriptionLabel = lv_label_create(parent);
lv_obj_set_width(descriptionLabel, LV_PCT(80));
lv_obj_set_style_text_align(descriptionLabel, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(descriptionLabel, LV_LABEL_LONG_WRAP);
lv_obj_align(descriptionLabel, LV_ALIGN_CENTER, 0, 0);
int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE);
lv_obj_align_to(titleLabel, descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin);
skipButton = lv_button_create(parent);
lv_obj_t* skip_label = lv_label_create(skipButton);
lv_label_set_text(skip_label, "Skip");
lv_obj_center(skip_label);
lv_obj_align(skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12);
lv_obj_add_event_cb(skipButton, onSkipClickedCallback, LV_EVENT_SHORT_CLICKED, this);
continueButton = lv_button_create(parent);
lv_obj_t* continue_label = lv_label_create(continueButton);
lv_label_set_text(continue_label, "Continue");
lv_obj_center(continue_label);
lv_obj_align(continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12);
lv_obj_add_event_cb(continueButton, onContinueClickedCallback, LV_EVENT_SHORT_CLICKED, this);
isShown = true;
renderCurrent();
}
void onHide(AppContext& app) override {
isShown = false;
}
void onResult(AppContext& app, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
lvgl_lock();
advanceTo(stepIndex + 1);
lvgl_unlock();
}
};
extern const AppManifest manifest = {
.appId = "Setup",
.appName = "Setup",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden | AppManifest::Flags::HideStatusBar,
.createApp = create<SetupApp>
};
LaunchId start() {
return app::start(manifest.appId);
}
}
@@ -1,12 +1,15 @@
#include "tactility/lvgl_module.h"
#include <Tactility/Logger.h>
#include <Tactility/RecursiveMutex.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/Logger.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/RecursiveMutex.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/Time.h>
#include <Tactility/settings/SystemSettings.h>
#include <Tactility/settings/Time.h>
#include <lvgl.h>
@@ -23,6 +26,7 @@ class TimeDateSettingsApp final : public App {
RecursiveMutex mutex;
lv_obj_t* timeZoneLabel = nullptr;
lv_obj_t* dateFormatDropdown = nullptr;
bool isShown = false;
static void onTimeFormatChanged(lv_event_t* event) {
auto* widget = lv_event_get_target_obj(event);
@@ -31,7 +35,7 @@ class TimeDateSettingsApp final : public App {
}
static void onTimeZonePressed(lv_event_t* event) {
timezone::start();
timezone::start(true);
}
static void onDateFormatChanged(lv_event_t* event) {
@@ -133,6 +137,12 @@ public:
}
lv_obj_center(timeZoneLabel);
lv_label_set_text(timeZoneLabel, timeZoneName.c_str());
isShown = true;
}
void onHide(AppContext& app) override {
isShown = false;
}
void onResult(AppContext& app, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
@@ -140,13 +150,14 @@ public:
const auto name = timezone::getResultName(*bundle);
const auto code = timezone::getResultCode(*bundle);
LOGGER.info("Result name={} code={}", name, code);
settings::setTimeZone(name, code);
if (!name.empty()) {
if (lvgl::lock(100 / portTICK_PERIOD_MS)) {
// onShow() may not have (re)created the widgets yet: onResult() runs synchronously
// on the loader thread and can race ahead of the async gui-task redraw.
if (!name.empty() && lvgl_try_lock(100 / portTICK_PERIOD_MS)) {
if (isShown) {
lv_label_set_text(timeZoneLabel, name.c_str());
lvgl::unlock();
}
lvgl_unlock();
}
}
}
+17 -3
View File
@@ -13,6 +13,7 @@
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/Time.h>
#include <lvgl.h>
#include <memory>
@@ -23,6 +24,7 @@ static const auto LOGGER = Logger("TimeZone");
constexpr auto* RESULT_BUNDLE_CODE_INDEX = "code";
constexpr auto* RESULT_BUNDLE_NAME_INDEX = "name";
constexpr auto* PARAM_SAVE_TIME_ZONE = "saveTimeZone";
extern const AppManifest manifest;
@@ -74,6 +76,7 @@ class TimeZoneApp final : public App {
std::unique_ptr<Timer> updateTimer;
lv_obj_t* listWidget = nullptr;
lv_obj_t* filterTextareaWidget = nullptr;
bool saveTimeZone = false;
static void onTextareaValueChangedCallback(lv_event_t* e) {
auto* app = (TimeZoneApp*)lv_event_get_user_data(e);
@@ -104,6 +107,10 @@ class TimeZoneApp final : public App {
auto& entry = entries[index];
if (saveTimeZone) {
settings::setTimeZone(entry.name, entry.code);
}
auto bundle = std::make_unique<Bundle>();
setResultName(*bundle, entry.name);
setResultCode(*bundle, entry.code);
@@ -222,6 +229,11 @@ public:
}
void onCreate(AppContext& app) override {
auto parameters = app.getParameters();
if (parameters != nullptr) {
parameters->optBool(PARAM_SAVE_TIME_ZONE, saveTimeZone);
}
updateTimer = std::make_unique<Timer>(Timer::Type::Once, 500 / portTICK_PERIOD_MS, [this] {
updateList();
});
@@ -230,14 +242,16 @@ public:
extern const AppManifest manifest = {
.appId = "TimeZone",
.appName = "Select timezone",
.appName = "Select Time zone",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<TimeZoneApp>
};
LaunchId start() {
return app::start(manifest.appId);
LaunchId start(bool saveTimeZone) {
auto bundle = std::make_shared<Bundle>();
bundle->putBool(PARAM_SAVE_TIME_ZONE, saveTimeZone);
return app::start(manifest.appId, bundle);
}
}
@@ -29,7 +29,6 @@ class WebServerSettingsApp final : public App {
settings::webserver::WebServerSettings originalSettings;
bool updated = false;
bool wifiSettingsChanged = false;
bool webServerEnabledChanged = false;
lv_obj_t* dropdownWifiMode = nullptr;
lv_obj_t* textAreaApPassword = nullptr;
lv_obj_t* switchApOpenNetwork = nullptr;
@@ -61,11 +60,19 @@ class WebServerSettingsApp final : public App {
getMainDispatcher().dispatch([app, enabled] {
app->wsSettings.webServerEnabled = enabled;
app->updated = true;
app->webServerEnabledChanged = true;
if (lvgl::lock(100)) {
app->updateUrlDisplay();
lvgl::unlock();
}
// Apply immediately instead of waiting for app exit
const auto copy = app->wsSettings;
if (!settings::webserver::save(copy)) {
LOGGER.warn("Failed to persist WebServer settings; changes may be lost on reboot");
}
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
LOGGER.info("WebServer {}", enabled ? "enabling..." : "disabling...");
service::webserver::setWebServerEnabled(enabled);
});
}
@@ -131,30 +138,6 @@ class WebServerSettingsApp final : public App {
});
}
static void onSyncAssets(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
auto* btn = static_cast<lv_obj_t*>(lv_event_get_target_obj(e));
lv_obj_add_state(btn, LV_STATE_DISABLED);
LOGGER.info("Manual asset sync triggered");
getMainDispatcher().dispatch([app, btn]{
bool success = service::webserver::syncAssets();
if (success) {
LOGGER.info("Asset sync completed successfully");
} else {
LOGGER.error("Asset sync failed");
}
// Only re-enable if button still exists (user hasn't navigated away)
// Must acquire LVGL lock since we're not in an LVGL event callback context
if (lvgl::lock(1000)) {
if (lv_obj_is_valid(btn)) {
lv_obj_remove_state(btn, LV_STATE_DISABLED);
}
lvgl::unlock();
}
});
}
void updateUrlDisplay() {
if (!labelUrlValue) return;
@@ -197,6 +180,8 @@ class WebServerSettingsApp final : public App {
public:
void onCreate(AppContext& app) override {
wsSettings = settings::webserver::loadOrGetDefault();
// Reflect the server's actual running state, in case it differs from the persisted setting
wsSettings.webServerEnabled = service::webserver::isWebServerEnabled();
originalSettings = wsSettings;
}
@@ -341,32 +326,6 @@ public:
updateUrlDisplay();
// Sync Assets button
auto* sync_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(sync_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(sync_wrapper, 10, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(sync_wrapper, 1, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(sync_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_flex_cross_place(sync_wrapper, LV_FLEX_ALIGN_START, 0);
auto* sync_label = lv_label_create(sync_wrapper);
lv_label_set_text(sync_label, "Asset Synchronization");
auto* sync_info = lv_label_create(sync_wrapper);
lv_label_set_long_mode(sync_info, LV_LABEL_LONG_WRAP);
lv_obj_set_width(sync_info, LV_PCT(95));
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(sync_info, lv_palette_main(LV_PALETTE_GREY), 0);
}
lv_label_set_text(sync_info, "Sync web assets between Data partition and SD card backup");
auto* sync_button = lv_btn_create(sync_wrapper);
lv_obj_set_width(sync_button, LV_SIZE_CONTENT);
auto* sync_button_label = lv_label_create(sync_button);
lv_label_set_text(sync_button_label, "Sync Assets Now");
lv_obj_center(sync_button_label);
lv_obj_add_event_cb(sync_button, onSyncAssets, LV_EVENT_CLICKED, this);
// Info text
auto* info_label = lv_label_create(main_wrapper);
lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP);
@@ -394,11 +353,11 @@ public:
}
// Save to flash only (settings sync at boot handles SD restore)
// Note: the enable/disable toggle already saved and applied itself immediately
const auto copy = wsSettings;
const bool wifiChanged = wifiSettingsChanged;
const bool webServerChanged = webServerEnabledChanged;
getMainDispatcher().dispatch([copy, wifiChanged, webServerChanged]{
getMainDispatcher().dispatch([copy, wifiChanged]{
// Save to flash (fast, low memory pressure)
if (!settings::webserver::save(copy)) {
LOGGER.warn("Failed to persist WebServer settings; changes may be lost on reboot");
@@ -411,12 +370,6 @@ public:
if (wifiChanged) {
LOGGER.info("WiFi mode changed to {}", copy.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station");
}
// Control WebServer service immediately
if (webServerChanged) {
LOGGER.info("WebServer {}", copy.webServerEnabled ? "enabling..." : "disabling...");
service::webserver::setWebServerEnabled(copy.webServerEnabled);
}
});
}
}