From 8e7f9975978be237a750ba37322c74a12d9c854a Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 4 Aug 2026 18:48:23 +0200 Subject: [PATCH 01/31] Rename system_event api functions and add subscribe mechanism --- Devices/lilygo-tlora-pager/source/module.cpp | 4 +- Documentation/ideas.md | 2 + Tactility/Source/lvgl/Statusbar.cpp | 2 +- .../Source/service/rtctime/RtcTimeService.cpp | 4 +- Tactility/Source/service/wifi/Wifi.cpp | 4 +- .../include/tactility/system_event.h | 67 ++++++++- TactilityKernel/source/system_event.cpp | 113 +++++++++++++-- .../Source/SystemEventTest.cpp | 132 +++++++++++++----- 8 files changed, 268 insertions(+), 60 deletions(-) diff --git a/Devices/lilygo-tlora-pager/source/module.cpp b/Devices/lilygo-tlora-pager/source/module.cpp index 4f4b27aa3..ca93edd98 100644 --- a/Devices/lilygo-tlora-pager/source/module.cpp +++ b/Devices/lilygo-tlora-pager/source/module.cpp @@ -17,12 +17,12 @@ static void on_boot_completed(struct SystemEvent* /*event*/, void* /*context*/) } static error_t start() { - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed, nullptr); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed, nullptr); return ERROR_NONE; } static error_t stop() { - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed); return ERROR_NONE; } diff --git a/Documentation/ideas.md b/Documentation/ideas.md index f92dbe67f..b2910b85f 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -12,6 +12,8 @@ ## Higher Priority +- Improve Setup: Show "Step done" screen +- Improve Setup: Add keyboard/keypad navigation explanation - display.h API: get_backlight does not change ref counting, but it should - bluetooth: various getters for child devices do not change ref counting, but they should - Improve kernel_init.cpp (and other modules): create driver_ensure_added() and driver_ensure_destructed() diff --git a/Tactility/Source/lvgl/Statusbar.cpp b/Tactility/Source/lvgl/Statusbar.cpp index 92016eda6..9f192ff0f 100644 --- a/Tactility/Source/lvgl/Statusbar.cpp +++ b/Tactility/Source/lvgl/Statusbar.cpp @@ -133,7 +133,7 @@ static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) if (!statusbar_data.time_update_timer->isRunning()) { statusbar_data.time_update_timer->start(); - system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, onTimeChanged, nullptr); + system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, onTimeChanged, nullptr); } } diff --git a/Tactility/Source/service/rtctime/RtcTimeService.cpp b/Tactility/Source/service/rtctime/RtcTimeService.cpp index d8f677364..4f430b500 100644 --- a/Tactility/Source/service/rtctime/RtcTimeService.cpp +++ b/Tactility/Source/service/rtctime/RtcTimeService.cpp @@ -120,7 +120,7 @@ bool RtcTimeService::onStart(ServiceContext& serviceContext) { system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); } - if (system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, &RtcTimeService::onTimeChangedTrampoline, this) == ERROR_NONE) { + if (system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, &RtcTimeService::onTimeChangedTrampoline, this) == ERROR_NONE) { timeEventSubscribed = true; } @@ -129,7 +129,7 @@ bool RtcTimeService::onStart(ServiceContext& serviceContext) { void RtcTimeService::onStop(ServiceContext& serviceContext) { if (timeEventSubscribed) { - system_event_unsubscribe(KERNEL_EVENT_TIME_CHANGED, &RtcTimeService::onTimeChangedTrampoline); + system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, &RtcTimeService::onTimeChangedTrampoline); timeEventSubscribed = false; } diff --git a/Tactility/Source/service/wifi/Wifi.cpp b/Tactility/Source/service/wifi/Wifi.cpp index 2aa57f3f7..28e0f9d19 100644 --- a/Tactility/Source/service/wifi/Wifi.cpp +++ b/Tactility/Source/service/wifi/Wifi.cpp @@ -510,7 +510,7 @@ class WifiService final : public Service { LOG_W(TAG, "No WiFi device found"); } - if (system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, onBootCompleted, nullptr) == ERROR_NONE) { + if (system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, onBootCompleted, nullptr) == ERROR_NONE) { state.bootEventSubscribed = true; } @@ -531,7 +531,7 @@ class WifiService final : public Service { state.autoConnectTimer = nullptr; // Must release as it holds a reference via its callback. if (state.bootEventSubscribed) { - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, onBootCompleted); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, onBootCompleted); state.bootEventSubscribed = false; } diff --git a/TactilityKernel/include/tactility/system_event.h b/TactilityKernel/include/tactility/system_event.h index 65c83c467..5b0b82c15 100644 --- a/TactilityKernel/include/tactility/system_event.h +++ b/TactilityKernel/include/tactility/system_event.h @@ -5,6 +5,8 @@ #include #include +#include +#include #ifdef __cplusplus extern "C" { @@ -73,7 +75,7 @@ struct ServiceStoppedEvent { /** * @param[in] event the event being delivered; only valid for the duration of the call - * @param[in] context the context pointer passed to system_event_subscribe() + * @param[in] context the context pointer passed to system_event_callback_add() */ typedef void (*system_event_callback_t)(struct SystemEvent* event, void* context); @@ -82,7 +84,7 @@ typedef void (*system_event_callback_t)(struct SystemEvent* event, void* context * @warning Does not work in ISR context. * @warning @a callback is invoked synchronously, on the caller's task, from within * system_event_emit(). The internal subscription lock is not held during the call, so - * @a callback may itself call system_event_subscribe(), system_event_unsubscribe() or + * @a callback may itself call system_event_callback_add(), system_event_callback_remove() or * system_event_emit() without deadlocking - but a subscribe/unsubscribe made from within * a callback only takes effect for events emitted after the current system_event_emit() * call returns, since that call already snapshotted the subscriptions it will invoke. @@ -91,7 +93,7 @@ typedef void (*system_event_callback_t)(struct SystemEvent* event, void* context * @param[in] context an opaque pointer passed back to @a callback unmodified * @return ERROR_NONE on success */ -error_t system_event_subscribe( +error_t system_event_callback_add( enum SystemEventType type, system_event_callback_t callback, void *context @@ -100,11 +102,11 @@ error_t system_event_subscribe( /** * Remove a previously added subscription. * @warning Does not work in ISR context. - * @param[in] type the event type passed to the matching system_event_subscribe() call - * @param[in] callback the callback passed to the matching system_event_subscribe() call + * @param[in] type the event type passed to the matching system_event_callback_add() call + * @param[in] callback the callback passed to the matching system_event_callback_add() call * @return ERROR_NONE on success, ERROR_NOT_FOUND if no matching subscription exists */ -error_t system_event_unsubscribe( +error_t system_event_callback_remove( enum SystemEventType type, system_event_callback_t callback ); @@ -125,6 +127,59 @@ error_t system_event_emit( size_t data_len ); +/** Size of the largest type-specific event struct documented in SystemEventType, i.e. the + * embedded buffer size needed by SystemEventSubscription to hold any event's payload by value. */ +#define SYSTEM_EVENT_MAX_DATA_SIZE (sizeof(struct NetworkConnectedEvent)) + +/** + * gps.h-style poll subscription: caller-owned node, registered with system_event_subscribe() + * and polled with system_event_await(). Unlike system_event_callback_t, the payload is copied + * by value into @a data (up to SYSTEM_EVENT_MAX_DATA_SIZE bytes) so it remains valid after + * system_event_emit() returns. + * @warning Fields other than `type` are for internal use only; do not read or write them + * directly. + */ +struct SystemEventSubscription { + /** Event type to subscribe to; set by the caller before system_event_subscribe(). */ + enum SystemEventType type; + + TaskHandle_t task; + + uint64_t timestamp; + uint8_t data[SYSTEM_EVENT_MAX_DATA_SIZE]; + size_t data_len; + + uint32_t sequence; + uint32_t consumed_sequence; + + struct SystemEventSubscription* next; +}; + +/** + * Register a poll subscription for events of @a sub->type. + * @warning Does not work in ISR context. + * @param[in,out] sub subscription to register; caller sets @a sub->type beforehand, owns the + * storage, and must keep it alive (and stationary) until unsubscribed + * @return ERROR_NONE on success + */ +error_t system_event_subscribe(struct SystemEventSubscription* sub); + +/** + * Remove a previously registered poll subscription. + * @warning Does not work in ISR context. + * @param[in] sub subscription to remove, as passed to system_event_subscribe() + * @return ERROR_NONE on success, ERROR_NOT_FOUND if no matching subscription exists + */ +error_t system_event_unsubscribe(struct SystemEventSubscription* sub); + +/** + * Blocks the calling task until a new event arrives for @a sub, or timeout elapses. + * @param[in,out] sub subscription to wait on, as passed to system_event_subscribe() + * @param[in] timeout max ticks to wait + * @return ERROR_NONE if an event arrived, ERROR_TIMEOUT if the timeout elapsed + */ +error_t system_event_await(struct SystemEventSubscription* sub, TickType_t timeout); + #ifdef __cplusplus } #endif diff --git a/TactilityKernel/source/system_event.cpp b/TactilityKernel/source/system_event.cpp index 5038e11a2..ae649d2f0 100644 --- a/TactilityKernel/source/system_event.cpp +++ b/TactilityKernel/source/system_event.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -27,9 +28,16 @@ struct KernelEventMutex { static KernelEventMutex subscriptions_mutex; +// Intrusive singly-linked list of poll subscriptions (system_event_subscribe()/_unsubscribe()/ +// _await()), separate from the callback-based `subscriptions` vector above. Guarded by its own +// mutex since notifying a poll subscriber never invokes caller code (just a memcpy and an +// xTaskNotifyGive), so there is no reentrancy concern requiring a snapshot-then-unlock dance. +static SystemEventSubscription* poll_subscriptions = nullptr; +static KernelEventMutex poll_subscriptions_mutex; + extern "C" { -error_t system_event_subscribe( +error_t system_event_callback_add( SystemEventType type, system_event_callback_t callback, void* context @@ -41,7 +49,7 @@ error_t system_event_subscribe( return ERROR_NONE; } -error_t system_event_unsubscribe( +error_t system_event_callback_remove( SystemEventType type, system_event_callback_t callback ) { @@ -59,20 +67,37 @@ error_t system_event_unsubscribe( return result; } -error_t system_event_emit( - enum SystemEventType type, +// Copies `data` into every current poll subscriber of `type` and wakes its waiting task. +// Held entirely under the lock: unlike the callback path, this never invokes caller code +// (just a memcpy and an xTaskNotifyGive), so there is nothing that could reenter and deadlock. +static void notify_poll_subscribers( + SystemEventType type, + uint64_t timestamp, const void* data, size_t data_len ) { - SystemEvent event = { - .type = type, - .timestamp = get_micros_since_boot(), - .data = data, - .data_len = data_len, - }; + mutex_lock(&poll_subscriptions_mutex.handle); + + for (SystemEventSubscription* sub = poll_subscriptions; sub != nullptr; sub = sub->next) { + if (sub->type == type) { + sub->timestamp = timestamp; + if (data_len > 0) { + std::memcpy(sub->data, data, std::min(data_len, static_cast(SYSTEM_EVENT_MAX_DATA_SIZE))); + } + sub->data_len = data_len; + sub->sequence++; + xTaskNotifyGive(sub->task); + } + } + + mutex_unlock(&poll_subscriptions_mutex.handle); +} +static error_t notify_listeners( + SystemEvent& event +) { // Snapshot matching subscriptions under the lock, then invoke after unlocking: a - // callback calling system_event_subscribe(), system_event_unsubscribe() or + // callback calling system_event_callback_add(), system_event_callback_remove() or // system_event_emit() would otherwise deadlock against this same (non-recursive) // mutex, and a slow callback would block every other thread's subscribe/unsubscribe // for the duration of this emit. @@ -86,7 +111,7 @@ error_t system_event_emit( size_t match_count = 0; for (const auto& subscription : subscriptions) { - if (subscription.type == type) { + if (subscription.type == event.type) { match_count++; } } @@ -101,7 +126,7 @@ error_t system_event_emit( size_t matched_count = 0; for (const auto& subscription : subscriptions) { - if (subscription.type == type) { + if (subscription.type == event.type) { matching[matched_count++] = subscription; } } @@ -117,4 +142,66 @@ error_t system_event_emit( return ERROR_NONE; } +error_t system_event_emit( + SystemEventType type, + const void* data, + size_t data_len +) { + SystemEvent event = { + .type = type, + .timestamp = get_micros_since_boot(), + .data = data, + .data_len = data_len, + }; + + notify_poll_subscribers(type, event.timestamp, data, data_len); + auto error = notify_listeners(event); + if (error != ERROR_NONE) { return error; } + + return ERROR_NONE; +} + +error_t system_event_subscribe(SystemEventSubscription* sub) { + sub->task = xTaskGetCurrentTaskHandle(); + sub->sequence = 0; + sub->consumed_sequence = 0; + sub->data_len = 0; + + mutex_lock(&poll_subscriptions_mutex.handle); + sub->next = poll_subscriptions; + poll_subscriptions = sub; + mutex_unlock(&poll_subscriptions_mutex.handle); + + return ERROR_NONE; +} + +error_t system_event_unsubscribe(SystemEventSubscription* sub) { + error_t result = ERROR_NOT_FOUND; + + mutex_lock(&poll_subscriptions_mutex.handle); + for (SystemEventSubscription** link = &poll_subscriptions; *link != nullptr; link = &(*link)->next) { + if (*link == sub) { + *link = sub->next; + result = ERROR_NONE; + break; + } + } + mutex_unlock(&poll_subscriptions_mutex.handle); + + return result; +} + +error_t system_event_await(SystemEventSubscription* sub, TickType_t timeout) { + uint32_t old_sequence = sub->sequence; + + while (sub->sequence == old_sequence) { + if (ulTaskNotifyTake(pdTRUE, timeout) == 0) { + return ERROR_TIMEOUT; + } + } + + sub->consumed_sequence = sub->sequence; + return ERROR_NONE; +} + } // extern "C" diff --git a/Tests/TactilityKernel/Source/SystemEventTest.cpp b/Tests/TactilityKernel/Source/SystemEventTest.cpp index c19f8759b..0ea1b7157 100644 --- a/Tests/TactilityKernel/Source/SystemEventTest.cpp +++ b/Tests/TactilityKernel/Source/SystemEventTest.cpp @@ -1,13 +1,15 @@ #include "doctest.h" +#include +#include #include #include #include // system_event_emit() snapshots matching subscriptions under the lock, then invokes them -// after unlocking (see the @warning on system_event_subscribe() in system_event.h), so a -// callback calling system_event_subscribe()/_unsubscribe()/_emit() must not deadlock - +// after unlocking (see the @warning on system_event_callback_add() in system_event.h), so a +// callback calling system_event_callback_add()/_unsubscribe()/_emit() must not deadlock - // covered below, mirroring DeviceListenerTest.cpp's reentrancy test. struct RecordedCall { @@ -39,8 +41,8 @@ TEST_CASE("system_event_emit invokes every subscriber registered for that type") int context_a = 1; int context_b = 2; - CHECK_EQ(system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a), ERROR_NONE); - CHECK_EQ(system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b), ERROR_NONE); + CHECK_EQ(system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a), ERROR_NONE); + CHECK_EQ(system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b), ERROR_NONE); CHECK_EQ(system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0), ERROR_NONE); @@ -51,14 +53,14 @@ TEST_CASE("system_event_emit invokes every subscriber registered for that type") REQUIRE_EQ(calls_b.size(), 1); CHECK_EQ(calls_b[0].context, &context_b); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b); } TEST_CASE("system_event_emit only invokes subscribers registered for the emitted type") { reset_calls(); int context_a = 1; - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); CHECK_EQ(calls_a.size(), 0); @@ -66,7 +68,7 @@ TEST_CASE("system_event_emit only invokes subscribers registered for the emitted system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); CHECK_EQ(calls_a.size(), 1); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a); } TEST_CASE("system_event_emit passes the data pointer and length through unchanged") { @@ -74,7 +76,7 @@ TEST_CASE("system_event_emit passes the data pointer and length through unchange int context_a = 1; struct Payload { int value; } payload { 42 }; - system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a); + system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a); system_event_emit(KERNEL_EVENT_TIME_CHANGED, &payload, sizeof(payload)); REQUIRE_EQ(calls_a.size(), 1); @@ -82,13 +84,13 @@ TEST_CASE("system_event_emit passes the data pointer and length through unchange CHECK_EQ(calls_a[0].data_len, sizeof(payload)); CHECK_EQ(static_cast(calls_a[0].data)->value, 42); - system_event_unsubscribe(KERNEL_EVENT_TIME_CHANGED, listener_a); + system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_a); } TEST_CASE("system_event_emit with no data passes a null pointer and zero length") { reset_calls(); int context_a = 1; - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); @@ -96,50 +98,50 @@ TEST_CASE("system_event_emit with no data passes a null pointer and zero length" CHECK_EQ(calls_a[0].data, nullptr); CHECK_EQ(calls_a[0].data_len, 0); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a); } -TEST_CASE("system_event_unsubscribe stops further notifications for that callback only") { +TEST_CASE("system_event_callback_remove stops further notifications for that callback only") { reset_calls(); int context_a = 1; int context_b = 2; - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b); - CHECK_EQ(system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NONE); + CHECK_EQ(system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NONE); system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); CHECK_EQ(calls_a.size(), 0); CHECK_EQ(calls_b.size(), 1); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b); } -TEST_CASE("system_event_unsubscribe on an unregistered callback returns ERROR_NOT_FOUND and is a no-op") { +TEST_CASE("system_event_callback_remove on an unregistered callback returns ERROR_NOT_FOUND and is a no-op") { reset_calls(); int context_b = 2; - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, &context_b); // listener_a was never added for this type, so removing it must not disturb listener_b. - CHECK_EQ(system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NOT_FOUND); + CHECK_EQ(system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a), ERROR_NOT_FOUND); system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); CHECK_EQ(calls_b.size(), 1); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b); } -TEST_CASE("system_event_unsubscribe matches on (type, callback), not the callback alone") { +TEST_CASE("system_event_callback_remove matches on (type, callback), not the callback alone") { reset_calls(); int context_a = 1; // Same callback subscribed for two different event types. - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); - system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); + system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_a, &context_a); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a); system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); CHECK_EQ(calls_a.size(), 0); @@ -147,7 +149,7 @@ TEST_CASE("system_event_unsubscribe matches on (type, callback), not the callbac system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); CHECK_EQ(calls_a.size(), 1); - system_event_unsubscribe(KERNEL_EVENT_TIME_CHANGED, listener_a); + system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_a); } TEST_CASE("system_event_emit with no subscribers for that type returns ERROR_NONE") { @@ -157,7 +159,7 @@ TEST_CASE("system_event_emit with no subscribers for that type returns ERROR_NON TEST_CASE("system_event_emit stamps the event with the current boot-relative time") { reset_calls(); int context_a = 1; - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); auto before = static_cast(get_micros_since_boot()); system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); @@ -167,7 +169,7 @@ TEST_CASE("system_event_emit stamps the event with the current boot-relative tim CHECK_GE(calls_a[0].timestamp, before); CHECK_LE(calls_a[0].timestamp, after); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_a); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a); } static bool reentrant_add_triggered = false; @@ -179,10 +181,10 @@ static void reentrant_listener(SystemEvent* event, void* context) { // Subscribing from within a notification must not deadlock: emit() releases the // lock before invoking callbacks, so this only blocks briefly on the (already // unlocked) mutex. - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b, context); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_b, context); // Also exercise unsubscribe() and a nested emit() of a different type from within // a callback - all must complete without deadlocking. - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener); system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); } } @@ -192,8 +194,8 @@ TEST_CASE("system_event_emit is safe when a callback subscribes, unsubscribes an reentrant_add_triggered = false; int context_a = 1; - system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener, &context_a); - system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, listener_b, &context_a); + system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, reentrant_listener, &context_a); + system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, listener_b, &context_a); system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); @@ -210,6 +212,68 @@ TEST_CASE("system_event_emit is safe when a callback subscribes, unsubscribes an CHECK_EQ(calls_a.size(), 1); CHECK_EQ(calls_b.size(), 2); - system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, listener_b); - system_event_unsubscribe(KERNEL_EVENT_TIME_CHANGED, listener_b); + system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_b); + system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_b); +} + +// gps.h-style poll subscription: system_event_subscribe()/_await()/_unsubscribe(). +// +// system_event_await() only detects sequence increments that happen *after* it starts +// waiting (same as gps_api_event_await()), so the emit must be started from another task +// while this one is already blocked in await() - emitting first and awaiting after would +// race the notification the same way it would with any FreeRTOS task-notify consumer. + +TEST_CASE("system_event_subscribe/_await deliver the event payload by value") { + SystemEventSubscription sub {}; + sub.type = KERNEL_EVENT_NETWORK_CONNECTED; + CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE); + + NetworkConnectedEvent connected { .device = nullptr, .ipv4_addr = 0x0A000001, .gateway = 0x0A0000FE }; + auto* thread = thread_alloc_full( + "system-event-emitter", + 4096, + [](void* context) { + delay_millis(20); + auto* connected_ptr = static_cast(context); + system_event_emit(KERNEL_EVENT_NETWORK_CONNECTED, connected_ptr, sizeof(*connected_ptr)); + return 0; + }, + &connected, + -1 + ); + CHECK_EQ(thread_start(thread), ERROR_NONE); + + CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); + + const auto* received = reinterpret_cast(sub.data); + CHECK_EQ(received->ipv4_addr, connected.ipv4_addr); + CHECK_EQ(received->gateway, connected.gateway); + CHECK_EQ(sub.data_len, sizeof(connected)); + + CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE); + thread_free(thread); + + CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE); + CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NOT_FOUND); +} + +TEST_CASE("system_event_await times out when no matching event has arrived") { + SystemEventSubscription sub {}; + sub.type = KERNEL_EVENT_TIME_CHANGED; + system_event_subscribe(&sub); + + CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT); + + system_event_unsubscribe(&sub); +} + +TEST_CASE("system_event_emit does not notify a poll subscriber of a different type") { + SystemEventSubscription sub {}; + sub.type = KERNEL_EVENT_BOOT_COMPLETED; + system_event_subscribe(&sub); + + system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); + CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT); + + system_event_unsubscribe(&sub); } From f61a30726fb27fe136fda9afa73011af01a31da8 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 6 Aug 2026 23:54:23 +0200 Subject: [PATCH 02/31] Add app loading and window manager modules --- Modules/app-esp32-module/CMakeLists.txt | 11 + Modules/app-esp32-module/devicetree.yaml | 4 + .../include/app_esp32/module.h | 12 + .../source/app_esp32_loader_service.cpp | 141 +++++++ Modules/app-esp32-module/source/module.cpp | 30 ++ Modules/app-module/CMakeLists.txt | 12 + Modules/app-module/devicetree.yaml | 3 + Modules/app-module/include/app/event.h | 103 +++++ Modules/app-module/include/app/install.h | 50 +++ Modules/app-module/include/app/instance.h | 19 + Modules/app-module/include/app/loader.h | 59 +++ Modules/app-module/include/app/location.h | 20 + Modules/app-module/include/app/manager.h | 135 ++++++ Modules/app-module/include/app/manifest.h | 41 ++ Modules/app-module/include/app/metadata.h | 59 +++ Modules/app-module/include/app/module.h | 12 + .../private/app/private/app_ledger.h | 54 +++ .../private/app_metadata_parsing_internal.h | 30 ++ .../private/app/private/app_scheduler.h | 43 ++ Modules/app-module/source/app_install.cpp | 386 ++++++++++++++++++ .../app-module/source/app_internal_loader.cpp | 48 +++ .../source/app_metadata_parsing.cpp | 157 +++++++ .../source/app_metadata_parsing_v1.cpp | 95 +++++ .../source/app_metadata_parsing_v2.cpp | 95 +++++ Modules/app-module/source/app_scheduler.cpp | 190 +++++++++ Modules/app-module/source/event.cpp | 116 ++++++ Modules/app-module/source/manager.cpp | 199 +++++++++ Modules/app-module/source/module.cpp | 30 ++ Modules/lvgl-window-manager/CMakeLists.txt | 11 + Modules/lvgl-window-manager/devicetree.yaml | 2 + .../include/lvgl_window_manager/module.h | 12 + .../lvgl_window_manager/window_manager.h | 110 +++++ Modules/lvgl-window-manager/source/module.cpp | 19 + .../source/window_manager.cpp | 279 +++++++++++++ 34 files changed, 2587 insertions(+) create mode 100644 Modules/app-esp32-module/CMakeLists.txt create mode 100644 Modules/app-esp32-module/devicetree.yaml create mode 100644 Modules/app-esp32-module/include/app_esp32/module.h create mode 100644 Modules/app-esp32-module/source/app_esp32_loader_service.cpp create mode 100644 Modules/app-esp32-module/source/module.cpp create mode 100644 Modules/app-module/CMakeLists.txt create mode 100644 Modules/app-module/devicetree.yaml create mode 100644 Modules/app-module/include/app/event.h create mode 100644 Modules/app-module/include/app/install.h create mode 100644 Modules/app-module/include/app/instance.h create mode 100644 Modules/app-module/include/app/loader.h create mode 100644 Modules/app-module/include/app/location.h create mode 100644 Modules/app-module/include/app/manager.h create mode 100644 Modules/app-module/include/app/manifest.h create mode 100644 Modules/app-module/include/app/metadata.h create mode 100644 Modules/app-module/include/app/module.h create mode 100644 Modules/app-module/private/app/private/app_ledger.h create mode 100644 Modules/app-module/private/app/private/app_metadata_parsing_internal.h create mode 100644 Modules/app-module/private/app/private/app_scheduler.h create mode 100644 Modules/app-module/source/app_install.cpp create mode 100644 Modules/app-module/source/app_internal_loader.cpp create mode 100644 Modules/app-module/source/app_metadata_parsing.cpp create mode 100644 Modules/app-module/source/app_metadata_parsing_v1.cpp create mode 100644 Modules/app-module/source/app_metadata_parsing_v2.cpp create mode 100644 Modules/app-module/source/app_scheduler.cpp create mode 100644 Modules/app-module/source/event.cpp create mode 100644 Modules/app-module/source/manager.cpp create mode 100644 Modules/app-module/source/module.cpp create mode 100644 Modules/lvgl-window-manager/CMakeLists.txt create mode 100644 Modules/lvgl-window-manager/devicetree.yaml create mode 100644 Modules/lvgl-window-manager/include/lvgl_window_manager/module.h create mode 100644 Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h create mode 100644 Modules/lvgl-window-manager/source/module.cpp create mode 100644 Modules/lvgl-window-manager/source/window_manager.cpp diff --git a/Modules/app-esp32-module/CMakeLists.txt b/Modules/app-esp32-module/CMakeLists.txt new file mode 100644 index 000000000..f1ff7b929 --- /dev/null +++ b/Modules/app-esp32-module/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(app-esp32-module + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + REQUIRES TactilityKernel app-module service-module elf_loader +) diff --git a/Modules/app-esp32-module/devicetree.yaml b/Modules/app-esp32-module/devicetree.yaml new file mode 100644 index 000000000..4082c5863 --- /dev/null +++ b/Modules/app-esp32-module/devicetree.yaml @@ -0,0 +1,4 @@ +dependencies: + - TactilityKernel + - Modules/app-module + - Modules/service-module diff --git a/Modules/app-esp32-module/include/app_esp32/module.h b/Modules/app-esp32-module/include/app_esp32/module.h new file mode 100644 index 000000000..bc0b0443b --- /dev/null +++ b/Modules/app-esp32-module/include/app_esp32/module.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module app_esp32_module; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp new file mode 100644 index 000000000..ec3e6e1cd --- /dev/null +++ b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "../../../TactilityKernel/include/tactility/error.h" +#include "../../../TactilityKernel/include/tactility/filesystem/file_mutex.h" +#include "../../app-module/include/app/loader.h" +#include "../../app-module/include/app/location.h" + + +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace { + +/** load()-allocated state, passed back through run()/unload(). */ +struct Esp32AppRuntime { + esp_elf_t elf {}; + uint8_t* file_data = nullptr; +}; + +error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) { + FileMutex mutex; + file_mutex_get(&mutex, path); + file_mutex_lock(&mutex); + + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + file_mutex_unlock(&mutex); + return ERROR_NOT_FOUND; + } + + fseek(file, 0, SEEK_END); + long size = ftell(file); + fseek(file, 0, SEEK_SET); + if (size <= 0) { + fclose(file); + file_mutex_unlock(&mutex); + return ERROR_RESOURCE; + } + + auto* data = static_cast(malloc(static_cast(size))); + if (data == nullptr) { + fclose(file); + file_mutex_unlock(&mutex); + return ERROR_OUT_OF_MEMORY; + } + + size_t read = fread(data, 1, static_cast(size), file); + fclose(file); + file_mutex_unlock(&mutex); + + if (read != static_cast(size)) { + free(data); + return ERROR_RESOURCE; + } + + *out_data = data; + *out_size = static_cast(size); + return ERROR_NONE; +} + +error_t api_load(AppLocation location, AppRuntime* out_runtime) { + auto* runtime = new (std::nothrow) Esp32AppRuntime(); + if (runtime == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + + if (location.type != APP_LOCATION_PATH) { + return ERROR_NOT_SUPPORTED; + } + + size_t size = 0; + error_t read_result = read_file(static_cast(location.location), &runtime->file_data, &size); + if (read_result != ERROR_NONE) { + delete runtime; + return read_result; + } + + if (esp_elf_init(&runtime->elf) != ESP_OK) { + free(runtime->file_data); + delete runtime; + return ERROR_RESOURCE; + } + + if (esp_elf_relocate(&runtime->elf, runtime->file_data) != 0) { + esp_elf_deinit(&runtime->elf); + free(runtime->file_data); + delete runtime; + return ERROR_RESOURCE; + } + + *out_runtime = runtime; + return ERROR_NONE; +} + +int32_t api_run(AppRuntime runtime_ptr, uint32_t /*app_instance_id*/, int argc, char* argv[]) { + auto* runtime = static_cast(runtime_ptr); + // A side-loaded ELF's own main() only ever gets a real argc/argv from esp_elf_request()'s + // fixed signature - there's no slot for app_instance_id there, and side-loaded apps don't + // need one yet. + return esp_elf_request(&runtime->elf, 0, argc, argv); +} + +void api_unload(AppRuntime runtime_ptr) { + auto* runtime = static_cast(runtime_ptr); + esp_elf_deinit(&runtime->elf); + free(runtime->file_data); + delete runtime; +} + +AppLoaderApi loader_api = { + .load = api_load, + .run = api_run, + .unload = api_unload, +}; + +void* create_service(const ServiceManifest*) { + return &loader_api; +} + +void destroy_service(const ServiceManifest*, void*) { +} + +} // namespace + +extern ServiceManifest loader_service_manifest = { + .id = APP_LOADER_PATH_SERVICE_ID, + .create_service = create_service, + .destroy_service = destroy_service, + .on_start = nullptr, + .on_stop = nullptr, +}; diff --git a/Modules/app-esp32-module/source/module.cpp b/Modules/app-esp32-module/source/module.cpp new file mode 100644 index 000000000..cfd0e0160 --- /dev/null +++ b/Modules/app-esp32-module/source/module.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include + +extern "C" { + +extern ServiceManifest loader_service_manifest; + +static error_t start() { + return service_manager_add(&loader_service_manifest, /*auto_start=*/true); +} + +static error_t stop() { + return service_manager_remove(loader_service_manifest.id); +} + +Module app_esp32_module = { + .name = "app-esp32", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Modules/app-module/CMakeLists.txt b/Modules/app-module/CMakeLists.txt new file mode 100644 index 000000000..916ed7039 --- /dev/null +++ b/Modules/app-module/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(app-module + SRCS ${SOURCE_FILES} + PRIV_INCLUDE_DIRS private/ + INCLUDE_DIRS include/ + REQUIRES TactilityKernel service-module minitar +) diff --git a/Modules/app-module/devicetree.yaml b/Modules/app-module/devicetree.yaml new file mode 100644 index 000000000..0bd5002d1 --- /dev/null +++ b/Modules/app-module/devicetree.yaml @@ -0,0 +1,3 @@ +dependencies: + - TactilityKernel + - Modules/service-module diff --git a/Modules/app-module/include/app/event.h b/Modules/app-module/include/app/event.h new file mode 100644 index 000000000..090d0ffc3 --- /dev/null +++ b/Modules/app-module/include/app/event.h @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Identifies the kind of app-lifecycle event delivered through app_event_await(). */ +enum AppEventType { + APP_EVENT_RESULT, // struct AppResultEventData + APP_EVENT_CLOSE, // no data - terminate now, permanently +}; + +/** Data for APP_EVENT_RESULT. */ +struct AppResultEventData { + uint32_t launch_id; + /** The child app instance's own AppMainFn/AppLoaderApi::run() return value. By convention: + * 0 = Ok, 1 = Cancelled, 2 = Error. Apps that need to hand back more than this (e.g. picked + * text, a path) expose their own "get last result" getter instead - see e.g. + * tt::app::inputdialog::getLastText(). */ + int32_t result; +}; + +struct AppEvent { + enum AppEventType type; + /** Stamped by app_event_emit(); any value passed in by the caller is ignored. */ + uint64_t timestamp; + /** Valid only when type == APP_EVENT_RESULT. */ + struct AppResultEventData result; +}; + +/** + * Number of events that can be queued per subscription before app_event_emit() starts + * returning ERROR_RESOURCE (dropping the newest event, preserving FIFO order of what's + * already queued). Deliberately generous: app-module's scheduler is the only emitter and it + * serializes app-lifecycle transitions, so a given app can't realistically receive events + * faster than the scheduler produces them one at a time. + */ +#define APP_EVENT_QUEUE_CAPACITY 4 + +/** + * Caller-owned subscription node. Unlike TactilityKernel's system_event poll subscription + * (which coalesces to the latest value), this queues events by value (FIFO) since dropping an + * APP_EVENT_RESULT would be unacceptable. + * @warning Fields other than `app_instance_id` are for internal use only; do not read or write + * them directly. + */ +struct AppEventSubscription { + /** The app instance this subscription receives events for; set by the caller before app_event_subscribe(). */ + uint32_t app_instance_id; + + TaskHandle_t task; + + struct AppEvent queue[APP_EVENT_QUEUE_CAPACITY]; + uint8_t head; + uint8_t count; + + struct AppEventSubscription* next; +}; + +/** + * Register a subscription for events addressed to @a sub->app_instance_id. + * @warning Does not work in ISR context. + * @param[in,out] sub subscription to register; caller sets @a sub->app_instance_id beforehand, + * owns the storage, and must keep it alive (and stationary) until unsubscribed + * @return ERROR_NONE on success + */ +error_t app_event_subscribe(struct AppEventSubscription* sub); + +/** + * Remove a previously registered subscription. + * @warning Does not work in ISR context. + * @return ERROR_NONE on success, ERROR_NOT_FOUND if no matching subscription exists + */ +error_t app_event_unsubscribe(struct AppEventSubscription* sub); + +/** + * Deliver @a event to every subscription registered for @a app_instance_id (normally exactly one). + * @warning Does not work in ISR context. + * @retval ERROR_NONE delivered to at least one subscription + * @retval ERROR_NOT_FOUND no subscription is registered for @a app_instance_id + * @retval ERROR_RESOURCE at least one matching subscription's queue was full; the event was + * dropped for that subscription (still delivered to any other matching subscription) + */ +error_t app_event_emit(uint32_t app_instance_id, const struct AppEvent* event); + +/** + * Pop the next event for @a sub, blocking up to @a timeout if the queue is currently empty. + * @retval ERROR_NONE @a out_event was filled + * @retval ERROR_TIMEOUT no event arrived before the timeout elapsed + */ +error_t app_event_await(struct AppEventSubscription* sub, struct AppEvent* out_event, TickType_t timeout); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/install.h b/Modules/app-module/include/app/install.h new file mode 100644 index 000000000..d6819f072 --- /dev/null +++ b/Modules/app-module/include/app/install.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Computes the install directory for @a app_id (does not check whether anything is actually + * installed there). + * @param[out] path always NULL-terminated on return, even on failure (empty string if + * @a path_size == 0 - nothing is written in that case; otherwise at least "" is written) + * @retval ERROR_NONE on success + * @retval ERROR_BUFFER_OVERFLOW @a path_size is too small to hold the path (including the + * NULL terminator) + * @retval ERROR_NOT_FOUND the app install location isn't available (e.g. no SD card) + */ +error_t app_get_install_path(const char* app_id, char* path, size_t path_size); + +/** + * Installs an app from a tarball at @a source_path: extracts it into the app install directory, + * parses the extracted manifest.properties (see app/metadata.h) to determine its id, then + * registers it with app_manager_add() as an AppLocation{APP_LOCATION_PATH, } app. + * If an app with the same id is already installed (via a previous app_install() call), it is + * uninstalled first - stopped if running, its old install directory removed - before the new + * one takes its place. + * @param[in] source_path path to a tar file containing the app (must have manifest.properties + * at its root) + * @retval ERROR_NONE on success + * @retval ERROR_NOT_FOUND @a source_path doesn't exist / can't be read + * @retval ERROR_INVALID_ARGUMENT the tarball has no valid manifest.properties at its root + */ +error_t app_install(const char* source_path); + +/** + * Uninstalls a previously app_install()-ed app: stops it if currently running, deletes its + * install directory, and unregisters it (app_manager_remove()). + * @param[in] app_id the id the app was installed under (AppMetadata::app_id) + * @retval ERROR_NONE on success + * @retval ERROR_NOT_FOUND no such app was installed via app_install() + */ +error_t app_uninstall(const char* app_id); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/instance.h b/Modules/app-module/include/app/instance.h new file mode 100644 index 000000000..abd95f346 --- /dev/null +++ b/Modules/app-module/include/app/instance.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +/** Lifecycle state of a running (or previously running) app instance. Every app instance owns + * its own task for its entire lifetime - there is no "saved, task given up" state. */ +typedef enum { + APP_INSTANCE_STATE_STARTING, + APP_INSTANCE_STATE_ACTIVE, + APP_INSTANCE_STATE_STOPPING, + APP_INSTANCE_STATE_STOPPED, +} AppInstanceState; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/loader.h b/Modules/app-module/include/app/loader.h new file mode 100644 index 000000000..1ca8d0336 --- /dev/null +++ b/Modules/app-module/include/app/loader.h @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include "location.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** service-module id the AppLoaderApi implementation for AppManifest::location.type == + * APP_LOCATION_MEMORY must register under. Implemented by app-module itself (source/app_internal_loader.cpp). */ +#define APP_LOADER_MEMORY_SERVICE_ID "app-loader-memory" + +/** service-module id the AppLoaderApi implementation for AppManifest::location.type == + * APP_LOCATION_PATH must register under. Implemented by a platform module (e.g. app-esp32-module). */ +#define APP_LOADER_PATH_SERVICE_ID "app-loader-path" + +/** + * Entry point signature for an APP_LOCATION_MEMORY app: a function linked directly into this + * firmware binary. Called on the dedicated task app-module's scheduler spawns for this instance, + * blocking for the app's whole lifetime - same contract as an external app's main(), plus + * @a app_instance_id identifying this running instance (use it with + * app_event_subscribe()/window_manager_create()/app_manager_finish()/etc.). + * AppManifest::location.location holds this cast to void*. + */ +typedef int32_t (*AppMainFn)(uint32_t app_instance_id, int argc, char* argv[]); + +typedef void* AppRuntime; + +/** + * Pluggable mechanism for loading and executing an app. + */ +struct AppLoaderApi { + /** + * Prepares an app instance for execution (e.g. read + relocate its binary). + * @param[in] location the location to load the elf from + * @param[out] out_runtime opaque handle to whatever load() allocated; passed back to run()/unload() + */ + error_t (*load)(struct AppLocation location, AppRuntime* out_runtime); + + /** + * Blocking: runs the app to completion. + * @param[in] runtime handle produced by load() + * @param[in] app_instance_id the running instance's id + * @param[in] argc the amount of arguments in @a argv + * @param[in] argv the array of string pointers (can be NULL) + */ + int32_t (*run)(AppRuntime runtime, uint32_t app_instance_id, int argc, char* argv[]); + + /** Releases whatever load() allocated. Called after run() returns. */ + void (*unload)(AppRuntime runtime); +}; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/location.h b/Modules/app-module/include/app/location.h new file mode 100644 index 000000000..721c07772 --- /dev/null +++ b/Modules/app-module/include/app/location.h @@ -0,0 +1,20 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +enum AppLocationType { + APP_LOCATION_MEMORY, + APP_LOCATION_PATH, +}; + +struct AppLocation { + enum AppLocationType type; + /** Meaning depends on `type`; see AppLocationType. */ + void* location; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/manager.h b/Modules/app-module/include/app/manager.h new file mode 100644 index 000000000..3a18ad62c --- /dev/null +++ b/Modules/app-module/include/app/manager.h @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Identifies a running (or previously running) app instance. 0 is never a valid instance id. */ +typedef uint32_t AppInstanceId; + +/** + * Register an app manifest. + * @retval ERROR_INVALID_ARGUMENT a manifest with the same id is already registered + * @retval ERROR_NONE on success + */ +error_t app_manager_add(const struct AppManifest* manifest); + +/** + * Unregister a previously-added manifest. + * @retval ERROR_NOT_FOUND no manifest with this id is registered + * @retval ERROR_NONE on success + */ +error_t app_manager_remove(const char* id); + +/** @return the manifest, or NULL if not found. */ +const struct AppManifest* app_manager_find_manifest(const char* id); + +/** + * Calls @a visitor once for every registered manifest (e.g. for AppList/Settings to enumerate + * apps to show). Iteration order is unspecified. Safe to call app_manager_add()/_remove() from + * within @a visitor is NOT guaranteed - do not mutate the registry from inside the callback. + */ +typedef void (*AppManifestVisitorFn)(const struct AppManifest* manifest, void* context); +void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context); + +/** + * Starts a new instance of the app registered under @a id. Every app instance gets its own + * dedicated task for its entire lifetime - starting an app never asks any other app to give up + * its task, and multiple instances (of the same or different apps) can be Active at once. + * @param[in] id the manifest id to start + * @param[out] out_app_instance_id the id of the new app instance + * @retval ERROR_NOT_FOUND no manifest with this id is registered, or no AppLoaderApi is registered + * @retval ERROR_NONE on success + */ +error_t app_manager_start(const char* id, AppInstanceId* out_app_instance_id); + +/** + * Same as app_manager_start(), but also passes @a argc/@a argv to the new instance's own main + * function (see app/loader.h's AppMainFn) - modelled on a C program's main(argc, argv). For + * regular (non-modal) navigations that need to pass data to the target app (e.g. "show details + * for this app id") without expecting a result back. + * @param[in] argv @a argc strings; app-module makes its own deep copy before returning, so + * @a argv and the strings it points to may be freed/go out of scope immediately after this call + * returns (e.g. safe to pass a stack-local array of a caller's own std::string::c_str()s). + */ +error_t app_manager_start_with_parameters(const char* id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id); + +/** + * Starts @a id as a modal child of @a parent_instance_id, for the purpose of receiving a + * result. The parent keeps running (window_manager's own multi-window stack handles burying its + * window while the child is shown). + * + * When the child's task exits, an APP_EVENT_RESULT is delivered to @a parent_instance_id - + * result is whatever the child's AppMainFn/AppLoaderApi::run() returned - unless + * @a parent_instance_id is 0, in which case no result is delivered (fire-and-forget, for + * callers with no app_instance_id of their own). The parent is then responsible for calling + * app_manager_stop() on the child's instance id to fully reap it. Children that need to hand + * back more than an int32_t (e.g. picked text, a path) expose their own "get last result" + * getter for the parent to call after receiving the event - see e.g. + * tt::app::inputdialog::getLastText(). + * @param[in] argv @a argc strings; app-module makes its own deep copy before returning (same as + * app_manager_start_with_parameters()), so @a argv and the strings it points to may be + * freed/go out of scope immediately after this call returns. + * @retval ERROR_NOT_FOUND no manifest with this id is registered, or no AppLoaderApi is registered + * @retval ERROR_NONE on success + */ +error_t app_manager_start_for_result(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id); + +/** + * Stop an app instance permanently. Emits APP_EVENT_CLOSE and bound-waits for its task to exit + * if it was running. + * @warning Must not be called from the instance's own task (it bound-waits via thread_join(), + * which asserts against joining yourself) - an app closing itself must call app_manager_finish() + * instead, right before returning from its own AppMainFn/AppLoaderApi::run(). + */ +error_t app_manager_stop(AppInstanceId app_instance_id); + +/** + * Called by an app instance, from its own task, right before it returns in response to + * APP_EVENT_CLOSE - whether that close was self-initiated (e.g. its own back button) or came + * from someone else. Marks this instance Stopped immediately (rather than waiting for its task + * to actually exit) so app_manager_get_state()/app_manager_get_topmost_instance_id() reflect the + * closure as soon as the app has decided to close, not just once its task has fully unwound. + * @warning Does not join or free this instance's own task/ledger entry (can't - this runs on + * that very task); those are cleaned up on a later app_manager_stop() call, same as any + * self-terminating instance. + */ +error_t app_manager_finish(AppInstanceId app_instance_id); + +/** @return the instance's current state, or APP_INSTANCE_STATE_STOPPED if the id is unknown. */ +AppInstanceState app_manager_get_state(AppInstanceId app_instance_id); + +/** + * @param[out] out_app_instance_id set to the instance id of the topmost currently-Active app - + * the most recently started of whichever instances are Active (a modal child launched via + * app_manager_start_for_result() stays Active alongside its parent while shown, so this + * correctly picks the child, not the parent, while a dialog is up). + * @retval ERROR_NOT_FOUND no app is Active + * @retval ERROR_NONE on success + */ +error_t app_manager_get_topmost_instance_id(AppInstanceId* out_app_instance_id); + +/** + * Same as app_manager_get_topmost_instance_id(), but resolves straight to the topmost app's + * manifest id string. + * @param[out] buffer always NULL-terminated on return, even on failure (empty string if + * @a buffer_size == 0 - nothing is written in that case; otherwise at least "" is written) + * @retval ERROR_NOT_FOUND no app is Active + * @retval ERROR_BUFFER_OVERFLOW @a buffer_size is too small to hold the id (including the NULL + * terminator) + * @retval ERROR_NONE on success + */ +error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/manifest.h b/Modules/app-module/include/app/manifest.h new file mode 100644 index 000000000..ec393d74a --- /dev/null +++ b/Modules/app-module/include/app/manifest.h @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "location.h" + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Broad classification of an app, used for grouping/launcher presentation. */ +enum AppCategory { + APP_CATEGORY_SYSTEM, + APP_CATEGORY_SETTINGS, + APP_CATEGORY_USER, +}; + +/** Bit flags for AppManifest::flags. */ +enum AppManifestFlags { + /** Excluded from generic app-browsing UIs (AppList, Settings) - for apps only ever reached + * by direct navigation (modal dialogs, detail views that require parameters, wizard/ + * bootstrap steps). */ + APP_MANIFEST_FLAG_HIDDEN = 0b00000001, +}; + +/** Describes a registrable app. One manifest exists per app id. */ +struct AppManifest { + /** Unique app identifier. Should never be NULL. */ + const char* id; + /** Human-readable name. Should never be NULL. */ + const char* name; + enum AppCategory category; + struct AppLocation location; + /** Bitmask of AppManifestFlags. Most apps should leave this 0. */ + uint8_t flags; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/metadata.h b/Modules/app-module/include/app/metadata.h new file mode 100644 index 000000000..1f02ac6ea --- /dev/null +++ b/Modules/app-module/include/app/metadata.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define APP_METADATA_TARGET_SDK_LENGTH 16 +#define APP_METADATA_APP_ID_LENGTH 32 +#define APP_METADATA_APP_NAME_LENGTH 32 +#define APP_METADATA_APP_VERSION_NAME_LENGTH 16 + +struct AppMetadata { + + /** + * The SDK version that was used to compile this app. (e.g. "0.6.0") + * Must be NULL-terminated. + */ + char target_sdk[APP_METADATA_TARGET_SDK_LENGTH + 1]; + + /** + * The identifier by which the app is launched by the system and other apps. + * Must be NULL-terminated. + */ + char app_id[APP_METADATA_APP_ID_LENGTH + 1]; + + /** + * The user-readable name of the app. Used in UI. + * Must be NULL-terminated. + */ + char app_name[APP_METADATA_APP_NAME_LENGTH + 1]; + + /** + * The version as it is displayed to the user (e.g. "1.2.0") + * Must be NULL-terminated. + */ + char app_version_name[APP_METADATA_APP_VERSION_NAME_LENGTH + 1]; + + /** The technical version (must be incremented with new releases of the app */ + uint64_t app_version_code = 0; +}; + +/** + * Parses a manifest.properties file at @a path into @a out_metadata, auto-detecting the V1 + * (sectioned, e.g. "[app]id=...") or V2 (flat dot-notation, e.g. "app.id=...") format from its + * first line. + * @retval ERROR_NONE on success + * @retval ERROR_NOT_FOUND the file doesn't exist / couldn't be opened + * @retval ERROR_INVALID_ARGUMENT the file isn't a valid manifest, or a field's value doesn't fit + * @a out_metadata's fixed-size buffers + */ +error_t app_metadata_parse(const char* path, struct AppMetadata* out_metadata); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/module.h b/Modules/app-module/include/app/module.h new file mode 100644 index 000000000..5bfd3ee67 --- /dev/null +++ b/Modules/app-module/include/app/module.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module app_module; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/private/app/private/app_ledger.h b/Modules/app-module/private/app/private/app_ledger.h new file mode 100644 index 000000000..3699706e6 --- /dev/null +++ b/Modules/app-module/private/app/private/app_ledger.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include + +/** A registered/running app instance, as tracked internally by app-module. */ +struct AppInstanceRecord { + uint32_t id; + const AppManifest* manifest; + AppInstanceState state; + /** The kernel thread currently executing AppLoaderApi::run() for this instance; NULL when not running. */ + Thread* thread; + + /** 0 for a top-level launch (app_manager_start()). Non-zero for a modal child launched via + * app_manager_start_for_result() - the instance that receives this child's APP_EVENT_RESULT. */ + uint32_t parent_id = 0; +}; + +struct AppLedger { + std::unordered_map manifests; + std::unordered_map instances; + uint32_t next_instance_id = 1; + Mutex mutex {}; + + AppLedger() { mutex_construct(&mutex); } + ~AppLedger() { mutex_destruct(&mutex); } +}; + +inline AppLedger& app_ledger() { + static AppLedger ledger; + return ledger; +} + +/** Frees a deep-copied argv previously built by app_manager_start_with_parameters()/ + * app_manager_start_for_result() (see app_scheduler.cpp's ThreadContext::argv) - each + * individually heap-allocated string, then the array itself. Safe to call with count == 0 / + * values == nullptr (no-op). */ +inline void app_ledger_free_arguments(int count, char** values) { + if (values == nullptr) { + return; + } + for (int i = 0; i < count; i++) { + delete[] values[i]; + } + delete[] values; +} diff --git a/Modules/app-module/private/app/private/app_metadata_parsing_internal.h b/Modules/app-module/private/app/private/app_metadata_parsing_internal.h new file mode 100644 index 000000000..f611b0a50 --- /dev/null +++ b/Modules/app-module/private/app/private/app_metadata_parsing_internal.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +/** Shared helpers + per-format parsers for app_metadata_parse() (source/app_metadata_parsing.cpp) + * - split out like the old tt::app manifest parser (AppManifestParsing/V1/V2.cpp) that this is + * modelled on, one file per format plus a shared dispatcher. */ + +bool app_metadata_get_value(const std::map& properties, const std::string& key, std::string& out_value); + +bool app_metadata_is_valid_format_version(const std::string& version); +bool app_metadata_is_valid_id(const std::string& id); +bool app_metadata_is_valid_name(const std::string& name); +bool app_metadata_is_valid_version_name(const std::string& version); +bool app_metadata_is_valid_version_code(const std::string& version); + +/** Copies @a value into @a dest (a fixed-size buffer of @a dest_size bytes, including the NULL + * terminator) if it fits. + * @retval false @a value doesn't fit in @a dest_size bytes - @a dest is left untouched */ +bool app_metadata_copy_bounded(char* dest, size_t dest_size, const std::string& value); + +/** Parses a V1 (sectioned INI, e.g. "[app]versionName=...") manifest map into @a out_metadata. */ +bool app_metadata_parse_v1(const std::map& properties, struct AppMetadata& out_metadata); + +/** Parses a V2 (flat dot-notation, e.g. "app.version.name=...") manifest map into @a out_metadata. */ +bool app_metadata_parse_v2(const std::map& properties, struct AppMetadata& out_metadata); diff --git a/Modules/app-module/private/app/private/app_scheduler.h b/Modules/app-module/private/app/private/app_scheduler.h new file mode 100644 index 000000000..e7d8cd3b1 --- /dev/null +++ b/Modules/app-module/private/app/private/app_scheduler.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include + +#include + +/** + * Owns per-app task lifecycle on behalf of app_manager_*(). AppLoaderApi implementations + * stay task-agnostic; all of thread_alloc_full()/thread_start()/thread_join() happen here. + * Every app instance gets its own dedicated task for its entire lifetime - no task is ever + * reused for a different instance. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Loads and starts an app instance: spawns a dedicated task that calls + * AppLoaderApi::load()/run(), marking the instance ACTIVE for the duration of run(). + * @param[in] app_instance_id id already allocated in the ledger for this instance + * @param[in] location the location of the app + * @param[in] argc the amount of arguments to pass to the app's main function + * @param[in] argv the array of arguments to pass to the app's main function - ownership is + * taken by the scheduler regardless of outcome (freed once the spawned task's run() returns, or + * immediately on a failure to start it) + */ +error_t app_scheduler_start(uint32_t app_instance_id, struct AppLocation location, int argc, char* argv[]); + +/** + * Permanently stops an app instance (APP_EVENT_CLOSE if it was running), bound-waits for its + * task to exit, and removes it from the ledger. + */ +error_t app_scheduler_stop(uint32_t app_instance_id, TickType_t join_timeout); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/source/app_install.cpp b/Modules/app-module/source/app_install.cpp new file mode 100644 index 000000000..f3ebe74c6 --- /dev/null +++ b/Modules/app-module/source/app_install.cpp @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +constexpr auto* TAG = "app_install"; + +namespace { + +// region Filesystem helpers (app-module may not depend upward on Tactility::file - see +// app_metadata_parsing.cpp for the same constraint applied to properties-file loading) + +std::string last_path_segment(const std::string& path) { + auto index = path.find_last_of('/'); + return index == std::string::npos ? path : path.substr(index + 1); +} + +bool is_directory(const std::string& path) { + struct stat result {}; + return stat(path.c_str(), &result) == 0 && S_ISDIR(result.st_mode); +} + +bool is_file(const std::string& path) { + struct stat result {}; + return stat(path.c_str(), &result) == 0 && S_ISREG(result.st_mode); +} + +// mkdir -p. +bool ensure_directory(const std::string& path) { + if (path.empty() || is_directory(path)) { + return true; + } + + FileMutex mutex {}; + file_mutex_get(&mutex, path.c_str()); + file_mutex_lock(&mutex); + bool created = mkdir(path.c_str(), 0777) == 0 || errno == EEXIST; + file_mutex_unlock(&mutex); + if (!created) { + return false; + } + + return is_directory(path); +} + +bool ensure_directory_recursive(const std::string& path) { + for (size_t index = path.find('/', 1); index != std::string::npos; index = path.find('/', index + 1)) { + if (!ensure_directory(path.substr(0, index))) { + return false; + } + } + return ensure_directory(path); +} + +bool delete_recursively(const std::string& path) { + if (path.empty() || path == "/" || path == "." || path == "..") { + return true; + } + + if (is_directory(path)) { + DIR* dir = opendir(path.c_str()); + if (dir == nullptr) { + LOG_E(TAG, "Failed to scan directory %s", path.c_str()); + return false; + } + + bool success = true; + struct dirent* entry; + while (success && (entry = readdir(dir)) != nullptr) { + if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) { + continue; + } + success = delete_recursively(path + "/" + entry->d_name); + } + closedir(dir); + + if (!success) { + return false; + } + + FileMutex mutex {}; + file_mutex_get(&mutex, path.c_str()); + file_mutex_lock(&mutex); + bool result = rmdir(path.c_str()) == 0; + file_mutex_unlock(&mutex); + return result; + } + + if (is_file(path)) { + FileMutex mutex {}; + file_mutex_get(&mutex, path.c_str()); + file_mutex_lock(&mutex); + bool result = remove(path.c_str()) == 0; + file_mutex_unlock(&mutex); + return result; + } + + // Doesn't exist - nothing to do. + return true; +} + +bool get_app_install_directory(std::string& out_path) { + char root[192]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + out_path = std::string(root) + "/app"; + return true; +} + +// endregion + +// region Tar extraction (ported from the old Tactility::app AppInstall.cpp) + +bool untar_file(minitar* archive, const minitar_entry* entry, const std::string& destination_path) { + auto absolute_path = destination_path + "/" + entry->metadata.path; + if (!ensure_directory_recursive(destination_path)) { + LOG_E(TAG, "Can't find or create directory %s", destination_path.c_str()); + return false; + } + + if (!minitar_read_contents_to_file(archive, entry, absolute_path.c_str())) { + LOG_E(TAG, "Failed to write data to %s", absolute_path.c_str()); + return false; + } + + // Note: fchmod() doesn't exist on ESP-IDF and chmod() does nothing on that platform. + chmod(absolute_path.c_str(), entry->metadata.mode); + + return true; +} + +bool untar_directory(const minitar_entry* entry, const std::string& destination_path) { + return ensure_directory_recursive(destination_path + "/" + entry->metadata.path); +} + +bool untar(const std::string& tar_path, const std::string& destination_path) { + minitar archive {}; + if (minitar_open(tar_path.c_str(), &archive) != 0) { + LOG_E(TAG, "Failed to open %s", tar_path.c_str()); + return false; + } + + bool success = true; + minitar_entry entry {}; + while (minitar_read_entry(&archive, &entry) == 0) { + LOG_I(TAG, "Extracting %s", entry.metadata.path); + if (entry.metadata.type == MTAR_DIRECTORY) { + if (std::strcmp(entry.metadata.name, ".") == 0 || std::strcmp(entry.metadata.name, "..") == 0 || std::strcmp(entry.metadata.name, "/") == 0) { + continue; + } + success = untar_directory(&entry, destination_path); + } else if (entry.metadata.type == MTAR_REGULAR) { + success = untar_file(&archive, &entry, destination_path); + } else { + LOG_E(TAG, "Unsupported entry type: %d", static_cast(entry.metadata.type)); + success = false; + } + + if (!success) { + LOG_E(TAG, "Failed to extract %s", entry.metadata.path); + break; + } + } + + minitar_close(&archive); + return success; +} + +// endregion + +// region Installed-app registry: owns the AppManifest (and its id/name/path strings) that +// app_manager's ledger only keeps a non-owning pointer to (see app_manager_add()'s contract). + +struct InstalledAppRecord { + std::string id; + std::string name; + std::string path; + AppManifest manifest {}; +}; + +struct InstallRegistry { + std::unordered_map> apps; + Mutex mutex {}; + + InstallRegistry() { mutex_construct(&mutex); } +}; + +InstallRegistry& install_registry() { + static InstallRegistry registry; + return registry; +} + +// Stops every currently-running instance of @a manifest. Collects matching instance ids while +// holding the ledger lock, then calls app_manager_stop() on each after releasing it - that call +// bound-joins the instance's thread, which must not happen while the ledger mutex (also taken by +// the instance's own thread_main()) is held, or the two threads would deadlock each other. +void stop_all_instances_of(const AppManifest* manifest) { + std::vector instance_ids; + + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + for (const auto& [id, record]: ledger.instances) { + if (record.manifest == manifest) { + instance_ids.push_back(id); + } + } + mutex_unlock(&ledger.mutex); + + for (uint32_t id: instance_ids) { + app_manager_stop(id); + } +} + +// Takes install_registry().mutex - caller must not already hold it. +error_t uninstall_locked(const std::string& app_id) { + auto& registry = install_registry(); + auto iterator = registry.apps.find(app_id); + if (iterator == registry.apps.end()) { + return ERROR_NOT_FOUND; + } + + stop_all_instances_of(&iterator->second->manifest); + app_manager_remove(app_id.c_str()); + delete_recursively(iterator->second->path); + registry.apps.erase(iterator); + + return ERROR_NONE; +} + +// endregion + +} // namespace + +extern "C" { + +error_t app_get_install_path(const char* app_id, char* path, size_t path_size) { + if (path_size == 0) { + return ERROR_BUFFER_OVERFLOW; + } + path[0] = '\0'; + + std::string app_parent_path; + if (!get_app_install_directory(app_parent_path)) { + return ERROR_NOT_FOUND; + } + + int written = std::snprintf(path, path_size, "%s/%s", app_parent_path.c_str(), app_id); + if (written < 0 || static_cast(written) >= path_size) { + path[0] = '\0'; + return ERROR_BUFFER_OVERFLOW; + } + + return ERROR_NONE; +} + +error_t app_install(const char* source_path) { + LOG_I(TAG, "Installing app from %s", source_path); + + std::string app_parent_path; + if (!get_app_install_directory(app_parent_path)) { + return ERROR_NOT_FOUND; + } + + if (!ensure_directory_recursive(app_parent_path)) { + LOG_E(TAG, "Failed to create %s", app_parent_path.c_str()); + return ERROR_NOT_FOUND; + } + + // Extract to a staging directory named after the tarball first - the real app id (and so + // the final directory name) is only known once the manifest inside it is parsed. + auto staging_path = app_parent_path + "/" + last_path_segment(source_path); + delete_recursively(staging_path); + + FileMutex target_mutex {}; + file_mutex_get(&target_mutex, app_parent_path.c_str()); + FileMutex source_mutex {}; + file_mutex_get(&source_mutex, source_path); + + file_mutex_lock(&target_mutex); + file_mutex_lock(&source_mutex); + bool untar_success = untar(source_path, staging_path); + file_mutex_unlock(&source_mutex); + file_mutex_unlock(&target_mutex); + + if (!untar_success) { + LOG_E(TAG, "Failed to extract %s", source_path); + delete_recursively(staging_path); + return ERROR_NOT_FOUND; + } + + auto manifest_path = staging_path + "/manifest.properties"; + if (!is_file(manifest_path)) { + LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str()); + delete_recursively(staging_path); + return ERROR_INVALID_ARGUMENT; + } + + AppMetadata metadata {}; + if (app_metadata_parse(manifest_path.c_str(), &metadata) != ERROR_NONE) { + LOG_W(TAG, "Invalid manifest"); + delete_recursively(staging_path); + return ERROR_INVALID_ARGUMENT; + } + + auto& registry = install_registry(); + mutex_lock(®istry.mutex); + + // Replace any previous install of this app id (mirrors the old install()'s "already + // running/present" handling). + uninstall_locked(metadata.app_id); + + auto final_path = app_parent_path + "/" + metadata.app_id; + delete_recursively(final_path); + + file_mutex_lock(&target_mutex); + bool rename_success = rename(staging_path.c_str(), final_path.c_str()) == 0; + file_mutex_unlock(&target_mutex); + + if (!rename_success) { + LOG_E(TAG, "Failed to rename \"%s\" to \"%s\"", staging_path.c_str(), final_path.c_str()); + delete_recursively(staging_path); + mutex_unlock(®istry.mutex); + return ERROR_NOT_FOUND; + } + + auto record = std::make_unique(); + record->id = metadata.app_id; + record->name = metadata.app_name; + record->path = final_path; + record->manifest = AppManifest { + .id = record->id.c_str(), + .name = record->name.c_str(), + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_PATH, const_cast(record->path.c_str()) }, + .flags = 0, + }; + + error_t add_result = app_manager_add(&record->manifest); + if (add_result != ERROR_NONE) { + // Only remaining failure mode is a duplicate id - can't happen, uninstall_locked() above + // already removed any previous registration for this exact id. + mutex_unlock(®istry.mutex); + return add_result; + } + + registry.apps[record->id] = std::move(record); + mutex_unlock(®istry.mutex); + + return ERROR_NONE; +} + +error_t app_uninstall(const char* app_id) { + LOG_I(TAG, "Uninstalling app %s", app_id); + + auto& registry = install_registry(); + mutex_lock(®istry.mutex); + error_t result = uninstall_locked(app_id); + mutex_unlock(®istry.mutex); + + return result; +} + +} // extern "C" diff --git a/Modules/app-module/source/app_internal_loader.cpp b/Modules/app-module/source/app_internal_loader.cpp new file mode 100644 index 000000000..d9175f188 --- /dev/null +++ b/Modules/app-module/source/app_internal_loader.cpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include + +namespace { + +error_t api_load(AppLocation location, AppRuntime* out_runtime) { + if (location.type != APP_LOCATION_MEMORY) { + return ERROR_NOT_SUPPORTED; + } + + *out_runtime = location.location; + return ERROR_NONE; +} + +int32_t api_run(AppRuntime runtime, uint32_t app_instance_id, int argc, char* argv[]) { + auto entry = reinterpret_cast(runtime); + return entry(app_instance_id, argc, argv); +} + +void api_unload(AppRuntime /*unused*/) { +} + +AppLoaderApi memory_loader_api = { + .load = api_load, + .run = api_run, + .unload = api_unload, +}; + +void* create_service(const ServiceManifest*) { + return &memory_loader_api; +} + +void destroy_service(const ServiceManifest*, void*) { +} + +} // namespace + +extern ServiceManifest app_internal_loader_service_manifest = { + .id = APP_LOADER_MEMORY_SERVICE_ID, + .create_service = create_service, + .destroy_service = destroy_service, + .on_start = nullptr, + .on_stop = nullptr, +}; diff --git a/Modules/app-module/source/app_metadata_parsing.cpp b/Modules/app-module/source/app_metadata_parsing.cpp new file mode 100644 index 000000000..79849f44e --- /dev/null +++ b/Modules/app-module/source/app_metadata_parsing.cpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "tactility/filesystem/file_mutex.h" + + +#include + +#include + +#include + +#include +#include +#include +#include +#include + +constexpr auto* TAG = "app_metadata"; + +namespace { + +std::string trim(const std::string& value) { + constexpr auto* whitespace = " \t\r\n"; + auto start = value.find_first_not_of(whitespace); + if (start == std::string::npos) { + return ""; + } + auto end = value.find_last_not_of(whitespace); + return value.substr(start, end - start + 1); +} + +bool validate_string(const std::string& value, bool (*is_valid_char)(char)) { + for (char c: value) { + if (!is_valid_char(c)) { + return false; + } + } + return true; +} + +/** manifest.properties format: "key=value" lines, "[section]" lines prefix every following key + * until the next section, "#" lines are comments, blank lines are skipped. Deliberately a local, + * minimal re-implementation rather than depending on Tactility's file::loadPropertiesFile() - + * app-module (like every other kernel module) may not depend upward on the Tactility layer. */ +bool load_properties(const std::string& path, std::map& out_properties, std::string& out_first_line) { + FileMutex mutex; + file_mutex_get(&mutex, path.c_str()); + file_mutex_lock(&mutex); + + std::ifstream file(path); + if (!file.is_open()) { + file_mutex_unlock(&mutex); + return false; + } + + std::string line; + std::string section_prefix; + bool got_first_line = false; + while (std::getline(file, line)) { + auto trimmed_line = trim(line); + if (!got_first_line) { + out_first_line = trimmed_line; + got_first_line = true; + } + + if (trimmed_line.empty() || trimmed_line.starts_with("#")) { + continue; + } + + if (trimmed_line.starts_with("[")) { + section_prefix = trimmed_line; + continue; + } + + auto separator_index = trimmed_line.find('='); + if (separator_index == std::string::npos) { + LOG_E(TAG, "Failed to parse manifest line (skipped): %s", trimmed_line.c_str()); + continue; + } + + auto key = section_prefix + trim(trimmed_line.substr(0, separator_index)); + auto value = trim(trimmed_line.substr(separator_index + 1)); + out_properties[key] = value; + } + + file_mutex_unlock(&mutex); + return true; +} + +} // namespace + +bool app_metadata_get_value(const std::map& properties, const std::string& key, std::string& out_value) { + const auto iterator = properties.find(key); + if (iterator == properties.end()) { + LOG_E(TAG, "Failed to find %s in manifest", key.c_str()); + return false; + } + out_value = iterator->second; + return true; +} + +bool app_metadata_is_valid_format_version(const std::string& version) { + return !version.empty() && validate_string(version, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '.'; + }); +} + +bool app_metadata_is_valid_id(const std::string& id) { + return id.size() >= 5 && id.size() <= APP_METADATA_APP_ID_LENGTH && validate_string(id, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '.'; + }); +} + +bool app_metadata_is_valid_name(const std::string& name) { + return name.size() >= 2 && name.size() <= APP_METADATA_APP_NAME_LENGTH && validate_string(name, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == ' ' || c == '-'; + }); +} + +bool app_metadata_is_valid_version_name(const std::string& version) { + return !version.empty() && version.size() <= APP_METADATA_APP_VERSION_NAME_LENGTH && validate_string(version, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '.' || c == '-' || c == '_'; + }); +} + +bool app_metadata_is_valid_version_code(const std::string& version) { + return !version.empty() && validate_string(version, [](char c) { + return std::isdigit(static_cast(c)) != 0; + }); +} + +bool app_metadata_copy_bounded(char* dest, size_t dest_size, const std::string& value) { + if (value.size() >= dest_size) { + return false; + } + memcpy(dest, value.c_str(), value.size() + 1); + return true; +} + +error_t app_metadata_parse(const char* path, struct AppMetadata* out_metadata) { + LOG_I(TAG, "Parsing manifest %s", path); + + std::map properties; + std::string first_line; + if (!load_properties(path, properties, first_line)) { + LOG_E(TAG, "Failed to load manifest at %s", path); + return ERROR_NOT_FOUND; + } + + // The V1 format's first line is always the literal "[manifest]" section header; V2 files are + // flat from the first line onward. + bool is_v1_format = first_line == "[manifest]"; + bool success = is_v1_format + ? app_metadata_parse_v1(properties, *out_metadata) + : app_metadata_parse_v2(properties, *out_metadata); + + return success ? ERROR_NONE : ERROR_INVALID_ARGUMENT; +} diff --git a/Modules/app-module/source/app_metadata_parsing_v1.cpp b/Modules/app-module/source/app_metadata_parsing_v1.cpp new file mode 100644 index 000000000..1f8d8c5f9 --- /dev/null +++ b/Modules/app-module/source/app_metadata_parsing_v1.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include + +constexpr auto* TAG = "app_metadata_v1"; + +bool app_metadata_parse_v1(const std::map& properties, AppMetadata& out_metadata) { + // [manifest] + + std::string format_version; + if (!app_metadata_get_value(properties, "[manifest]version", format_version)) { + return false; + } + + if (!app_metadata_is_valid_format_version(format_version)) { + LOG_E(TAG, "Invalid version"); + return false; + } + + // [app] + + std::string id; + if (!app_metadata_get_value(properties, "[app]id", id)) { + return false; + } + + if (!app_metadata_is_valid_id(id)) { + LOG_E(TAG, "Invalid app id"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_id, sizeof(out_metadata.app_id), id)) { + LOG_E(TAG, "App id too long"); + return false; + } + + std::string name; + if (!app_metadata_get_value(properties, "[app]name", name)) { + return false; + } + + if (!app_metadata_is_valid_name(name)) { + LOG_E(TAG, "Invalid app name"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_name, sizeof(out_metadata.app_name), name)) { + LOG_E(TAG, "App name too long"); + return false; + } + + std::string version_name; + if (!app_metadata_get_value(properties, "[app]versionName", version_name)) { + return false; + } + + if (!app_metadata_is_valid_version_name(version_name)) { + LOG_E(TAG, "Invalid app version name"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_version_name, sizeof(out_metadata.app_version_name), version_name)) { + LOG_E(TAG, "App version name too long"); + return false; + } + + std::string version_code_string; + if (!app_metadata_get_value(properties, "[app]versionCode", version_code_string)) { + return false; + } + + if (!app_metadata_is_valid_version_code(version_code_string)) { + LOG_E(TAG, "Invalid app version code"); + return false; + } + + out_metadata.app_version_code = std::stoull(version_code_string); + + // [target] + + std::string target_sdk; + if (!app_metadata_get_value(properties, "[target]sdk", target_sdk)) { + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.target_sdk, sizeof(out_metadata.target_sdk), target_sdk)) { + LOG_E(TAG, "Target sdk too long"); + return false; + } + + return true; +} diff --git a/Modules/app-module/source/app_metadata_parsing_v2.cpp b/Modules/app-module/source/app_metadata_parsing_v2.cpp new file mode 100644 index 000000000..a5facdd2e --- /dev/null +++ b/Modules/app-module/source/app_metadata_parsing_v2.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include + +constexpr auto* TAG = "app_metadata_v2"; + +bool app_metadata_parse_v2(const std::map& properties, AppMetadata& out_metadata) { + // manifest + + std::string format_version; + if (!app_metadata_get_value(properties, "manifest.version", format_version)) { + return false; + } + + if (!app_metadata_is_valid_format_version(format_version)) { + LOG_E(TAG, "Invalid version"); + return false; + } + + // app + + std::string id; + if (!app_metadata_get_value(properties, "app.id", id)) { + return false; + } + + if (!app_metadata_is_valid_id(id)) { + LOG_E(TAG, "Invalid app id"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_id, sizeof(out_metadata.app_id), id)) { + LOG_E(TAG, "App id too long"); + return false; + } + + std::string name; + if (!app_metadata_get_value(properties, "app.name", name)) { + return false; + } + + if (!app_metadata_is_valid_name(name)) { + LOG_E(TAG, "Invalid app name"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_name, sizeof(out_metadata.app_name), name)) { + LOG_E(TAG, "App name too long"); + return false; + } + + std::string version_name; + if (!app_metadata_get_value(properties, "app.version.name", version_name)) { + return false; + } + + if (!app_metadata_is_valid_version_name(version_name)) { + LOG_E(TAG, "Invalid app version name"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_version_name, sizeof(out_metadata.app_version_name), version_name)) { + LOG_E(TAG, "App version name too long"); + return false; + } + + std::string version_code_string; + if (!app_metadata_get_value(properties, "app.version.code", version_code_string)) { + return false; + } + + if (!app_metadata_is_valid_version_code(version_code_string)) { + LOG_E(TAG, "Invalid app version code"); + return false; + } + + out_metadata.app_version_code = std::stoull(version_code_string); + + // target + + std::string target_sdk; + if (!app_metadata_get_value(properties, "target.sdk", target_sdk)) { + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.target_sdk, sizeof(out_metadata.target_sdk), target_sdk)) { + LOG_E(TAG, "Target sdk too long"); + return false; + } + + return true; +} diff --git a/Modules/app-module/source/app_scheduler.cpp b/Modules/app-module/source/app_scheduler.cpp new file mode 100644 index 000000000..0c5f711ea --- /dev/null +++ b/Modules/app-module/source/app_scheduler.cpp @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include + +#include +#include + +#include +#include + +#include + +#define TAG "app_scheduler" + +namespace { + +struct ThreadContext { + const AppLoaderApi* loader; + void* runtime; + uint32_t app_instance_id; + int argc; + char** argv; +}; + +void set_state(uint32_t app_instance_id, AppInstanceState state) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator != ledger.instances.end()) { + iterator->second.state = state; + } + mutex_unlock(&ledger.mutex); +} + +Thread* get_thread(uint32_t app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + Thread* thread = (iterator != ledger.instances.end()) ? iterator->second.thread : nullptr; + mutex_unlock(&ledger.mutex); + return thread; +} + +void set_thread(uint32_t app_instance_id, Thread* thread) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator != ledger.instances.end()) { + iterator->second.thread = thread; + } + mutex_unlock(&ledger.mutex); +} + +const char* loader_service_id_for(AppLocationType type) { + return (type == APP_LOCATION_MEMORY) ? APP_LOADER_MEMORY_SERVICE_ID : APP_LOADER_PATH_SERVICE_ID; +} + +const AppLoaderApi* find_loader_api(AppLocationType type) { + ServiceInstance* instance = service_manager_find_instance(loader_service_id_for(type)); + if (instance == nullptr) { + return nullptr; + } + return static_cast(service_instance_get_data(instance)); +} + +// If this instance was launched via app_manager_start_for_result(), delivers @a result (its +// own AppMainFn/AppLoaderApi::run() return value) to its parent. No-op for a top-level instance +// (parent_id == 0). +void deliver_result_to_parent_if_any(uint32_t app_instance_id, int32_t result) { + auto& ledger = app_ledger(); + + uint32_t parent_id; + AppEvent event { .type = APP_EVENT_RESULT, .timestamp = 0, .result = {} }; + + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator == ledger.instances.end()) { + mutex_unlock(&ledger.mutex); + return; + } + parent_id = iterator->second.parent_id; + event.result.launch_id = app_instance_id; + event.result.result = result; + mutex_unlock(&ledger.mutex); + + if (parent_id != 0) { + app_event_emit(parent_id, &event); + } +} + +int32_t thread_main(void* context) { + auto* ctx = static_cast(context); + + set_state(ctx->app_instance_id, APP_INSTANCE_STATE_ACTIVE); + + int32_t result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, ctx->argc, ctx->argv); + + ctx->loader->unload(ctx->runtime); + + deliver_result_to_parent_if_any(ctx->app_instance_id, result); + + // A safe default terminal marker for CLOSE (and any other exit): an app that calls + // app_manager_finish() already marked itself Stopped before returning, so this is a no-op + // for it - but it's still needed as the terminal marker for any other exit path. + set_state(ctx->app_instance_id, APP_INSTANCE_STATE_STOPPED); + + app_ledger_free_arguments(ctx->argc, ctx->argv); + delete ctx; + return result; +} + +} // namespace + +extern "C" { + +error_t app_scheduler_start(uint32_t app_instance_id, AppLocation location, int argc, char* argv[]) { + const AppLoaderApi* loader = find_loader_api(location.type); + if (loader == nullptr) { + LOG_E(TAG, "No app loader is registered (service '%s' not found)", loader_service_id_for(location.type)); + app_ledger_free_arguments(argc, argv); + return ERROR_NOT_FOUND; + } + + void* runtime = nullptr; + error_t load_result = loader->load(location, &runtime); + if (load_result != ERROR_NONE) { + app_ledger_free_arguments(argc, argv); + return load_result; + } + + auto* context = new (std::nothrow) ThreadContext { loader, runtime, app_instance_id, argc, argv }; + if (context == nullptr) { + loader->unload(runtime); + app_ledger_free_arguments(argc, argv); + return ERROR_OUT_OF_MEMORY; + } + + // -1 (no affinity) matches the FreeRTOS POSIX/simulator port; ESP-IDF's tskNO_AFFINITY is + // a numerically equivalent SMP-only constant not available in the plain FreeRTOS-Kernel port. + Thread* thread = thread_alloc_full("app", 8192, thread_main, context, -1); + if (thread == nullptr) { + delete context; + loader->unload(runtime); + app_ledger_free_arguments(argc, argv); + return ERROR_OUT_OF_MEMORY; + } + + set_thread(app_instance_id, thread); + + error_t start_result = thread_start(thread); + if (start_result != ERROR_NONE) { + set_thread(app_instance_id, nullptr); + thread_free(thread); + delete context; + loader->unload(runtime); + app_ledger_free_arguments(argc, argv); + return start_result; + } + + return ERROR_NONE; +} + +error_t app_scheduler_stop(uint32_t app_instance_id, TickType_t join_timeout) { + Thread* thread = get_thread(app_instance_id); + if (thread != nullptr) { + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(app_instance_id, &event); + + if (thread_join(thread, join_timeout, pdMS_TO_TICKS(10)) != ERROR_NONE) { + LOG_W(TAG, "App instance %u did not stop in time", app_instance_id); + return ERROR_TIMEOUT; + } + thread_free(thread); + set_thread(app_instance_id, nullptr); + } + + set_state(app_instance_id, APP_INSTANCE_STATE_STOPPED); + + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + ledger.instances.erase(app_instance_id); + mutex_unlock(&ledger.mutex); + + return ERROR_NONE; +} + +} // extern "C" diff --git a/Modules/app-module/source/event.cpp b/Modules/app-module/source/event.cpp new file mode 100644 index 000000000..51f1a7549 --- /dev/null +++ b/Modules/app-module/source/event.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +// Intrusive singly-linked list of subscriptions, keyed by app_instance_id (not broadcast by +// type, unlike TactilityKernel's system_event) - an app should only ever see events addressed +// to it. Guarded by a single coarse-grained mutex, same tradeoff system_event.cpp makes for its +// poll-subscription list: notifying a subscriber here never invokes caller code (just a struct +// copy and an xTaskNotifyGive), so there is no reentrancy concern requiring a snapshot-then- +// unlock dance. +static AppEventSubscription* subscriptions = nullptr; + +struct AppEventMutex { + Mutex handle {}; + AppEventMutex() { mutex_construct(&handle); } + ~AppEventMutex() { mutex_destruct(&handle); } +}; + +static AppEventMutex subscriptions_mutex; + +extern "C" { + +error_t app_event_subscribe(AppEventSubscription* sub) { + sub->task = xTaskGetCurrentTaskHandle(); + sub->head = 0; + sub->count = 0; + + mutex_lock(&subscriptions_mutex.handle); + sub->next = subscriptions; + subscriptions = sub; + mutex_unlock(&subscriptions_mutex.handle); + + return ERROR_NONE; +} + +error_t app_event_unsubscribe(AppEventSubscription* sub) { + error_t result = ERROR_NOT_FOUND; + + mutex_lock(&subscriptions_mutex.handle); + for (AppEventSubscription** link = &subscriptions; *link != nullptr; link = &(*link)->next) { + if (*link == sub) { + *link = sub->next; + result = ERROR_NONE; + break; + } + } + mutex_unlock(&subscriptions_mutex.handle); + + return result; +} + +error_t app_event_emit(uint32_t app_instance_id, const AppEvent* event) { + AppEvent stamped_event = *event; + stamped_event.timestamp = get_micros_since_boot(); + + error_t result = ERROR_NOT_FOUND; + + mutex_lock(&subscriptions_mutex.handle); + for (AppEventSubscription* sub = subscriptions; sub != nullptr; sub = sub->next) { + if (sub->app_instance_id != app_instance_id) { + continue; + } + + if (sub->count >= APP_EVENT_QUEUE_CAPACITY) { + result = ERROR_RESOURCE; + continue; + } + + uint8_t tail = (sub->head + sub->count) % APP_EVENT_QUEUE_CAPACITY; + sub->queue[tail] = stamped_event; + sub->count++; + if (result != ERROR_RESOURCE) { + result = ERROR_NONE; + } + xTaskNotifyGive(sub->task); + } + mutex_unlock(&subscriptions_mutex.handle); + + return result; +} + +static bool try_pop(AppEventSubscription* sub, AppEvent* out_event) { + mutex_lock(&subscriptions_mutex.handle); + bool has_event = sub->count > 0; + if (has_event) { + *out_event = sub->queue[sub->head]; + sub->head = (sub->head + 1) % APP_EVENT_QUEUE_CAPACITY; + sub->count--; + } + mutex_unlock(&subscriptions_mutex.handle); + return has_event; +} + +error_t app_event_await(AppEventSubscription* sub, AppEvent* out_event, TickType_t timeout) { + if (try_pop(sub, out_event)) { + // Drain any notification credit this (or an earlier) push accumulated on this task's + // FreeRTOS notification value: each app_event_emit() calls xTaskNotifyGive() regardless + // of whether the consumer takes this fast path or the blocking path below, so without + // this the credit would carry over and cause a future ulTaskNotifyTake() below to + // return immediately for a notification that was already accounted for here. + ulTaskNotifyTake(pdTRUE, 0); + return ERROR_NONE; + } + + if (ulTaskNotifyTake(pdTRUE, timeout) == 0) { + return ERROR_TIMEOUT; + } + + // Single-consumer by design (one task per subscription), so a wakeup implies the event + // this call was notified for is still there for us to pop. + return try_pop(sub, out_event) ? ERROR_NONE : ERROR_TIMEOUT; +} + +} // extern "C" diff --git a/Modules/app-module/source/manager.cpp b/Modules/app-module/source/manager.cpp new file mode 100644 index 000000000..af26a60e7 --- /dev/null +++ b/Modules/app-module/source/manager.cpp @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +#include + +#include + +#define TAG "app_manager" + +extern "C" { + +error_t app_manager_add(const AppManifest* manifest) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + if (ledger.manifests.contains(manifest->id)) { + mutex_unlock(&ledger.mutex); + LOG_E(TAG, "Manifest with id '%s' is already registered", manifest->id); + return ERROR_INVALID_ARGUMENT; + } + ledger.manifests[manifest->id] = manifest; + mutex_unlock(&ledger.mutex); + + return ERROR_NONE; +} + +error_t app_manager_remove(const char* id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.manifests.find(id); + if (iterator == ledger.manifests.end()) { + mutex_unlock(&ledger.mutex); + return ERROR_NOT_FOUND; + } + ledger.manifests.erase(iterator); + mutex_unlock(&ledger.mutex); + + return ERROR_NONE; +} + +const AppManifest* app_manager_find_manifest(const char* id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.manifests.find(id); + const AppManifest* manifest = (iterator != ledger.manifests.end()) ? iterator->second : nullptr; + mutex_unlock(&ledger.mutex); + return manifest; +} + +void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + for (auto& [id, manifest] : ledger.manifests) { + visitor(manifest, context); + } + mutex_unlock(&ledger.mutex); +} + +namespace { + +// Deep-copies argv (argc <= 0 => NULL, matching "no parameters"). Caller passes the result to +// app_scheduler_start(), which takes ownership regardless of outcome. +char** copy_arguments(int argc, const char* const argv[]) { + if (argc <= 0) { + return nullptr; + } + auto* copy = new char*[argc + 1]; + for (int i = 0; i < argc; i++) { + size_t length = strlen(argv[i]); + copy[i] = new char[length + 1]; + memcpy(copy[i], argv[i], length + 1); + } + copy[argc] = nullptr; + return copy; +} + +// Takes ownership of argv (already a deep copy, or NULL/argc==0) regardless of outcome - +// app_scheduler_start() frees it on any failure path, and the spawned task frees it once its +// run() returns. +error_t start_internal(const char* id, AppInstanceId parent_instance_id, int argc, char* argv[], AppInstanceId* out_app_instance_id) { + const AppManifest* manifest = app_manager_find_manifest(id); + if (manifest == nullptr) { + app_ledger_free_arguments(argc, argv); + return ERROR_NOT_FOUND; + } + + auto& ledger = app_ledger(); + + mutex_lock(&ledger.mutex); + AppInstanceId target_id = ledger.next_instance_id++; + AppInstanceRecord record { target_id, manifest, APP_INSTANCE_STATE_STARTING, nullptr }; + record.parent_id = parent_instance_id; + ledger.instances[target_id] = record; + mutex_unlock(&ledger.mutex); + + error_t result = app_scheduler_start(target_id, manifest->location, argc, argv); + if (result != ERROR_NONE) { + mutex_lock(&ledger.mutex); + ledger.instances.erase(target_id); + mutex_unlock(&ledger.mutex); + return result; + } + + *out_app_instance_id = target_id; + return ERROR_NONE; +} + +} // namespace + +error_t app_manager_start(const char* id, AppInstanceId* out_app_instance_id) { + return start_internal(id, 0, 0, nullptr, out_app_instance_id); +} + +error_t app_manager_start_with_parameters(const char* id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id) { + return start_internal(id, 0, argc, copy_arguments(argc, argv), out_app_instance_id); +} + +error_t app_manager_start_for_result(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id) { + return start_internal(id, parent_instance_id, argc, copy_arguments(argc, argv), out_app_instance_id); +} + +error_t app_manager_stop(AppInstanceId app_instance_id) { + return app_scheduler_stop(app_instance_id, pdMS_TO_TICKS(2000)); +} + +error_t app_manager_finish(AppInstanceId app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator != ledger.instances.end()) { + iterator->second.state = APP_INSTANCE_STATE_STOPPED; + } + mutex_unlock(&ledger.mutex); + return ERROR_NONE; +} + +AppInstanceState app_manager_get_state(AppInstanceId app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + AppInstanceState state = (iterator != ledger.instances.end()) ? iterator->second.state : APP_INSTANCE_STATE_STOPPED; + mutex_unlock(&ledger.mutex); + return state; +} + +error_t app_manager_get_topmost_instance_id(AppInstanceId* out_app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + AppInstanceId topmost_id = 0; + for (auto& [instance_id, record] : ledger.instances) { + // Instance ids are handed out in increasing order (AppLedger::next_instance_id), so + // the highest Active id is also the most recently started one. + if (record.state == APP_INSTANCE_STATE_ACTIVE && instance_id > topmost_id) { + topmost_id = instance_id; + } + } + mutex_unlock(&ledger.mutex); + + if (topmost_id == 0) { + return ERROR_NOT_FOUND; + } + *out_app_instance_id = topmost_id; + return ERROR_NONE; +} + +error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size) { + if (buffer_size == 0) { + return ERROR_BUFFER_OVERFLOW; + } + buffer[0] = '\0'; + + AppInstanceId topmost_id = 0; + error_t result = app_manager_get_topmost_instance_id(&topmost_id); + if (result != ERROR_NONE) { + return result; + } + + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(topmost_id); + const char* app_id = (iterator != ledger.instances.end()) ? iterator->second.manifest->id : nullptr; + mutex_unlock(&ledger.mutex); + + if (app_id == nullptr) { + return ERROR_NOT_FOUND; + } + + size_t length = strlen(app_id); + if (length >= buffer_size) { + buffer[0] = '\0'; + return ERROR_BUFFER_OVERFLOW; + } + memcpy(buffer, app_id, length + 1); + return ERROR_NONE; +} + +} // extern "C" diff --git a/Modules/app-module/source/module.cpp b/Modules/app-module/source/module.cpp new file mode 100644 index 000000000..f4811265f --- /dev/null +++ b/Modules/app-module/source/module.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include + +extern "C" { + +extern ServiceManifest app_internal_loader_service_manifest; + +static error_t start() { + return service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true); +} + +static error_t stop() { + return service_manager_remove(app_internal_loader_service_manifest.id); +} + +Module app_module = { + .name = "app", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Modules/lvgl-window-manager/CMakeLists.txt b/Modules/lvgl-window-manager/CMakeLists.txt new file mode 100644 index 000000000..7067fcdb4 --- /dev/null +++ b/Modules/lvgl-window-manager/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(lvgl-window-manager + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + REQUIRES TactilityKernel lvgl-module +) diff --git a/Modules/lvgl-window-manager/devicetree.yaml b/Modules/lvgl-window-manager/devicetree.yaml new file mode 100644 index 000000000..6bbb24367 --- /dev/null +++ b/Modules/lvgl-window-manager/devicetree.yaml @@ -0,0 +1,2 @@ +dependencies: + - TactilityKernel diff --git a/Modules/lvgl-window-manager/include/lvgl_window_manager/module.h b/Modules/lvgl-window-manager/include/lvgl_window_manager/module.h new file mode 100644 index 000000000..a4c2615db --- /dev/null +++ b/Modules/lvgl-window-manager/include/lvgl_window_manager/module.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module lvgl_window_manager_module; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h b/Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h new file mode 100644 index 000000000..71c7022ba --- /dev/null +++ b/Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef uint32_t WindowId; + +enum WindowState { + /** id is the current topmost window and has live widgets. */ + WINDOW_STATE_GRANTED, + /** id is not currently topmost - either buried under a newer window (its widgets don't + * exist right now, but it may resurface and get rebuilt if everything above it is removed) + * or it no longer exists at all (removed). */ + WINDOW_STATE_REVOKED, +}; + +/** + * Called once by window_manager_start(), given the real root widget (a raw, full-size + * container created directly under the default display's active screen). May add extra chrome + * (e.g. a statusbar) as children of @a root_widget. + * @param[in] root_widget the real root widget; owned by this module, deleted automatically + * (along with everything added under it) by window_manager_stop() + * @return the widget windows should actually be placed into - @a root_widget itself, or a + * child of it. Returning NULL falls back to @a root_widget. + * @warning Called on the LVGL task with the LVGL lock already held. + */ +typedef lv_obj_t* (*WindowManagerScreenInitFn)(lv_obj_t* root_widget); + +/** + * Configures the screen-init callback window_manager_start() invokes to build the root/content + * widgets. Pass NULL to restore the default (no chrome - the raw root widget is used directly). + * @warning Must be called before window_manager_start(); has no effect once already started. + */ +void window_manager_configure(WindowManagerScreenInitFn screen_init); + +/** + * Creates the root widget (under the default display's active screen) and, via the configured + * screen-init callback, whatever chrome/content widget it wants around it. Idempotent - a + * second call while already started is a no-op. + * @retval ERROR_RESOURCE no default display is active (lv_screen_active() returned NULL) + * @retval ERROR_NONE on success (including if already started) + */ +error_t window_manager_start(void); + +/** + * Deletes the root widget created by window_manager_start() (and everything under it - any + * chrome plus whatever the topmost window had drawn), removing it from the display, and drops + * every tracked window. Idempotent - a second call while already stopped is a no-op. + */ +error_t window_manager_stop(void); + +/** + * Called to populate a window's widgets: once by window_manager_create() when the window is + * first created, and again later by window_manager_remove() if this window resurfaces as the + * new topmost after whatever was above it is removed. Only the current topmost window ever has + * live widgets - everything below it in the stack exists as tracked state only. + * @param[in] root a fresh, full-size container created directly under the content widget for + * this window; deleted automatically once this window stops being topmost + * @param[in] user_data whatever was passed to window_manager_create() for this window + * @warning Called on the LVGL task with the LVGL lock already held. + * @warning May run on a different kernel thread than the one that called window_manager_create() + * for this window - the rebuild-on-remove path runs on whichever thread called + * window_manager_remove() for the window that used to be on top (e.g. a dialog's own thread as + * it closes). Do NOT rely on thread_local state set by this window's own app thread; use + * @a user_data instead. + */ +typedef void (*WindowCreateWidgetsFn)(lv_obj_t* root, void* user_data); + +/** + * Creates a new window on top of the stack (last created = topmost). Deletes the previously + * topmost window's widgets (if any) and builds this window's widgets immediately via + * @a create_widgets - only the topmost window ever has live widgets. + * @param[in] user_data opaque; passed back to @a create_widgets on every call, including a + * later rebuild triggered by window_manager_remove() - see its @warning about which thread that + * can run on. Typically the calling app's own Context*. + * @return the new window's id, or 0 if window_manager_start() hasn't been called + */ +WindowId window_manager_create(uint32_t app_instance_id, WindowCreateWidgetsFn create_widgets, void* user_data); + +/** + * Removes a window, wherever it is in the stack - not necessarily the topmost one. If it was + * topmost, its widgets are deleted and whichever window is now on top (if any) has its + * create_widgets called again to rebuild its widgets. + */ +void window_manager_remove(WindowId id); + +/** @return the current state of @a id; WINDOW_STATE_REVOKED if @a id is buried or doesn't exist. */ +enum WindowState window_manager_get_state(WindowId id); + +/** + * Blocks the calling task until @a id's state changes away from WINDOW_STATE_GRANTED, or + * @a timeout elapses. Returns immediately with WINDOW_STATE_REVOKED if @a id isn't currently + * topmost (nothing to wait for). + * @return the state after waking (or immediately, if there was nothing to wait for) + */ +enum WindowState window_manager_await_state_change(WindowId id, TickType_t timeout); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-window-manager/source/module.cpp b/Modules/lvgl-window-manager/source/module.cpp new file mode 100644 index 000000000..b93d5010f --- /dev/null +++ b/Modules/lvgl-window-manager/source/module.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include + +extern "C" { + +Module lvgl_window_manager_module = { + .name = "lvgl-window-manager", + .start = window_manager_start, + .stop = window_manager_stop, + .drivers = nullptr, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Modules/lvgl-window-manager/source/window_manager.cpp b/Modules/lvgl-window-manager/source/window_manager.cpp new file mode 100644 index 000000000..c2d1e91b3 --- /dev/null +++ b/Modules/lvgl-window-manager/source/window_manager.cpp @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include + +#include +#include + +namespace { + +struct WindowRecord { + WindowId id; + uint32_t app_instance_id; + WindowCreateWidgetsFn create_widgets; + void* user_data; +}; + +struct WindowManagerState { + Mutex mutex {}; + + bool started = false; + WindowManagerScreenInitFn screen_init = nullptr; + + /** The raw, full-size container window_manager_start() creates; owns (and deletion + * cascades to) whatever the screen-init callback added under it. */ + lv_obj_t* real_root_widget = nullptr; + /** The stable parent each window's own widget is created under - real_root_widget itself, + * unless the screen-init callback returned a nested content widget instead. */ + lv_obj_t* content_root_widget = nullptr; + + WindowId next_id = 1; + /** windows.back() is topmost; only it ever has a live widget (top_widget). */ + std::vector windows; + lv_obj_t* top_widget = nullptr; + + /** Task blocked in window_manager_await_state_change(), if any. */ + TaskHandle_t waiting_task = nullptr; + + WindowManagerState() { mutex_construct(&mutex); } +}; + +WindowManagerState& state() { + static WindowManagerState instance; + return instance; +} + +lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_widgets, void* user_data) { + if (content == nullptr) { + return nullptr; + } + lvgl_lock(); + lv_obj_t* widget = lv_obj_create(content); + lv_obj_set_size(widget, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(widget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(widget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(widget, 0, LV_STATE_DEFAULT); + if (create_widgets != nullptr) { + create_widgets(widget, user_data); + } + lvgl_unlock(); + return widget; +} + +void delete_widget(lv_obj_t* widget) { + if (widget == nullptr) { + return; + } + lvgl_lock(); + lv_obj_delete(widget); + lvgl_unlock(); +} + +} // namespace + +extern "C" { + +void window_manager_configure(WindowManagerScreenInitFn screen_init) { + auto& s = state(); + mutex_lock(&s.mutex); + s.screen_init = screen_init; + mutex_unlock(&s.mutex); +} + +error_t window_manager_start(void) { + auto& s = state(); + + mutex_lock(&s.mutex); + if (s.started) { + mutex_unlock(&s.mutex); + return ERROR_NONE; + } + WindowManagerScreenInitFn screen_init = s.screen_init; + mutex_unlock(&s.mutex); + + lv_obj_t* real_widget = nullptr; + lv_obj_t* content_widget = nullptr; + + lvgl_lock(); + lv_obj_t* screen = lv_screen_active(); + if (screen != nullptr) { + real_widget = lv_obj_create(screen); + lv_obj_set_size(real_widget, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(real_widget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(real_widget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(real_widget, 0, LV_STATE_DEFAULT); + + content_widget = (screen_init != nullptr) ? screen_init(real_widget) : nullptr; + if (content_widget == nullptr) { + content_widget = real_widget; + } + } + lvgl_unlock(); + + if (real_widget == nullptr) { + return ERROR_RESOURCE; + } + + mutex_lock(&s.mutex); + s.real_root_widget = real_widget; + s.content_root_widget = content_widget; + s.started = true; + mutex_unlock(&s.mutex); + + return ERROR_NONE; +} + +error_t window_manager_stop(void) { + auto& s = state(); + + mutex_lock(&s.mutex); + if (!s.started) { + mutex_unlock(&s.mutex); + return ERROR_NONE; + } + lv_obj_t* widget = s.real_root_widget; + TaskHandle_t waiter = s.waiting_task; + s.real_root_widget = nullptr; + s.content_root_widget = nullptr; + s.top_widget = nullptr; + s.windows.clear(); + s.started = false; + s.waiting_task = nullptr; + mutex_unlock(&s.mutex); + + if (waiter != nullptr) { + xTaskNotifyGive(waiter); + } + + // Deleting the real widget cascades to everything under it - chrome and top_widget alike. + delete_widget(widget); + + return ERROR_NONE; +} + +WindowId window_manager_create(uint32_t app_instance_id, WindowCreateWidgetsFn create_widgets, void* user_data) { + auto& s = state(); + + mutex_lock(&s.mutex); + if (!s.started) { + mutex_unlock(&s.mutex); + return 0; + } + lv_obj_t* content = s.content_root_widget; + lv_obj_t* old_top_widget = s.top_widget; + TaskHandle_t waiter = s.waiting_task; + s.waiting_task = nullptr; + s.top_widget = nullptr; + WindowId new_id = s.next_id++; + s.windows.push_back(WindowRecord { new_id, app_instance_id, create_widgets, user_data }); + mutex_unlock(&s.mutex); + + if (waiter != nullptr) { + xTaskNotifyGive(waiter); + } + + delete_widget(old_top_widget); + lv_obj_t* new_widget = build_window_widget(content, create_widgets, user_data); + + mutex_lock(&s.mutex); + bool still_topmost = !s.windows.empty() && s.windows.back().id == new_id; + if (still_topmost) { + s.top_widget = new_widget; + new_widget = nullptr; // consumed + } + mutex_unlock(&s.mutex); + + // Something else became topmost while we were building (e.g. a concurrent create() from + // another app thread) - discard what we just made. + delete_widget(new_widget); + + return new_id; +} + +void window_manager_remove(WindowId id) { + auto& s = state(); + + mutex_lock(&s.mutex); + auto iterator = std::find_if(s.windows.begin(), s.windows.end(), + [id](const WindowRecord& window) { return window.id == id; }); + if (iterator == s.windows.end()) { + mutex_unlock(&s.mutex); + return; + } + bool was_topmost = (iterator + 1 == s.windows.end()); + s.windows.erase(iterator); + + lv_obj_t* content = s.content_root_widget; + lv_obj_t* old_widget = nullptr; + WindowCreateWidgetsFn next_create_widgets = nullptr; + void* next_user_data = nullptr; + WindowId next_id = 0; + bool has_next = false; + + if (was_topmost) { + old_widget = s.top_widget; + s.top_widget = nullptr; + if (!s.windows.empty()) { + next_create_widgets = s.windows.back().create_widgets; + next_user_data = s.windows.back().user_data; + next_id = s.windows.back().id; + has_next = true; + } + } + + TaskHandle_t waiter = s.waiting_task; + s.waiting_task = nullptr; + mutex_unlock(&s.mutex); + + if (waiter != nullptr) { + xTaskNotifyGive(waiter); + } + + if (!was_topmost) { + // A buried window was removed - the topmost window's widgets are unaffected. + return; + } + + delete_widget(old_widget); + lv_obj_t* new_widget = has_next ? build_window_widget(content, next_create_widgets, next_user_data) : nullptr; + + mutex_lock(&s.mutex); + bool still_topmost = has_next && !s.windows.empty() && s.windows.back().id == next_id; + if (still_topmost) { + s.top_widget = new_widget; + new_widget = nullptr; // consumed + } + mutex_unlock(&s.mutex); + + delete_widget(new_widget); +} + +WindowState window_manager_get_state(WindowId id) { + auto& s = state(); + mutex_lock(&s.mutex); + bool is_top = !s.windows.empty() && s.windows.back().id == id; + mutex_unlock(&s.mutex); + return is_top ? WINDOW_STATE_GRANTED : WINDOW_STATE_REVOKED; +} + +WindowState window_manager_await_state_change(WindowId id, TickType_t timeout) { + auto& s = state(); + + mutex_lock(&s.mutex); + bool is_top = !s.windows.empty() && s.windows.back().id == id; + if (!is_top) { + mutex_unlock(&s.mutex); + return WINDOW_STATE_REVOKED; + } + s.waiting_task = xTaskGetCurrentTaskHandle(); + mutex_unlock(&s.mutex); + + ulTaskNotifyTake(pdTRUE, timeout); + + return window_manager_get_state(id); +} + +} // extern "C" From c628668e75e7b410928bfa3747867cd2c9e669f6 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 14:13:00 +0200 Subject: [PATCH 03/31] Update --- Buildscripts/TactilitySDK/CMakeLists.txt | 4 +- Buildscripts/TactilitySDK/TactilitySDK.cmake | 12 +- Buildscripts/release-sdk.py | 6 +- CMakeLists.txt | 2 + Devices/lilygo-tdeck-plus/device.properties | 5 + Documentation/ideas.md | 15 +- .../source/app_esp32_loader_service.cpp | 51 ++- Modules/app-module/include/app/instance.h | 5 + Modules/app-module/include/app/manager.h | 23 +- Modules/app-module/include/app/metadata.h | 4 +- Modules/app-module/include/app/scheduler.h | 21 + .../app-module/private/app/private/app_fs.h | 63 +++ .../private/app/private/app_ledger.h | 10 +- .../private/app/private/app_scheduler.h | 14 +- Modules/app-module/source/app_install.cpp | 109 +++-- .../source/app_metadata_parsing.cpp | 12 +- .../source/app_metadata_parsing_v1.cpp | 14 +- .../source/app_metadata_parsing_v2.cpp | 12 +- Modules/app-module/source/app_scheduler.cpp | 119 +++-- Modules/app-module/source/manager.cpp | 118 +++++ Modules/app-module/source/module.cpp | 30 -- Modules/app-module/source/symbols.cpp | 54 +++ .../CMakeLists.txt | 2 +- .../devicetree.yaml | 0 .../include/lvgl_window_manager/module.h | 0 .../lvgl_window_manager/window_manager.h | 0 .../source/symbols.cpp} | 11 +- .../source/window_manager.cpp | 53 ++- .../include/tactility/system_event.h | 12 +- TactilityKernel/source/system_event.cpp | 45 +- Tests/CMakeLists.txt | 2 + Tests/Tactility/CMakeLists.txt | 2 + Tests/app-module/CMakeLists.txt | 19 + Tests/app-module/Source/AppEventTest.cpp | 137 ++++++ Tests/app-module/Source/AppManagerTest.cpp | 431 ++++++++++++++++++ Tests/app-module/Source/Main.cpp | 51 +++ 36 files changed, 1283 insertions(+), 185 deletions(-) create mode 100644 Modules/app-module/include/app/scheduler.h create mode 100644 Modules/app-module/private/app/private/app_fs.h delete mode 100644 Modules/app-module/source/module.cpp create mode 100644 Modules/app-module/source/symbols.cpp rename Modules/{lvgl-window-manager => lvgl-window-manager-module}/CMakeLists.txt (83%) rename Modules/{lvgl-window-manager => lvgl-window-manager-module}/devicetree.yaml (100%) rename Modules/{lvgl-window-manager => lvgl-window-manager-module}/include/lvgl_window_manager/module.h (100%) rename Modules/{lvgl-window-manager => lvgl-window-manager-module}/include/lvgl_window_manager/window_manager.h (100%) rename Modules/{lvgl-window-manager/source/module.cpp => lvgl-window-manager-module/source/symbols.cpp} (51%) rename Modules/{lvgl-window-manager => lvgl-window-manager-module}/source/window_manager.cpp (74%) create mode 100644 Tests/app-module/CMakeLists.txt create mode 100644 Tests/app-module/Source/AppEventTest.cpp create mode 100644 Tests/app-module/Source/AppManagerTest.cpp create mode 100644 Tests/app-module/Source/Main.cpp diff --git a/Buildscripts/TactilitySDK/CMakeLists.txt b/Buildscripts/TactilitySDK/CMakeLists.txt index 3be1ba3ef..ffa734c57 100644 --- a/Buildscripts/TactilitySDK/CMakeLists.txt +++ b/Buildscripts/TactilitySDK/CMakeLists.txt @@ -2,12 +2,12 @@ idf_component_register( INCLUDE_DIRS "Libraries/TactilityC/include" "Libraries/TactilityKernel/include" - "Libraries/TactilityFreeRtos/include" + "Libraries/TactilityFreeRtos/Include" "Libraries/lvgl/include" "Libraries/minmea/include" "Modules/lvgl-module/include" # DRIVER_INCLUDE_DIRS_PLACEHOLDER - REQUIRES esp_timer + REQUIRES esp_timer app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module ) # Regular and core features diff --git a/Buildscripts/TactilitySDK/TactilitySDK.cmake b/Buildscripts/TactilitySDK/TactilitySDK.cmake index 71af31dd7..eb3c64682 100644 --- a/Buildscripts/TactilitySDK/TactilitySDK.cmake +++ b/Buildscripts/TactilitySDK/TactilitySDK.cmake @@ -18,13 +18,19 @@ macro(tactility_project project_name) endif() set(EXTRA_COMPONENT_DIRS - "Libraries/TactilityFreeRtos" - "Modules" - "Drivers" + "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" + "${TACTILITY_SDK_PATH}/Modules" + "${TACTILITY_SDK_PATH}/Drivers" ) set(COMPONENTS TactilityFreeRtos + app-module + crypt-module + gps-module + lvgl-module + lvgl-window-manager-module + service-module # DRIVER_COMPONENTS_PLACEHOLDER ) diff --git a/Buildscripts/release-sdk.py b/Buildscripts/release-sdk.py index 338546621..2aadf72c6 100644 --- a/Buildscripts/release-sdk.py +++ b/Buildscripts/release-sdk.py @@ -167,7 +167,7 @@ def main(): {'src': 'TactilityC/CMakeLists.txt', 'dst': 'Libraries/TactilityC/'}, {'src': 'TactilityC/LICENSE*.*', 'dst': 'Libraries/TactilityC/'}, # TactilityFreeRtos - {'src': 'TactilityFreeRtos/Include/**', 'dst': 'Libraries/TactilityFreeRtos/include/'}, + {'src': 'TactilityFreeRtos/Include/**', 'dst': 'Libraries/TactilityFreeRtos/Include/'}, {'src': 'TactilityFreeRtos/CMakeLists.txt', 'dst': 'Libraries/TactilityFreeRtos/'}, {'src': 'TactilityFreeRtos/LICENSE*.*', 'dst': 'Libraries/TactilityFreeRtos/'}, # TactilityKernel @@ -197,9 +197,11 @@ def main(): map_copy(mappings, target_path) # Modules - add_module(target_path, "lvgl-module") + add_module(target_path, "app-module") add_module(target_path, "crypt-module") add_module(target_path, "gps-module") + add_module(target_path, "lvgl-module") + add_module(target_path, "lvgl-window-manager-module") add_module(target_path, "service-module") # Drivers - only ones actually built for this target (chip-restricted drivers like diff --git a/CMakeLists.txt b/CMakeLists.txt index 02965b4be..d4604c897 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,8 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION}) add_subdirectory(Modules/crypt-module) add_subdirectory(Modules/gps-module) add_subdirectory(Modules/service-module) + add_subdirectory(Modules/app-module) + add_subdirectory(Modules/lvgl-window-manager-module) add_subdirectory(Drivers/gps-generic-module) add_subdirectory(Drivers/gps-meshtastic-module) diff --git a/Devices/lilygo-tdeck-plus/device.properties b/Devices/lilygo-tdeck-plus/device.properties index 3aaf6fbba..594b5fc50 100644 --- a/Devices/lilygo-tdeck-plus/device.properties +++ b/Devices/lilygo-tdeck-plus/device.properties @@ -23,3 +23,8 @@ cdn.infoMessage=To put the device into bootloader mode:
1. Press the trackb lvgl.colorDepth=16 sdkconfig.CONFIG_CODEC_DUMMY_SUPPORT=y + +# Fix error "PSRAM space not enough for the Flash instructions" on boot: +sdkconfig.CONFIG_SPIRAM_FETCH_INSTRUCTIONS=n +sdkconfig.CONFIG_SPIRAM_RODATA=n +sdkconfig.CONFIG_SPIRAM_XIP_FROM_PSRAM=n diff --git a/Documentation/ideas.md b/Documentation/ideas.md index b2910b85f..963ffabaa 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -12,13 +12,25 @@ ## Higher Priority +- Move "# Fix error "PSRAM space not enough for the Flash instructions" on boot:" fix from T-Deck and others to device.py +- Make it possible to override stack size for an app via config file (loaded at boot), and make it possible to set preferred memory location (e.g. internal/external) +- Put task stacks in PSRAM when possible. +- Wrap file operations like fopen/fclose with file_mutex +- Add bold fonts for e-ink readability improvement +- Split up Claude instructions: https://code.claude.com/docs/en/memory#import-additional-files + and add https://github.com/multica-ai/andrej-karpathy-skills/blob/main/CLAUDE.md +- Move test projects to their relevant subproject +- tt_alertdialog start() etc is broken as it can't fetch the app instance id. Fetch automatically via thread context? +- Migrate Tactility/Paths.cpp functions to TactilityKernel +- app_manager_find_manifest() should make a copy, not return a pointer. +- Httpd.cpp: warn if running on same CPU core (or task) as UI/LVGL/window manager. - Improve Setup: Show "Step done" screen - Improve Setup: Add keyboard/keypad navigation explanation - display.h API: get_backlight does not change ref counting, but it should - bluetooth: various getters for child devices do not change ref counting, but they should - Improve kernel_init.cpp (and other modules): create driver_ensure_added() and driver_ensure_destructed() - Remove and migrate `Include/Tactility/kernel/Kernel.h` into `tactility/delay.h` -- Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual module. +- Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual modulej. - LilyGO T-Dongle S3: 1 button control, stop auto-launching web server - Core2: support power off via software - Create `#define` for empty module (for modules that fully rely on device.properties and don't define drivers or have start/stop logic) @@ -40,6 +52,7 @@ ## Medium Priority +- Consider using https://github.com/Graphify-Labs/graphify - Implement a LED kernel driver (single colour and RGB, plain GPIO and PWM) - Make USB host driver disabled by default, so it doesn't consume memory - Filtering for apps in App Hub: diff --git a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp index ec3e6e1cd..ee510d818 100644 --- a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp +++ b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp @@ -1,24 +1,27 @@ // SPDX-License-Identifier: Apache-2.0 -#include "../../../TactilityKernel/include/tactility/error.h" -#include "../../../TactilityKernel/include/tactility/filesystem/file_mutex.h" -#include "../../app-module/include/app/loader.h" -#include "../../app-module/include/app/location.h" - +#ifdef ESP_PLATFORM +#include +#endif #include -#include +#include + +#include +#include +#include +#include -#include #include #include #include #include -#include #include #include +constexpr auto* TAG = "app_esp32_loader"; + namespace { /** load()-allocated state, passed back through run()/unload(). */ @@ -34,6 +37,7 @@ error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) { FILE* file = fopen(path, "rb"); if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", path); file_mutex_unlock(&mutex); return ERROR_NOT_FOUND; } @@ -68,19 +72,35 @@ error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) { return ERROR_NONE; } +// location.location can be either an app's install directory or the .elf file directly; the +// former resolves to the per-target binary at {dir}/elf/{CONFIG_IDF_TARGET}.elf. +std::string resolve_elf_path(const std::string& path) { + if (path.ends_with(".elf")) { + return path; + } + return path + "/elf/" + CONFIG_IDF_TARGET + ".elf"; +} + error_t api_load(AppLocation location, AppRuntime* out_runtime) { + if (location.type != APP_LOCATION_PATH) { + LOG_E(TAG, "Out of memory"); + return ERROR_NOT_SUPPORTED; + } + + LOG_I(TAG, "Loading %s", static_cast(location.location)); + auto* runtime = new (std::nothrow) Esp32AppRuntime(); if (runtime == nullptr) { + LOG_E(TAG, "Out of memory"); return ERROR_OUT_OF_MEMORY; } - if (location.type != APP_LOCATION_PATH) { - return ERROR_NOT_SUPPORTED; - } + auto elf_path = resolve_elf_path(static_cast(location.location)); size_t size = 0; - error_t read_result = read_file(static_cast(location.location), &runtime->file_data, &size); + error_t read_result = read_file(elf_path.c_str(), &runtime->file_data, &size); if (read_result != ERROR_NONE) { + LOG_E(TAG, "Failed to read file"); delete runtime; return read_result; } @@ -88,13 +108,15 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) { if (esp_elf_init(&runtime->elf) != ESP_OK) { free(runtime->file_data); delete runtime; + LOG_E(TAG, "Failed to init elf"); return ERROR_RESOURCE; } if (esp_elf_relocate(&runtime->elf, runtime->file_data) != 0) { - esp_elf_deinit(&runtime->elf); + // esp_elf_relocate() already frees elf->pdata/ptext itself on a relocation failure free(runtime->file_data); delete runtime; + LOG_E(TAG, "Failed to map elf"); return ERROR_RESOURCE; } @@ -104,9 +126,6 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) { int32_t api_run(AppRuntime runtime_ptr, uint32_t /*app_instance_id*/, int argc, char* argv[]) { auto* runtime = static_cast(runtime_ptr); - // A side-loaded ELF's own main() only ever gets a real argc/argv from esp_elf_request()'s - // fixed signature - there's no slot for app_instance_id there, and side-loaded apps don't - // need one yet. return esp_elf_request(&runtime->elf, 0, argc, argv); } diff --git a/Modules/app-module/include/app/instance.h b/Modules/app-module/include/app/instance.h index abd95f346..b8993d82f 100644 --- a/Modules/app-module/include/app/instance.h +++ b/Modules/app-module/include/app/instance.h @@ -1,10 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include + #ifdef __cplusplus extern "C" { #endif +/** Identifies a running (or previously running) app instance. 0 is never a valid instance id. */ +typedef uint32_t AppInstanceId; + /** Lifecycle state of a running (or previously running) app instance. Every app instance owns * its own task for its entire lifetime - there is no "saved, task given up" state. */ typedef enum { diff --git a/Modules/app-module/include/app/manager.h b/Modules/app-module/include/app/manager.h index 3a18ad62c..55f6c7943 100644 --- a/Modules/app-module/include/app/manager.h +++ b/Modules/app-module/include/app/manager.h @@ -13,9 +13,6 @@ extern "C" { #endif -/** Identifies a running (or previously running) app instance. 0 is never a valid instance id. */ -typedef uint32_t AppInstanceId; - /** * Register an app manifest. * @retval ERROR_INVALID_ARGUMENT a manifest with the same id is already registered @@ -130,6 +127,26 @@ error_t app_manager_get_topmost_instance_id(AppInstanceId* out_app_instance_id); */ error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size); +/** + * Registers @a path as a directory to scan for app manifests - each direct subdirectory of + * @a path is expected to hold a manifest.properties (see app/metadata.h), matching the layout + * app_install() creates ({install dir}/{app_id}/manifest.properties), though this is not + * install/uninstall - it only ever adds/removes manifest registrations, never touches files on + * disk or running instances. No-op if @a path is already registered. Does not scan immediately - + * call app_manager_install_path_scan() to do that. + * @retval ERROR_NONE on success + */ +error_t app_manager_install_path_add(const char* path); + +/** + * Scans every path registered via app_manager_install_path_add(): registers + * (app_manager_add()) any direct subdirectory with a valid manifest.properties that isn't + * already registered, and unregisters (app_manager_remove() only - does not stop it if running, + * does not delete anything) any manifest a previous scan registered whose directory has since + * disappeared. Safe to call repeatedly (e.g. after an SD card is mounted/unmounted). + */ +void app_manager_install_path_scan(void); + #ifdef __cplusplus } #endif diff --git a/Modules/app-module/include/app/metadata.h b/Modules/app-module/include/app/metadata.h index 1f02ac6ea..4b759f4ae 100644 --- a/Modules/app-module/include/app/metadata.h +++ b/Modules/app-module/include/app/metadata.h @@ -39,8 +39,8 @@ struct AppMetadata { */ char app_version_name[APP_METADATA_APP_VERSION_NAME_LENGTH + 1]; - /** The technical version (must be incremented with new releases of the app */ - uint64_t app_version_code = 0; + /** The technical version (must be incremented with new releases of the app) */ + uint64_t app_version_code; }; /** diff --git a/Modules/app-module/include/app/scheduler.h b/Modules/app-module/include/app/scheduler.h new file mode 100644 index 000000000..163c44ec9 --- /dev/null +++ b/Modules/app-module/include/app/scheduler.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @return the app_instance_id of whichever app instance's task is calling this (every app + * instance's task stashes it in its own thread-local storage when it starts), or 0 if called + * from a task that isn't a running app instance. An app's own main() typically calls this once, + * near the top, to learn its own instance id - see e.g. app_event_subscribe()/ + * window_manager_create(), both of which need it. + */ +AppInstanceId app_scheduler_current_app_id(void); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/private/app/private/app_fs.h b/Modules/app-module/private/app/private/app_fs.h new file mode 100644 index 000000000..03b4d77c5 --- /dev/null +++ b/Modules/app-module/private/app/private/app_fs.h @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +// Minimal filesystem helpers shared by app-module internals that need to look at on-disk app +// directories (app_install.cpp, manager.cpp's install-path scan) - app-module may not depend +// upward on Tactility::file, so this is a small local re-implementation (see +// app_metadata_parsing.cpp for the same constraint applied to properties-file loading). + +#include "tactility/filesystem/file_mutex.h" + + +#include +#include +#include +#include +#include + +inline bool app_fs_is_directory(const std::string& path) { + struct stat result {}; + FileMutex file_mutex; + file_mutex_get(&file_mutex, path.c_str()); + file_mutex_lock(&file_mutex); + auto is_dir = stat(path.c_str(), &result) == 0 && S_ISDIR(result.st_mode); + file_mutex_unlock(&file_mutex); + return is_dir; +} + +inline bool app_fs_is_file(const std::string& path) { + FileMutex file_mutex; + file_mutex_get(&file_mutex, path.c_str()); + file_mutex_lock(&file_mutex); + struct stat result {}; + auto retval = stat(path.c_str(), &result) == 0 && S_ISREG(result.st_mode); + file_mutex_unlock(&file_mutex); + return retval; +} + +// Appends the full path of every direct subdirectory of @a path to @a out. No-op (not an error) +// if @a path can't be opened. +inline void app_fs_list_direct_subdirectories(const std::string& path, std::vector& out) { + FileMutex file_mutex; + file_mutex_get(&file_mutex, path.c_str()); + file_mutex_lock(&file_mutex); + DIR* dir = opendir(path.c_str()); + if (dir == nullptr) { + file_mutex_unlock(&file_mutex); + return; + } + + struct dirent* entry; + while ((entry = readdir(dir)) != nullptr) { + if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) { + continue; + } + auto child_path = path + "/" + entry->d_name; + if (app_fs_is_directory(child_path)) { + out.push_back(child_path); + } + } + + closedir(dir); + file_mutex_unlock(&file_mutex); +} diff --git a/Modules/app-module/private/app/private/app_ledger.h b/Modules/app-module/private/app/private/app_ledger.h index 3699706e6..fdac66287 100644 --- a/Modules/app-module/private/app/private/app_ledger.h +++ b/Modules/app-module/private/app/private/app_ledger.h @@ -5,7 +5,8 @@ #include #include -#include +#include +#include #include #include @@ -16,8 +17,9 @@ struct AppInstanceRecord { uint32_t id; const AppManifest* manifest; AppInstanceState state; - /** The kernel thread currently executing AppLoaderApi::run() for this instance; NULL when not running. */ - Thread* thread; + /** The FreeRTOS task currently executing AppLoaderApi::run() for this instance; NULL when + * not running. */ + TaskHandle_t task; /** 0 for a top-level launch (app_manager_start()). Non-zero for a modal child launched via * app_manager_start_for_result() - the instance that receives this child's APP_EVENT_RESULT. */ @@ -40,7 +42,7 @@ inline AppLedger& app_ledger() { } /** Frees a deep-copied argv previously built by app_manager_start_with_parameters()/ - * app_manager_start_for_result() (see app_scheduler.cpp's ThreadContext::argv) - each + * app_manager_start_for_result() (see app_scheduler.cpp's TaskContext::argv) - each * individually heap-allocated string, then the array itself. Safe to call with count == 0 / * values == nullptr (no-op). */ inline void app_ledger_free_arguments(int count, char** values) { diff --git a/Modules/app-module/private/app/private/app_scheduler.h b/Modules/app-module/private/app/private/app_scheduler.h index e7d8cd3b1..6f9069a77 100644 --- a/Modules/app-module/private/app/private/app_scheduler.h +++ b/Modules/app-module/private/app/private/app_scheduler.h @@ -4,16 +4,14 @@ #include #include -#include -#include #include /** * Owns per-app task lifecycle on behalf of app_manager_*(). AppLoaderApi implementations - * stay task-agnostic; all of thread_alloc_full()/thread_start()/thread_join() happen here. - * Every app instance gets its own dedicated task for its entire lifetime - no task is ever - * reused for a different instance. + * stay task-agnostic; all of xTaskCreate()/vTaskDelete() happens here, as a plain FreeRTOS task + * (not TactilityKernel's Thread wrapper). Every app instance gets its own dedicated task for its + * entire lifetime - no task is ever reused for a different instance. */ #ifdef __cplusplus @@ -30,13 +28,15 @@ extern "C" { * taken by the scheduler regardless of outcome (freed once the spawned task's run() returns, or * immediately on a failure to start it) */ -error_t app_scheduler_start(uint32_t app_instance_id, struct AppLocation location, int argc, char* argv[]); +error_t app_scheduler_start(AppInstanceId app_instance_id, struct AppLocation location, int argc, char* argv[]); /** * Permanently stops an app instance (APP_EVENT_CLOSE if it was running), bound-waits for its * task to exit, and removes it from the ledger. */ -error_t app_scheduler_stop(uint32_t app_instance_id, TickType_t join_timeout); +error_t app_scheduler_stop(AppInstanceId app_instance_id, TickType_t join_timeout); + +// app_scheduler_current_app_id() is public - see app/scheduler.h. #ifdef __cplusplus } diff --git a/Modules/app-module/source/app_install.cpp b/Modules/app-module/source/app_install.cpp index f3ebe74c6..3586a4034 100644 --- a/Modules/app-module/source/app_install.cpp +++ b/Modules/app-module/source/app_install.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -37,19 +38,9 @@ std::string last_path_segment(const std::string& path) { return index == std::string::npos ? path : path.substr(index + 1); } -bool is_directory(const std::string& path) { - struct stat result {}; - return stat(path.c_str(), &result) == 0 && S_ISDIR(result.st_mode); -} - -bool is_file(const std::string& path) { - struct stat result {}; - return stat(path.c_str(), &result) == 0 && S_ISREG(result.st_mode); -} - // mkdir -p. bool ensure_directory(const std::string& path) { - if (path.empty() || is_directory(path)) { + if (path.empty() || app_fs_is_directory(path)) { return true; } @@ -62,7 +53,7 @@ bool ensure_directory(const std::string& path) { return false; } - return is_directory(path); + return app_fs_is_directory(path); } bool ensure_directory_recursive(const std::string& path) { @@ -75,19 +66,27 @@ bool ensure_directory_recursive(const std::string& path) { } bool delete_recursively(const std::string& path) { + LOG_D(TAG, "Deleting %s...", path.c_str()); if (path.empty() || path == "/" || path == "." || path == "..") { return true; } - if (is_directory(path)) { + if (app_fs_is_directory(path)) { + LOG_D(TAG, "Deleting dir %s", path.c_str()); + + FileMutex file_mutex; + file_mutex_get(&file_mutex, path.c_str()); + file_mutex_lock(&file_mutex); + DIR* dir = opendir(path.c_str()); if (dir == nullptr) { LOG_E(TAG, "Failed to scan directory %s", path.c_str()); + file_mutex_unlock(&file_mutex); return false; } bool success = true; - struct dirent* entry; + dirent* entry; while (success && (entry = readdir(dir)) != nullptr) { if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) { continue; @@ -97,18 +96,17 @@ bool delete_recursively(const std::string& path) { closedir(dir); if (!success) { + file_mutex_unlock(&file_mutex); return false; } - FileMutex mutex {}; - file_mutex_get(&mutex, path.c_str()); - file_mutex_lock(&mutex); bool result = rmdir(path.c_str()) == 0; - file_mutex_unlock(&mutex); + file_mutex_unlock(&file_mutex); return result; } - if (is_file(path)) { + if (app_fs_is_file(path)) { + LOG_D(TAG, "Deleting file %s", path.c_str()); FileMutex mutex {}; file_mutex_get(&mutex, path.c_str()); file_mutex_lock(&mutex); @@ -117,7 +115,7 @@ bool delete_recursively(const std::string& path) { return result; } - // Doesn't exist - nothing to do. + LOG_D(TAG, "Deleting done"); return true; } @@ -213,6 +211,35 @@ InstallRegistry& install_registry() { return registry; } +// Registers @a app_dir_path (already confirmed to hold a valid manifest.properties, parsed into +// @a metadata) with app_manager_add(), taking ownership of its id/name/path strings. +// @warning Caller must hold install_registry().mutex, and must have already ensured +// @a metadata.app_id isn't already registered (app_manager_add() rejects duplicates, but the +// InstalledAppRecord for the earlier registration would leak since this always inserts fresh). +error_t register_installed_app_locked(const std::string& app_dir_path, const AppMetadata& metadata) { + auto& registry = install_registry(); + + auto record = std::make_unique(); + record->id = metadata.app_id; + record->name = metadata.app_name; + record->path = app_dir_path; + record->manifest = AppManifest { + .id = record->id.c_str(), + .name = record->name.c_str(), + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_PATH, const_cast(record->path.c_str()) }, + .flags = 0, + }; + + error_t add_result = app_manager_add(&record->manifest); + if (add_result != ERROR_NONE) { + return add_result; + } + + registry.apps[record->id] = std::move(record); + return ERROR_NONE; +} + // Stops every currently-running instance of @a manifest. Collects matching instance ids while // holding the ledger lock, then calls app_manager_stop() on each after releasing it - that call // bound-joins the instance's thread, which must not happen while the ledger mutex (also taken by @@ -312,7 +339,7 @@ error_t app_install(const char* source_path) { } auto manifest_path = staging_path + "/manifest.properties"; - if (!is_file(manifest_path)) { + if (!app_fs_is_file(manifest_path)) { LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str()); delete_recursively(staging_path); return ERROR_INVALID_ARGUMENT; @@ -320,7 +347,7 @@ error_t app_install(const char* source_path) { AppMetadata metadata {}; if (app_metadata_parse(manifest_path.c_str(), &metadata) != ERROR_NONE) { - LOG_W(TAG, "Invalid manifest"); + LOG_E(TAG, "Install failed: invalid manifest"); delete_recursively(staging_path); return ERROR_INVALID_ARGUMENT; } @@ -329,8 +356,18 @@ error_t app_install(const char* source_path) { mutex_lock(®istry.mutex); // Replace any previous install of this app id (mirrors the old install()'s "already - // running/present" handling). + // running/present" handling). uninstall_locked() only clears app_install.cpp's own + // registry - the same app id may instead be registered by app_manager_install_path_scan() + // (manager.cpp's separate registry, scanning this same directory tree), which + // uninstall_locked() doesn't know about. Clear the app-manager registration unconditionally + // too, or app_manager_add() below rejects the re-add as a duplicate. uninstall_locked(metadata.app_id); + if (app_manager_remove(metadata.app_id) != ERROR_NONE) { + LOG_E(TAG, "Install failed: failed to remove existing installation"); + mutex_unlock(®istry.mutex); + delete_recursively(staging_path); + return ERROR_RESOURCE; + } auto final_path = app_parent_path + "/" + metadata.app_id; delete_recursively(final_path); @@ -346,30 +383,12 @@ error_t app_install(const char* source_path) { return ERROR_NOT_FOUND; } - auto record = std::make_unique(); - record->id = metadata.app_id; - record->name = metadata.app_name; - record->path = final_path; - record->manifest = AppManifest { - .id = record->id.c_str(), - .name = record->name.c_str(), - .category = APP_CATEGORY_USER, - .location = { APP_LOCATION_PATH, const_cast(record->path.c_str()) }, - .flags = 0, - }; - - error_t add_result = app_manager_add(&record->manifest); - if (add_result != ERROR_NONE) { - // Only remaining failure mode is a duplicate id - can't happen, uninstall_locked() above - // already removed any previous registration for this exact id. - mutex_unlock(®istry.mutex); - return add_result; - } - - registry.apps[record->id] = std::move(record); + // Only remaining failure mode is a duplicate id - can't happen, uninstall_locked() above + // already removed any previous registration for this exact id. + error_t add_result = register_installed_app_locked(final_path, metadata); mutex_unlock(®istry.mutex); - return ERROR_NONE; + return add_result; } error_t app_uninstall(const char* app_id) { diff --git a/Modules/app-module/source/app_metadata_parsing.cpp b/Modules/app-module/source/app_metadata_parsing.cpp index 79849f44e..208f7ea99 100644 --- a/Modules/app-module/source/app_metadata_parsing.cpp +++ b/Modules/app-module/source/app_metadata_parsing.cpp @@ -57,15 +57,16 @@ bool load_properties(const std::string& path, std::map bool got_first_line = false; while (std::getline(file, line)) { auto trimmed_line = trim(line); - if (!got_first_line) { - out_first_line = trimmed_line; - got_first_line = true; - } if (trimmed_line.empty() || trimmed_line.starts_with("#")) { continue; } + if (!got_first_line) { + out_first_line = trimmed_line; + got_first_line = true; + } + if (trimmed_line.starts_with("[")) { section_prefix = trimmed_line; continue; @@ -123,7 +124,8 @@ bool app_metadata_is_valid_version_name(const std::string& version) { } bool app_metadata_is_valid_version_code(const std::string& version) { - return !version.empty() && validate_string(version, [](char c) { + // 20 digits is the maximum decimal width of uint64_t. + return !version.empty() && version.size() <= 20 && validate_string(version, [](char c) { return std::isdigit(static_cast(c)) != 0; }); } diff --git a/Modules/app-module/source/app_metadata_parsing_v1.cpp b/Modules/app-module/source/app_metadata_parsing_v1.cpp index 1f8d8c5f9..ae759a514 100644 --- a/Modules/app-module/source/app_metadata_parsing_v1.cpp +++ b/Modules/app-module/source/app_metadata_parsing_v1.cpp @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 #include - #include +#include + #include constexpr auto* TAG = "app_metadata_v1"; @@ -77,9 +78,14 @@ bool app_metadata_parse_v1(const std::map& properties, return false; } - out_metadata.app_version_code = std::stoull(version_code_string); - - // [target] + uint64_t version_code = 0; + const auto* first = version_code_string.data(); + const auto* last = first + version_code_string.size(); + if (std::from_chars(first, last, version_code).ec != std::errc {}) { + LOG_E(TAG, "App version code out of range"); + return false; + } + out_metadata.app_version_code = version_code; // [target] std::string target_sdk; if (!app_metadata_get_value(properties, "[target]sdk", target_sdk)) { diff --git a/Modules/app-module/source/app_metadata_parsing_v2.cpp b/Modules/app-module/source/app_metadata_parsing_v2.cpp index a5facdd2e..fe7a58991 100644 --- a/Modules/app-module/source/app_metadata_parsing_v2.cpp +++ b/Modules/app-module/source/app_metadata_parsing_v2.cpp @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 #include - #include +#include + #include constexpr auto* TAG = "app_metadata_v2"; @@ -77,7 +78,14 @@ bool app_metadata_parse_v2(const std::map& properties, return false; } - out_metadata.app_version_code = std::stoull(version_code_string); + uint64_t version_code = 0; + const auto* first = version_code_string.data(); + const auto* last = first + version_code_string.size(); + if (std::from_chars(first, last, version_code).ec != std::errc {}) { + LOG_E(TAG, "App version code out of range"); + return false; + } + out_metadata.app_version_code = version_code; // [target] // target diff --git a/Modules/app-module/source/app_scheduler.cpp b/Modules/app-module/source/app_scheduler.cpp index 0c5f711ea..91f3d94b8 100644 --- a/Modules/app-module/source/app_scheduler.cpp +++ b/Modules/app-module/source/app_scheduler.cpp @@ -1,31 +1,45 @@ // SPDX-License-Identifier: Apache-2.0 #include #include - #include +#include #include +#include #include #include -#include +#include +#include #include +#include +#include +#include #include -#define TAG "app_scheduler" +constexpr auto* TAG = "app_scheduler"; + +// Slot 0 is reserved by ESP-IDF's pthread API (see TactilityKernel's Thread wrapper for the +// same convention/comment) - app tasks use slot 1 to stash their own app_instance_id, so any +// code running on an app's own task can retrieve it via app_scheduler_current_app_id() without +// needing it threaded through as a parameter. +constexpr size_t APP_INSTANCE_ID_THREAD_SLOT_INDEX = 1; + +// Matches TactilityKernel's Thread wrapper's THREAD_PRIORITY_NORMAL. +constexpr UBaseType_t APP_TASK_PRIORITY = 4; namespace { -struct ThreadContext { +struct TaskContext { const AppLoaderApi* loader; void* runtime; - uint32_t app_instance_id; + AppInstanceId app_instance_id; int argc; char** argv; }; -void set_state(uint32_t app_instance_id, AppInstanceState state) { +void set_state(AppInstanceId app_instance_id, AppInstanceState state) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); auto iterator = ledger.instances.find(app_instance_id); @@ -35,21 +49,21 @@ void set_state(uint32_t app_instance_id, AppInstanceState state) { mutex_unlock(&ledger.mutex); } -Thread* get_thread(uint32_t app_instance_id) { +TaskHandle_t get_task(AppInstanceId app_instance_id) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); auto iterator = ledger.instances.find(app_instance_id); - Thread* thread = (iterator != ledger.instances.end()) ? iterator->second.thread : nullptr; + TaskHandle_t task = (iterator != ledger.instances.end()) ? iterator->second.task : nullptr; mutex_unlock(&ledger.mutex); - return thread; + return task; } -void set_thread(uint32_t app_instance_id, Thread* thread) { +void set_task(AppInstanceId app_instance_id, TaskHandle_t task) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); auto iterator = ledger.instances.find(app_instance_id); if (iterator != ledger.instances.end()) { - iterator->second.thread = thread; + iterator->second.task = task; } mutex_unlock(&ledger.mutex); } @@ -69,10 +83,10 @@ const AppLoaderApi* find_loader_api(AppLocationType type) { // If this instance was launched via app_manager_start_for_result(), delivers @a result (its // own AppMainFn/AppLoaderApi::run() return value) to its parent. No-op for a top-level instance // (parent_id == 0). -void deliver_result_to_parent_if_any(uint32_t app_instance_id, int32_t result) { +void deliver_result_to_parent_if_any(AppInstanceId app_instance_id, int32_t result) { auto& ledger = app_ledger(); - uint32_t parent_id; + AppInstanceId parent_id; AppEvent event { .type = APP_EVENT_RESULT, .timestamp = 0, .result = {} }; mutex_lock(&ledger.mutex); @@ -91,13 +105,20 @@ void deliver_result_to_parent_if_any(uint32_t app_instance_id, int32_t result) { } } -int32_t thread_main(void* context) { - auto* ctx = static_cast(context); +void app_task_main(void* context) { + auto* ctx = static_cast(context); + + check(pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX) == nullptr); + vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, reinterpret_cast(static_cast(ctx->app_instance_id))); + + LOG_I(TAG, "Thread for %d started", ctx->app_instance_id); set_state(ctx->app_instance_id, APP_INSTANCE_STATE_ACTIVE); int32_t result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, ctx->argc, ctx->argv); + vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, nullptr); + ctx->loader->unload(ctx->runtime); deliver_result_to_parent_if_any(ctx->app_instance_id, result); @@ -108,15 +129,26 @@ int32_t thread_main(void* context) { set_state(ctx->app_instance_id, APP_INSTANCE_STATE_STOPPED); app_ledger_free_arguments(ctx->argc, ctx->argv); + + AppInstanceId app_instance_id = ctx->app_instance_id; delete ctx; - return result; + + LOG_I(TAG, "Thread for %d finished", app_instance_id); + + // Erase the ledger entry before self-deleting + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + ledger.instances.erase(app_instance_id); + mutex_unlock(&ledger.mutex); + + vTaskDelete(nullptr); } } // namespace extern "C" { -error_t app_scheduler_start(uint32_t app_instance_id, AppLocation location, int argc, char* argv[]) { +error_t app_scheduler_start(AppInstanceId app_instance_id, AppLocation location, int argc, char* argv[]) { const AppLoaderApi* loader = find_loader_api(location.type); if (loader == nullptr) { LOG_E(TAG, "No app loader is registered (service '%s' not found)", loader_service_id_for(location.type)); @@ -127,54 +159,54 @@ error_t app_scheduler_start(uint32_t app_instance_id, AppLocation location, int void* runtime = nullptr; error_t load_result = loader->load(location, &runtime); if (load_result != ERROR_NONE) { + LOG_E(TAG, "Failed to load app: %s", error_to_string(load_result)); app_ledger_free_arguments(argc, argv); return load_result; } - auto* context = new (std::nothrow) ThreadContext { loader, runtime, app_instance_id, argc, argv }; + auto* context = new (std::nothrow) TaskContext { loader, runtime, app_instance_id, argc, argv }; if (context == nullptr) { + LOG_E(TAG, "Failed to allocate app"); loader->unload(runtime); app_ledger_free_arguments(argc, argv); return ERROR_OUT_OF_MEMORY; } - // -1 (no affinity) matches the FreeRTOS POSIX/simulator port; ESP-IDF's tskNO_AFFINITY is - // a numerically equivalent SMP-only constant not available in the plain FreeRTOS-Kernel port. - Thread* thread = thread_alloc_full("app", 8192, thread_main, context, -1); - if (thread == nullptr) { + char task_name[16]; + snprintf(task_name, sizeof(task_name), "app_%lu", static_cast(app_instance_id)); + + TaskHandle_t task_handle = nullptr; + // 8192 bytes -> stack depth in words, matching what TactilityKernel's Thread wrapper does + // with the stack size it's given. + BaseType_t create_result = xTaskCreate(app_task_main, task_name, 8192 / sizeof(StackType_t), context, APP_TASK_PRIORITY, &task_handle); + if (create_result != pdPASS) { delete context; loader->unload(runtime); app_ledger_free_arguments(argc, argv); return ERROR_OUT_OF_MEMORY; } - set_thread(app_instance_id, thread); - - error_t start_result = thread_start(thread); - if (start_result != ERROR_NONE) { - set_thread(app_instance_id, nullptr); - thread_free(thread); - delete context; - loader->unload(runtime); - app_ledger_free_arguments(argc, argv); - return start_result; - } + set_task(app_instance_id, task_handle); return ERROR_NONE; } -error_t app_scheduler_stop(uint32_t app_instance_id, TickType_t join_timeout) { - Thread* thread = get_thread(app_instance_id); - if (thread != nullptr) { +error_t app_scheduler_stop(AppInstanceId app_instance_id, TickType_t join_timeout) { + TaskHandle_t task = get_task(app_instance_id); + if (task != nullptr) { AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; app_event_emit(app_instance_id, &event); - if (thread_join(thread, join_timeout, pdMS_TO_TICKS(10)) != ERROR_NONE) { - LOG_W(TAG, "App instance %u did not stop in time", app_instance_id); - return ERROR_TIMEOUT; + // Poll for the task to clear its own ledger entry (see app_task_main()) - plain + // FreeRTOS has no built-in task-join primitive. + TickType_t start_ticks = get_ticks(); + while (get_task(app_instance_id) != nullptr) { + delay_ticks(pdMS_TO_TICKS(10)); + if (get_ticks() - start_ticks > join_timeout) { + LOG_W(TAG, "App instance %u did not stop in time", app_instance_id); + return ERROR_TIMEOUT; + } } - thread_free(thread); - set_thread(app_instance_id, nullptr); } set_state(app_instance_id, APP_INSTANCE_STATE_STOPPED); @@ -187,4 +219,9 @@ error_t app_scheduler_stop(uint32_t app_instance_id, TickType_t join_timeout) { return ERROR_NONE; } +AppInstanceId app_scheduler_current_app_id(void) { + void* value = pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX); + return reinterpret_cast(value); +} + } // extern "C" diff --git a/Modules/app-module/source/manager.cpp b/Modules/app-module/source/manager.cpp index af26a60e7..3c13821e7 100644 --- a/Modules/app-module/source/manager.cpp +++ b/Modules/app-module/source/manager.cpp @@ -1,12 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include + +#include #include #include +#include #include +#include #include +#include +#include +#include #define TAG "app_manager" @@ -197,3 +205,113 @@ error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size) { } } // extern "C" + +namespace { + +// Owns the AppManifest (and its id/name/path strings) that app_manager_add() only keeps a +// non-owning pointer to (see app_manager_add()'s contract), for manifests registered by +// app_manager_install_path_scan() specifically - separate from app_install.cpp's own registry, +// since scanning only ever adds/removes manifest registrations and never touches files on disk +// or running instances (unlike app_install()/app_uninstall()). +struct ScannedAppManifest { + std::string id; + std::string name; + std::string path; + AppManifest manifest {}; +}; + +struct InstallPathRegistry { + std::vector paths; + std::unordered_map> scanned; + Mutex mutex {}; + + InstallPathRegistry() { mutex_construct(&mutex); } +}; + +InstallPathRegistry& install_path_registry() { + static InstallPathRegistry registry; + return registry; +} + +} // namespace + +extern "C" { + +error_t app_manager_install_path_add(const char* path) { + auto& registry = install_path_registry(); + mutex_lock(®istry.mutex); + if (std::ranges::find(registry.paths, path) == registry.paths.end()) { + registry.paths.emplace_back(path); + } + mutex_unlock(®istry.mutex); + return ERROR_NONE; +} + +void app_manager_install_path_scan(void) { + auto& registry = install_path_registry(); + + mutex_lock(®istry.mutex); + auto paths_copy = registry.paths; + mutex_unlock(®istry.mutex); + + std::vector found_app_dirs; + for (const auto& root : paths_copy) { + app_fs_list_direct_subdirectories(root, found_app_dirs); + } + + mutex_lock(®istry.mutex); + + for (const auto& app_dir : found_app_dirs) { + auto manifest_path = app_dir + "/manifest.properties"; + if (!app_fs_is_file(manifest_path)) { + continue; + } + + AppMetadata metadata {}; + if (app_metadata_parse(manifest_path.c_str(), &metadata) != ERROR_NONE) { + LOG_W(TAG, "Invalid manifest at %s", manifest_path.c_str()); + continue; + } + + if (registry.scanned.contains(metadata.app_id)) { + continue; // already registered by an earlier scan + } + + auto record = std::make_unique(); + record->id = metadata.app_id; + record->name = metadata.app_name; + record->path = app_dir; + record->manifest = AppManifest { + .id = record->id.c_str(), + .name = record->name.c_str(), + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_PATH, const_cast(record->path.c_str()) }, + .flags = 0, + }; + + if (app_manager_add(&record->manifest) != ERROR_NONE) { + LOG_E(TAG, "Failed to register app %s (duplicate id?)", record->id.c_str()); + continue; + } + + registry.scanned[record->id] = std::move(record); + } + + // Anything a previous scan registered whose directory has since disappeared (e.g. an SD + // card was removed) just gets unregistered - no file deletion, no touching running + // instances, that's app_install()/app_uninstall()'s job, not scanning's. + std::vector missing_ids; + for (const auto& [id, record] : registry.scanned) { + if (!app_fs_is_directory(record->path)) { + missing_ids.push_back(id); + } + } + for (const auto& id : missing_ids) { + app_manager_remove(id.c_str()); + registry.scanned.erase(id); + } + + mutex_unlock(®istry.mutex); +} + +} // extern "C" diff --git a/Modules/app-module/source/module.cpp b/Modules/app-module/source/module.cpp deleted file mode 100644 index f4811265f..000000000 --- a/Modules/app-module/source/module.cpp +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#include - -#include - -#include -#include - -extern "C" { - -extern ServiceManifest app_internal_loader_service_manifest; - -static error_t start() { - return service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true); -} - -static error_t stop() { - return service_manager_remove(app_internal_loader_service_manifest.id); -} - -Module app_module = { - .name = "app", - .start = start, - .stop = stop, - .drivers = nullptr, - .symbols = nullptr, - .internal = nullptr -}; - -} diff --git a/Modules/app-module/source/symbols.cpp b/Modules/app-module/source/symbols.cpp new file mode 100644 index 000000000..7a1b38a3a --- /dev/null +++ b/Modules/app-module/source/symbols.cpp @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include +#include + +#include + +#include +#include + +extern "C" { + +extern ServiceManifest app_internal_loader_service_manifest; + +const ModuleSymbol app_module_symbols[] = { + // app/scheduler + DEFINE_MODULE_SYMBOL(app_scheduler_current_app_id), + // app/event + DEFINE_MODULE_SYMBOL(app_event_subscribe), + DEFINE_MODULE_SYMBOL(app_event_unsubscribe), + DEFINE_MODULE_SYMBOL(app_event_emit), + DEFINE_MODULE_SYMBOL(app_event_await), + // app/manager + DEFINE_MODULE_SYMBOL(app_manager_start), + DEFINE_MODULE_SYMBOL(app_manager_start_with_parameters), + DEFINE_MODULE_SYMBOL(app_manager_start_for_result), + DEFINE_MODULE_SYMBOL(app_manager_stop), + DEFINE_MODULE_SYMBOL(app_manager_finish), + DEFINE_MODULE_SYMBOL(app_manager_get_state), + DEFINE_MODULE_SYMBOL(app_manager_find_manifest), + DEFINE_MODULE_SYMBOL(app_manager_for_each_manifest), + // terminator + MODULE_SYMBOL_TERMINATOR +}; + +static error_t start() { + return service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true); +} + +static error_t stop() { + return service_manager_remove(app_internal_loader_service_manifest.id); +} + +Module app_module = { + .name = "app", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = app_module_symbols, + .internal = nullptr +}; + +} diff --git a/Modules/lvgl-window-manager/CMakeLists.txt b/Modules/lvgl-window-manager-module/CMakeLists.txt similarity index 83% rename from Modules/lvgl-window-manager/CMakeLists.txt rename to Modules/lvgl-window-manager-module/CMakeLists.txt index 7067fcdb4..d8e518d56 100644 --- a/Modules/lvgl-window-manager/CMakeLists.txt +++ b/Modules/lvgl-window-manager-module/CMakeLists.txt @@ -4,7 +4,7 @@ include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") file(GLOB_RECURSE SOURCE_FILES "source/*.c*") -tactility_add_module(lvgl-window-manager +tactility_add_module(lvgl-window-manager-module SRCS ${SOURCE_FILES} INCLUDE_DIRS include/ REQUIRES TactilityKernel lvgl-module diff --git a/Modules/lvgl-window-manager/devicetree.yaml b/Modules/lvgl-window-manager-module/devicetree.yaml similarity index 100% rename from Modules/lvgl-window-manager/devicetree.yaml rename to Modules/lvgl-window-manager-module/devicetree.yaml diff --git a/Modules/lvgl-window-manager/include/lvgl_window_manager/module.h b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/module.h similarity index 100% rename from Modules/lvgl-window-manager/include/lvgl_window_manager/module.h rename to Modules/lvgl-window-manager-module/include/lvgl_window_manager/module.h diff --git a/Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h similarity index 100% rename from Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h rename to Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h diff --git a/Modules/lvgl-window-manager/source/module.cpp b/Modules/lvgl-window-manager-module/source/symbols.cpp similarity index 51% rename from Modules/lvgl-window-manager/source/module.cpp rename to Modules/lvgl-window-manager-module/source/symbols.cpp index b93d5010f..b41acce2a 100644 --- a/Modules/lvgl-window-manager/source/module.cpp +++ b/Modules/lvgl-window-manager-module/source/symbols.cpp @@ -7,12 +7,21 @@ extern "C" { +const ModuleSymbol lvgl_window_manager_module_symbols[] = { + DEFINE_MODULE_SYMBOL(window_manager_create), + DEFINE_MODULE_SYMBOL(window_manager_remove), + DEFINE_MODULE_SYMBOL(window_manager_get_state), + DEFINE_MODULE_SYMBOL(window_manager_await_state_change), + // terminator + MODULE_SYMBOL_TERMINATOR +}; + Module lvgl_window_manager_module = { .name = "lvgl-window-manager", .start = window_manager_start, .stop = window_manager_stop, .drivers = nullptr, - .symbols = nullptr, + .symbols = lvgl_window_manager_module_symbols, .internal = nullptr }; diff --git a/Modules/lvgl-window-manager/source/window_manager.cpp b/Modules/lvgl-window-manager-module/source/window_manager.cpp similarity index 74% rename from Modules/lvgl-window-manager/source/window_manager.cpp rename to Modules/lvgl-window-manager-module/source/window_manager.cpp index c2d1e91b3..dda814abe 100644 --- a/Modules/lvgl-window-manager/source/window_manager.cpp +++ b/Modules/lvgl-window-manager-module/source/window_manager.cpp @@ -18,8 +18,17 @@ struct WindowRecord { }; struct WindowManagerState { + /** Mutex for read/write operations. Shortly held. */ Mutex mutex {}; + /** Serializes the full start()/stop() transition (including the LVGL work done with + * `mutex` released) so two concurrent starts can't both pass the `started` check and each + * create their own root widget, and a concurrent stop can't run while a start is still + * mid-flight. Never held across a create_widgets()/screen_init() callback - those only + * reach window_manager_create()/remove(), not start()/stop() - so there's no lock-order + * risk with `mutex` or the LVGL lock. */ + Mutex lifecycle_mutex {}; + bool started = false; WindowManagerScreenInitFn screen_init = nullptr; @@ -38,7 +47,10 @@ struct WindowManagerState { /** Task blocked in window_manager_await_state_change(), if any. */ TaskHandle_t waiting_task = nullptr; - WindowManagerState() { mutex_construct(&mutex); } + WindowManagerState() { + mutex_construct(&mutex); + mutex_construct(&lifecycle_mutex); + } }; WindowManagerState& state() { @@ -86,9 +98,16 @@ void window_manager_configure(WindowManagerScreenInitFn screen_init) { error_t window_manager_start(void) { auto& s = state(); + // Held for the whole transition (including the LVGL work below, done with `mutex` + // released) - blocks a concurrent start() from also passing the `started` check and + // building its own root widget, and blocks a concurrent stop() from running while this + // start is still mid-flight. + mutex_lock(&s.lifecycle_mutex); + mutex_lock(&s.mutex); if (s.started) { mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); return ERROR_NONE; } WindowManagerScreenInitFn screen_init = s.screen_init; @@ -114,6 +133,7 @@ error_t window_manager_start(void) { lvgl_unlock(); if (real_widget == nullptr) { + mutex_unlock(&s.lifecycle_mutex); return ERROR_RESOURCE; } @@ -123,15 +143,21 @@ error_t window_manager_start(void) { s.started = true; mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); return ERROR_NONE; } error_t window_manager_stop(void) { auto& s = state(); + // See window_manager_start() - blocks until any in-flight start() has fully completed (or + // failed) before this stop can observe/tear down state. + mutex_lock(&s.lifecycle_mutex); + mutex_lock(&s.mutex); if (!s.started) { mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); return ERROR_NONE; } lv_obj_t* widget = s.real_root_widget; @@ -151,6 +177,7 @@ error_t window_manager_stop(void) { // Deleting the real widget cascades to everything under it - chrome and top_widget alike. delete_widget(widget); + mutex_unlock(&s.lifecycle_mutex); return ERROR_NONE; } @@ -212,6 +239,7 @@ void window_manager_remove(WindowId id) { void* next_user_data = nullptr; WindowId next_id = 0; bool has_next = false; + TaskHandle_t waiter = nullptr; if (was_topmost) { old_widget = s.top_widget; @@ -222,10 +250,12 @@ void window_manager_remove(WindowId id) { next_id = s.windows.back().id; has_next = true; } + // Only the topmost window's state can actually change here - a waiter blocked in + // window_manager_await_state_change() is always waiting on the current top window (see + // that function), so removing a buried window never affects what it's waiting for. + waiter = s.waiting_task; + s.waiting_task = nullptr; } - - TaskHandle_t waiter = s.waiting_task; - s.waiting_task = nullptr; mutex_unlock(&s.mutex); if (waiter != nullptr) { @@ -273,6 +303,21 @@ WindowState window_manager_await_state_change(WindowId id, TickType_t timeout) { ulTaskNotifyTake(pdTRUE, timeout); + /* Deregister ourselves if a create()/remove() hasn't already claimed us (the ordinary, intended wakeup) + * Otherwise a later create()/remove() could notify a task that's no longer waiting here: + * a use-after-exit on the handle if this task is gone, or a stale wakeup the next time it waits. */ + mutex_lock(&s.mutex); + if (s.waiting_task == xTaskGetCurrentTaskHandle()) { + s.waiting_task = nullptr; + } + mutex_unlock(&s.mutex); + + /* create()/remove() read+clear `waiting_task` under the lock but call xTaskNotifyGive() + * after releasing it, so a notification can still land on us right around the timeout + * boundary regardless of which branch above ran. Drain it now (non-blocking) so it doesn't + * linger and cause a spurious immediate return the next time this task awaits. */ + ulTaskNotifyTake(pdTRUE, 0); + return window_manager_get_state(id); } diff --git a/TactilityKernel/include/tactility/system_event.h b/TactilityKernel/include/tactility/system_event.h index 5b0b82c15..9b4cb967b 100644 --- a/TactilityKernel/include/tactility/system_event.h +++ b/TactilityKernel/include/tactility/system_event.h @@ -6,6 +6,7 @@ #include #include +#include #include #ifdef __cplusplus @@ -143,7 +144,11 @@ struct SystemEventSubscription { /** Event type to subscribe to; set by the caller before system_event_subscribe(). */ enum SystemEventType type; - TaskHandle_t task; + /** Own wakeup signal, not the subscribing task's shared default notification value - a + * task with more than one poll subscription would otherwise have events for one + * subscription wake (and consume the notification meant for) system_event_await() calls + * on another. */ + SemaphoreHandle_t semaphore; uint64_t timestamp; uint8_t data[SYSTEM_EVENT_MAX_DATA_SIZE]; @@ -160,7 +165,10 @@ struct SystemEventSubscription { * @warning Does not work in ISR context. * @param[in,out] sub subscription to register; caller sets @a sub->type beforehand, owns the * storage, and must keep it alive (and stationary) until unsubscribed - * @return ERROR_NONE on success + * @retval ERROR_NONE on success + * @retval ERROR_OUT_OF_MEMORY failed to allocate the subscription's wakeup semaphore; @a sub + * was not registered + * @retval ERROR_INVALID_STATE @a sub is already registered */ error_t system_event_subscribe(struct SystemEventSubscription* sub); diff --git a/TactilityKernel/source/system_event.cpp b/TactilityKernel/source/system_event.cpp index ae649d2f0..679d5e3e1 100644 --- a/TactilityKernel/source/system_event.cpp +++ b/TactilityKernel/source/system_event.cpp @@ -67,9 +67,9 @@ error_t system_event_callback_remove( return result; } -// Copies `data` into every current poll subscriber of `type` and wakes its waiting task. +// Copies `data` into every current poll subscriber of `type` and signals its wakeup semaphore. // Held entirely under the lock: unlike the callback path, this never invokes caller code -// (just a memcpy and an xTaskNotifyGive), so there is nothing that could reenter and deadlock. +// (just a memcpy and a semaphore give), so there is nothing that could reenter and deadlock. static void notify_poll_subscribers( SystemEventType type, uint64_t timestamp, @@ -81,12 +81,13 @@ static void notify_poll_subscribers( for (SystemEventSubscription* sub = poll_subscriptions; sub != nullptr; sub = sub->next) { if (sub->type == type) { sub->timestamp = timestamp; - if (data_len > 0) { - std::memcpy(sub->data, data, std::min(data_len, static_cast(SYSTEM_EVENT_MAX_DATA_SIZE))); + const size_t copied_len = std::min(data_len, SYSTEM_EVENT_MAX_DATA_SIZE); + if (copied_len > 0) { + std::memcpy(sub->data, data, copied_len); } - sub->data_len = data_len; + sub->data_len = copied_len; sub->sequence++; - xTaskNotifyGive(sub->task); + xSemaphoreGive(sub->semaphore); } } @@ -162,14 +163,31 @@ error_t system_event_emit( } error_t system_event_subscribe(SystemEventSubscription* sub) { - sub->task = xTaskGetCurrentTaskHandle(); + SemaphoreHandle_t semaphore = xSemaphoreCreateBinary(); + if (semaphore == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + + mutex_lock(&poll_subscriptions_mutex.handle); + + // Check-and-insert in one critical section: registering the same `sub` twice would link + // it into a list that already contains it, creating a cycle that notify_poll_subscribers() + // would then traverse forever while holding this same mutex. + for (SystemEventSubscription* existing = poll_subscriptions; existing != nullptr; existing = existing->next) { + if (existing == sub) { + mutex_unlock(&poll_subscriptions_mutex.handle); + vSemaphoreDelete(semaphore); + return ERROR_INVALID_STATE; + } + } + + sub->semaphore = semaphore; sub->sequence = 0; sub->consumed_sequence = 0; sub->data_len = 0; - - mutex_lock(&poll_subscriptions_mutex.handle); sub->next = poll_subscriptions; poll_subscriptions = sub; + mutex_unlock(&poll_subscriptions_mutex.handle); return ERROR_NONE; @@ -188,6 +206,13 @@ error_t system_event_unsubscribe(SystemEventSubscription* sub) { } mutex_unlock(&poll_subscriptions_mutex.handle); + if (result == ERROR_NONE) { + // Unlinked first, so notify_poll_subscribers() can no longer reach this semaphore + // before it's deleted. + vSemaphoreDelete(sub->semaphore); + sub->semaphore = nullptr; + } + return result; } @@ -195,7 +220,7 @@ error_t system_event_await(SystemEventSubscription* sub, TickType_t timeout) { uint32_t old_sequence = sub->sequence; while (sub->sequence == old_sequence) { - if (ulTaskNotifyTake(pdTRUE, timeout) == 0) { + if (xSemaphoreTake(sub->semaphore, timeout) == pdFALSE) { return ERROR_TIMEOUT; } } diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index e11ad29d8..56ca74268 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -8,6 +8,7 @@ add_subdirectory(TactilityFreeRtos) add_subdirectory(TactilityKernel) add_subdirectory(Tactility) add_subdirectory(crypt-module) +add_subdirectory(app-module) add_custom_target(build-tests) add_dependencies(build-tests ServiceModuleTests) @@ -15,3 +16,4 @@ add_dependencies(build-tests TactilityFreeRtosTests) add_dependencies(build-tests TactilityTests) add_dependencies(build-tests TactilityKernelTests) add_dependencies(build-tests CryptModuleTests) +add_dependencies(build-tests AppModuleTests) diff --git a/Tests/Tactility/CMakeLists.txt b/Tests/Tactility/CMakeLists.txt index db87316c0..8d1377531 100644 --- a/Tests/Tactility/CMakeLists.txt +++ b/Tests/Tactility/CMakeLists.txt @@ -15,6 +15,8 @@ target_link_libraries(TactilityTests PRIVATE TactilityKernel platform-posix lvgl-module + lvgl-window-manager-module + app-module crypt-module gps-module gps-generic-module diff --git a/Tests/app-module/CMakeLists.txt b/Tests/app-module/CMakeLists.txt new file mode 100644 index 000000000..b58e0e0b7 --- /dev/null +++ b/Tests/app-module/CMakeLists.txt @@ -0,0 +1,19 @@ +project(AppModuleTests) + +enable_language(C CXX ASM) + + +file(GLOB_RECURSE TEST_SOURCES ${PROJECT_SOURCE_DIR}/Source/*.cpp) +add_executable(AppModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES}) + +target_include_directories(AppModuleTests PRIVATE ${DOCTESTINC}) + +add_test(NAME AppModuleTests COMMAND AppModuleTests) + +target_link_libraries(AppModuleTests PUBLIC + TactilityKernel + app-module + service-module + platform-posix + freertos_kernel +) diff --git a/Tests/app-module/Source/AppEventTest.cpp b/Tests/app-module/Source/AppEventTest.cpp new file mode 100644 index 000000000..e12a51296 --- /dev/null +++ b/Tests/app-module/Source/AppEventTest.cpp @@ -0,0 +1,137 @@ +#include "doctest.h" + +#include + +#include +#include +#include + +TEST_CASE("app_event_subscribe/_await deliver events in FIFO order") { + AppEventSubscription sub {}; + sub.app_instance_id = 1; + CHECK_EQ(app_event_subscribe(&sub), ERROR_NONE); + + for (uint32_t i = 0; i < 3; i++) { + AppEvent event { .type = APP_EVENT_RESULT, .timestamp = 0, .result = { .launch_id = i, .result = 0 } }; + CHECK_EQ(app_event_emit(1, &event), ERROR_NONE); + } + + for (uint32_t i = 0; i < 3; i++) { + AppEvent out {}; + CHECK_EQ(app_event_await(&sub, &out, 0), ERROR_NONE); + CHECK_EQ(out.type, APP_EVENT_RESULT); + CHECK_EQ(out.result.launch_id, i); + } + + app_event_unsubscribe(&sub); +} + +TEST_CASE("app_event_emit only delivers to subscriptions for that app_instance_id") { + AppEventSubscription sub {}; + sub.app_instance_id = 10; + app_event_subscribe(&sub); + + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + CHECK_EQ(app_event_emit(11, &event), ERROR_NOT_FOUND); + + AppEvent out {}; + CHECK_EQ(app_event_await(&sub, &out, 0), ERROR_TIMEOUT); + + app_event_unsubscribe(&sub); +} + +TEST_CASE("app_event_emit returns ERROR_RESOURCE and drops the newest event once a subscription's queue is full") { + AppEventSubscription sub {}; + sub.app_instance_id = 20; + app_event_subscribe(&sub); + + for (uint32_t i = 0; i < APP_EVENT_QUEUE_CAPACITY; i++) { + AppEvent event { .type = APP_EVENT_RESULT, .timestamp = 0, .result = { .launch_id = i, .result = 0 } }; + CHECK_EQ(app_event_emit(20, &event), ERROR_NONE); + } + + // Queue is now full; this one should be dropped. + AppEvent overflow_event { .type = APP_EVENT_RESULT, .timestamp = 0, .result = { .launch_id = 999, .result = 0 } }; + CHECK_EQ(app_event_emit(20, &overflow_event), ERROR_RESOURCE); + + // The already-queued events survive, in order, and the dropped one never arrives. + for (uint32_t i = 0; i < APP_EVENT_QUEUE_CAPACITY; i++) { + AppEvent out {}; + CHECK_EQ(app_event_await(&sub, &out, 0), ERROR_NONE); + CHECK_EQ(out.result.launch_id, i); + } + AppEvent out {}; + CHECK_EQ(app_event_await(&sub, &out, 0), ERROR_TIMEOUT); + + app_event_unsubscribe(&sub); +} + +TEST_CASE("app_event_unsubscribe stops further delivery") { + AppEventSubscription sub {}; + sub.app_instance_id = 30; + app_event_subscribe(&sub); + + CHECK_EQ(app_event_unsubscribe(&sub), ERROR_NONE); + CHECK_EQ(app_event_unsubscribe(&sub), ERROR_NOT_FOUND); + + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + CHECK_EQ(app_event_emit(30, &event), ERROR_NOT_FOUND); +} + +TEST_CASE("app_event_await times out when no event has arrived") { + AppEventSubscription sub {}; + sub.app_instance_id = 40; + app_event_subscribe(&sub); + + AppEvent out {}; + CHECK_EQ(app_event_await(&sub, &out, 0), ERROR_TIMEOUT); + + app_event_unsubscribe(&sub); +} + +TEST_CASE("app_event_emit stamps the event with the current boot-relative time") { + AppEventSubscription sub {}; + sub.app_instance_id = 50; + app_event_subscribe(&sub); + + auto before = static_cast(get_micros_since_boot()); + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(50, &event); + auto after = static_cast(get_micros_since_boot()); + + AppEvent out {}; + REQUIRE_EQ(app_event_await(&sub, &out, 0), ERROR_NONE); + CHECK_GE(out.timestamp, before); + CHECK_LE(out.timestamp, after); + + app_event_unsubscribe(&sub); +} + +TEST_CASE("app_event_await wakes when the event is emitted from another task") { + AppEventSubscription sub {}; + sub.app_instance_id = 60; + CHECK_EQ(app_event_subscribe(&sub), ERROR_NONE); + + auto* thread = thread_alloc_full( + "app-event-emitter", + 4096, + [](void*) -> int32_t { + delay_millis(20); + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(60, &event); + return 0; + }, + nullptr, + -1 + ); + CHECK_EQ(thread_start(thread), ERROR_NONE); + + AppEvent out {}; + CHECK_EQ(app_event_await(&sub, &out, pdMS_TO_TICKS(2000)), ERROR_NONE); + CHECK_EQ(out.type, APP_EVENT_CLOSE); + + CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), 1), ERROR_NONE); + thread_free(thread); + + app_event_unsubscribe(&sub); +} diff --git a/Tests/app-module/Source/AppManagerTest.cpp b/Tests/app-module/Source/AppManagerTest.cpp new file mode 100644 index 000000000..43f999dd1 --- /dev/null +++ b/Tests/app-module/Source/AppManagerTest.cpp @@ -0,0 +1,431 @@ +#include "doctest.h" + +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include + +extern ServiceManifest app_internal_loader_service_manifest; + +namespace { + +error_t fake_load(AppLocation, void** out_runtime) { + *out_runtime = nullptr; + return ERROR_NONE; +} + +// Stashed by fake_run() on every call, for tests that need to verify exactly what argc/argv it +// received (e.g. that app-module deep-copied the caller's argv) without a getter to query it +// through. +int last_received_argc = -1; +std::vector last_received_argv; + +void stash_received_arguments(int argc, char* argv[]) { + last_received_argc = argc; + last_received_argv.clear(); + for (int i = 0; i < argc; i++) { + last_received_argv.emplace_back(argv[i]); + } +} + +// A minimal stand-in for a real app's main(): subscribes to its own app_event stream and exits +// as soon as it's asked to close - exactly the contract every app instance (with its own +// dedicated task for its whole lifetime) is expected to follow. If launched with a single +// parameter (app_manager_start_for_result()), acts as a modal dialog instead: returns the +// requested result (argv[0], parsed as an int) immediately (the app's own return value IS the +// delivered APP_EVENT_RESULT.result - see app_scheduler.cpp's thread_main()). +int32_t fake_run(void*, uint32_t app_instance_id, int argc, char* argv[]) { + stash_received_arguments(argc, argv); + + if (argc == 1) { + // Single-arg shortcut used by the start_for_result() result-delivery tests: returns + // immediately with argv[0] parsed as the result code, instead of running the normal + // event loop below. Tests that pass other argc (0, or >1 to check deep-copy) fall + // through and run the loop as usual. + return static_cast(strtol(argv[0], nullptr, 10)); + } + + AppEventSubscription sub {}; + sub.app_instance_id = app_instance_id; + app_event_subscribe(&sub); + + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, pdMS_TO_TICKS(5000)) != ERROR_NONE) { + break; // safety net so a bug here can't hang the test suite + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + break; + } + } + + app_event_unsubscribe(&sub); + return 0; +} + +void fake_unload(void*) { +} + +AppLoaderApi fake_loader_api = { + .load = fake_load, + .run = fake_run, + .unload = fake_unload, +}; + +void* create_loader_service(const ServiceManifest*) { + return &fake_loader_api; +} + +void destroy_loader_service(const ServiceManifest*, void*) { +} + +ServiceManifest fake_loader_manifest = { + .id = APP_LOADER_PATH_SERVICE_ID, + .create_service = create_loader_service, + .destroy_service = destroy_loader_service, + .on_start = nullptr, + .on_stop = nullptr, +}; + +void ensure_fake_loader_registered() { + static bool registered = false; + if (!registered) { + CHECK_EQ(service_manager_add(&fake_loader_manifest, /*auto_start=*/true), ERROR_NONE); + registered = true; + } +} + +// app-module's real APP_LOCATION_MEMORY loader (source/app_internal_loader.cpp) - not a fake, +// since it has no platform dependency and is exactly what a statically-linked app would go +// through. +void ensure_memory_loader_registered() { + static bool registered = false; + if (!registered) { + CHECK_EQ(service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true), ERROR_NONE); + registered = true; + } +} + +// Same subscribe-until-close contract as fake_run() above, but called directly as an AppMainFn - +// this is what a real internal app's entry point looks like. +int32_t fake_app_main(uint32_t app_instance_id, int argc, char* argv[]) { + return fake_run(nullptr, app_instance_id, argc, argv); +} + +// Wraps app_manager_get_topmost_instance_id() for terse assertions: 0 if no app is Active. +AppInstanceId topmost_instance_id() { + AppInstanceId id = 0; + return app_manager_get_topmost_instance_id(&id) == ERROR_NONE ? id : 0; +} + +bool wait_for_state(uint32_t instance_id, AppInstanceState target, uint32_t timeout_ms) { + uint32_t waited = 0; + while (waited < timeout_ms) { + if (app_manager_get_state(instance_id) == target) { + return true; + } + delay_millis(10); + waited += 10; + } + return app_manager_get_state(instance_id) == target; +} + +} // namespace + +TEST_CASE("app_manager_start activates an app instance, app_manager_stop terminates it") { + ensure_fake_loader_registered(); + + AppManifest manifest { "test.app.a", "Test App A", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + + uint32_t instance_id = 0; + REQUIRE_EQ(app_manager_start("test.app.a", &instance_id), ERROR_NONE); + CHECK(wait_for_state(instance_id, APP_INSTANCE_STATE_ACTIVE, 1000)); + + CHECK_EQ(app_manager_stop(instance_id), ERROR_NONE); + CHECK_EQ(app_manager_get_state(instance_id), APP_INSTANCE_STATE_STOPPED); + + app_manager_remove("test.app.a"); +} + +TEST_CASE("app_manager_start never touches another already-running app - every instance gets its own task") { + ensure_fake_loader_registered(); + + AppManifest manifest_b { "test.app.b", "Test App B", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + AppManifest manifest_c { "test.app.c", "Test App C", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&manifest_b), ERROR_NONE); + REQUIRE_EQ(app_manager_add(&manifest_c), ERROR_NONE); + + uint32_t id_b = 0; + REQUIRE_EQ(app_manager_start("test.app.b", &id_b), ERROR_NONE); + CHECK(wait_for_state(id_b, APP_INSTANCE_STATE_ACTIVE, 1000)); + + uint32_t id_c = 0; + REQUIRE_EQ(app_manager_start("test.app.c", &id_c), ERROR_NONE); + CHECK(wait_for_state(id_c, APP_INSTANCE_STATE_ACTIVE, 1000)); + + // b is untouched by c starting - both stay Active at once, each with its own task. + CHECK_EQ(app_manager_get_state(id_b), APP_INSTANCE_STATE_ACTIVE); + + app_manager_stop(id_b); + app_manager_stop(id_c); + app_manager_remove("test.app.b"); + app_manager_remove("test.app.c"); +} + +TEST_CASE("app_manager_start always creates a fresh instance, even for the same manifest id twice") { + ensure_fake_loader_registered(); + + AppManifest manifest { "test.app.twice", "Test App Twice", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + + uint32_t id_first = 0; + REQUIRE_EQ(app_manager_start("test.app.twice", &id_first), ERROR_NONE); + CHECK(wait_for_state(id_first, APP_INSTANCE_STATE_ACTIVE, 1000)); + + uint32_t id_second = 0; + REQUIRE_EQ(app_manager_start("test.app.twice", &id_second), ERROR_NONE); + CHECK(wait_for_state(id_second, APP_INSTANCE_STATE_ACTIVE, 1000)); + + CHECK_NE(id_first, id_second); + CHECK_EQ(app_manager_get_state(id_first), APP_INSTANCE_STATE_ACTIVE); + + app_manager_stop(id_first); + app_manager_stop(id_second); + app_manager_remove("test.app.twice"); +} + +TEST_CASE("app_manager_get_state returns STOPPED for an unknown instance id") { + CHECK_EQ(app_manager_get_state(999999), APP_INSTANCE_STATE_STOPPED); +} + +TEST_CASE("app_manager_start_with_parameters deep-copies argv before the app instance receives it") { + ensure_fake_loader_registered(); + + AppManifest manifest { "test.app.args", "Test App Args", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + + uint32_t instance_id = 0; + { + // Caller's argv is stack-local and goes out of scope immediately after this block - + // proves app-module made its own copy rather than aliasing the caller's strings. + std::string ssid = "MyNetwork"; + std::string password = "hunter2"; + const char* argv[] = { ssid.c_str(), password.c_str() }; + REQUIRE_EQ(app_manager_start_with_parameters("test.app.args", 2, argv, &instance_id), ERROR_NONE); + } + CHECK(wait_for_state(instance_id, APP_INSTANCE_STATE_ACTIVE, 1000)); + + REQUIRE_EQ(last_received_argc, 2); + REQUIRE_EQ(last_received_argv.size(), 2u); + CHECK_EQ(last_received_argv[0], "MyNetwork"); + CHECK_EQ(last_received_argv[1], "hunter2"); + + app_manager_stop(instance_id); + app_manager_remove("test.app.args"); +} + +TEST_CASE("app_manager_add rejects a duplicate id") { + AppManifest manifest { "test.app.dup", "Test App Dup", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + CHECK_EQ(app_manager_add(&manifest), ERROR_INVALID_ARGUMENT); + app_manager_remove("test.app.dup"); +} + +TEST_CASE("app_manager_for_each_manifest visits every registered manifest, including newly added ones") { + AppManifest manifest_x { "test.app.foreach.x", "X", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + AppManifest manifest_y { "test.app.foreach.y", "Y", APP_CATEGORY_SETTINGS, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&manifest_x), ERROR_NONE); + REQUIRE_EQ(app_manager_add(&manifest_y), ERROR_NONE); + + std::vector seen_ids; + app_manager_for_each_manifest([](const AppManifest* manifest, void* context) { + static_cast*>(context)->emplace_back(manifest->id); + }, &seen_ids); + + CHECK(std::ranges::find(seen_ids, "test.app.foreach.x") != seen_ids.end()); + CHECK(std::ranges::find(seen_ids, "test.app.foreach.y") != seen_ids.end()); + + app_manager_remove("test.app.foreach.x"); + app_manager_remove("test.app.foreach.y"); + + seen_ids.clear(); + app_manager_for_each_manifest([](const AppManifest* manifest, void* context) { + static_cast*>(context)->emplace_back(manifest->id); + }, &seen_ids); + CHECK(std::ranges::find(seen_ids, "test.app.foreach.x") == seen_ids.end()); +} + +TEST_CASE("app_manager_start fails for an unregistered manifest id") { + uint32_t instance_id = 0; + CHECK_EQ(app_manager_start("test.app.nonexistent", &instance_id), ERROR_NOT_FOUND); +} + +TEST_CASE("app_manager_start runs an APP_LOCATION_MEMORY app via its function pointer, through the real internal loader") { + ensure_memory_loader_registered(); + + AppManifest manifest { + "test.app.memory", + "Test App Memory", + APP_CATEGORY_USER, + { APP_LOCATION_MEMORY, reinterpret_cast(fake_app_main) } + }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + + uint32_t instance_id = 0; + REQUIRE_EQ(app_manager_start("test.app.memory", &instance_id), ERROR_NONE); + CHECK(wait_for_state(instance_id, APP_INSTANCE_STATE_ACTIVE, 1000)); + + CHECK_EQ(app_manager_stop(instance_id), ERROR_NONE); + CHECK_EQ(app_manager_get_state(instance_id), APP_INSTANCE_STATE_STOPPED); + + app_manager_remove("test.app.memory"); +} + +TEST_CASE("app_manager_start_for_result delivers APP_EVENT_RESULT to the parent, which stays Active throughout") { + ensure_fake_loader_registered(); + + AppManifest parent_manifest { "test.app.parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + AppManifest child_manifest { "test.app.child", "Child", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE); + REQUIRE_EQ(app_manager_add(&child_manifest), ERROR_NONE); + + uint32_t parent_id = 0; + REQUIRE_EQ(app_manager_start("test.app.parent", &parent_id), ERROR_NONE); + CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000)); + + AppEventSubscription parent_sub {}; + parent_sub.app_instance_id = parent_id; + REQUIRE_EQ(app_event_subscribe(&parent_sub), ERROR_NONE); + + const char* argv[] = { "42" }; + uint32_t child_id = 0; + REQUIRE_EQ(app_manager_start_for_result("test.app.child", parent_id, 1, argv, &child_id), ERROR_NONE); + + // Launching a modal child never touches the parent's own task/state. + CHECK_EQ(app_manager_get_state(parent_id), APP_INSTANCE_STATE_ACTIVE); + + AppEvent event {}; + REQUIRE_EQ(app_event_await(&parent_sub, &event, pdMS_TO_TICKS(2000)), ERROR_NONE); + CHECK_EQ(event.type, APP_EVENT_RESULT); + CHECK_EQ(event.result.launch_id, child_id); + CHECK_EQ(event.result.result, 42); + + app_event_unsubscribe(&parent_sub); + app_manager_stop(child_id); + app_manager_stop(parent_id); + app_manager_remove("test.app.parent"); + app_manager_remove("test.app.child"); +} + +TEST_CASE("app_manager_start_for_result delivers the child's own return value as the result") { + ensure_fake_loader_registered(); + + AppManifest parent_manifest { "test.app.parent2", "Parent2", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + AppManifest child_manifest { "test.app.child2", "Child2", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE); + REQUIRE_EQ(app_manager_add(&child_manifest), ERROR_NONE); + + uint32_t parent_id = 0; + REQUIRE_EQ(app_manager_start("test.app.parent2", &parent_id), ERROR_NONE); + CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000)); + + AppEventSubscription parent_sub {}; + parent_sub.app_instance_id = parent_id; + REQUIRE_EQ(app_event_subscribe(&parent_sub), ERROR_NONE); + + uint32_t child_id = 0; + // No parameters - fake_run falls through to its normal CLOSE loop instead of acting as a + // dialog. + REQUIRE_EQ(app_manager_start_for_result("test.app.child2", parent_id, 0, nullptr, &child_id), ERROR_NONE); + CHECK(wait_for_state(child_id, APP_INSTANCE_STATE_ACTIVE, 1000)); + + app_manager_stop(child_id); // force-close + + AppEvent event {}; + REQUIRE_EQ(app_event_await(&parent_sub, &event, pdMS_TO_TICKS(2000)), ERROR_NONE); + CHECK_EQ(event.type, APP_EVENT_RESULT); + CHECK_EQ(event.result.launch_id, child_id); + CHECK_EQ(event.result.result, 0); // fake_run's CLOSE loop always returns 0 + + app_event_unsubscribe(&parent_sub); + app_manager_stop(parent_id); + app_manager_remove("test.app.parent2"); + app_manager_remove("test.app.child2"); +} + +TEST_CASE("app_manager_get_topmost_instance_id returns NOT_FOUND when nothing is active, then tracks who's on top") { + ensure_fake_loader_registered(); + AppInstanceId id = 999999; + CHECK_EQ(app_manager_get_topmost_instance_id(&id), ERROR_NOT_FOUND); + + AppManifest manifest_a { "test.app.top_a", "A", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + AppManifest manifest_b { "test.app.top_b", "B", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&manifest_a), ERROR_NONE); + REQUIRE_EQ(app_manager_add(&manifest_b), ERROR_NONE); + + uint32_t id_a = 0; + REQUIRE_EQ(app_manager_start("test.app.top_a", &id_a), ERROR_NONE); + CHECK(wait_for_state(id_a, APP_INSTANCE_STATE_ACTIVE, 1000)); + CHECK_EQ(topmost_instance_id(), id_a); + + // a stays Active - b just has a higher (more recently allocated) instance id, so it becomes + // topmost without a superseding/saving. + uint32_t id_b = 0; + REQUIRE_EQ(app_manager_start("test.app.top_b", &id_b), ERROR_NONE); + CHECK(wait_for_state(id_b, APP_INSTANCE_STATE_ACTIVE, 1000)); + CHECK_EQ(topmost_instance_id(), id_b); + + char app_id_buffer[64]; + REQUIRE_EQ(app_manager_get_topmost_app_id(app_id_buffer, sizeof(app_id_buffer)), ERROR_NONE); + CHECK_EQ(std::string(app_id_buffer), "test.app.top_b"); + + // A modal child stays Active alongside its parent while shown - the child (started more + // recently) must be reported as topmost, not the parent. No parameters, so fake_run() takes + // its persistent CLOSE loop branch instead of instantly resolving like a real dialog would - + // needed here so there's a reliable window to observe it as topmost. + uint32_t id_c = 0; + REQUIRE_EQ(app_manager_start_for_result("test.app.top_a", id_b, 0, nullptr, &id_c), ERROR_NONE); + CHECK(wait_for_state(id_c, APP_INSTANCE_STATE_ACTIVE, 1000)); + CHECK_EQ(topmost_instance_id(), id_c); + + app_manager_stop(id_c); + CHECK_EQ(topmost_instance_id(), id_b); + + app_manager_stop(id_a); + app_manager_stop(id_b); + app_manager_remove("test.app.top_a"); + app_manager_remove("test.app.top_b"); +} + +TEST_CASE("app_manager_get_topmost_app_id returns BUFFER_OVERFLOW for a too-small buffer, NOT_FOUND when nothing is active") { + ensure_fake_loader_registered(); + + char buffer[4]; + CHECK_EQ(app_manager_get_topmost_app_id(buffer, sizeof(buffer)), ERROR_NOT_FOUND); + CHECK_EQ(app_manager_get_topmost_app_id(buffer, 0), ERROR_BUFFER_OVERFLOW); + + AppManifest manifest { "test.app.top_overflow", "Overflow", APP_CATEGORY_USER, { APP_LOCATION_PATH, nullptr } }; + REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); + + uint32_t id = 0; + REQUIRE_EQ(app_manager_start("test.app.top_overflow", &id), ERROR_NONE); + CHECK(wait_for_state(id, APP_INSTANCE_STATE_ACTIVE, 1000)); + + // "test.app.top_overflow" doesn't fit in a 4-byte buffer. + CHECK_EQ(app_manager_get_topmost_app_id(buffer, sizeof(buffer)), ERROR_BUFFER_OVERFLOW); + + app_manager_stop(id); + app_manager_remove("test.app.top_overflow"); +} diff --git a/Tests/app-module/Source/Main.cpp b/Tests/app-module/Source/Main.cpp new file mode 100644 index 000000000..acd1df905 --- /dev/null +++ b/Tests/app-module/Source/Main.cpp @@ -0,0 +1,51 @@ +#define DOCTEST_CONFIG_IMPLEMENT +#include "doctest.h" +#include + +#include "FreeRTOS.h" +#include "task.h" + +typedef struct { + int argc; + char** argv; + int result; +} TestTaskData; + +void test_task(void* parameter) { + auto* data = (TestTaskData*)parameter; + + doctest::Context context; + + context.applyCommandLine(data->argc, data->argv); + + // overrides + context.setOption("no-breaks", true); // don't break in the debugger when assertions fail + + data->result = context.run(); + + vTaskEndScheduler(); + + vTaskDelete(nullptr); +} + +int main(int argc, char** argv) { + TestTaskData data = { + .argc = argc, + .argv = argv, + .result = 0 + }; + + BaseType_t task_result = xTaskCreate( + test_task, + "test_task", + 8192, + &data, + 1, + nullptr + ); + assert(task_result == pdPASS); + + vTaskStartScheduler(); + + return data.result; +} From 1ae6e7708394906a4973cd55c1fc221c938c404d Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 16:44:58 +0200 Subject: [PATCH 04/31] Fixes and improvements --- Buildscripts/TactilitySDK/CMakeLists.txt | 2 +- Documentation/ideas.md | 3 +- .../app-esp32-module/LICENSE-Apache-2.0.md | 195 ++++++++++++++++ Modules/app-module/LICENSE-Apache-2.0.md | 195 ++++++++++++++++ Modules/app-module/include/app/location.h | 1 + Modules/app-module/include/app/manager.h | 7 +- Modules/app-module/include/app/metadata.h | 1 + Modules/app-module/include/app/module.h | 2 + Modules/app-module/include/app/paths.h | 56 +++++ .../app-module/private/app/private/app_fs.h | 25 ++- .../private/app/private/app_ledger.h | 8 +- .../private/app/private/app_scheduler.h | 2 - Modules/app-module/source/app_install.cpp | 2 +- .../app-module/source/app_internal_loader.cpp | 2 +- Modules/app-module/source/app_paths.cpp | 62 +++++ Modules/app-module/source/app_scheduler.cpp | 72 ++++-- Modules/app-module/source/symbols.cpp | 11 + .../LICENSE-Apache-2.0.md | 195 ++++++++++++++++ .../lvgl_window_manager/window_manager.h | 4 + .../source/window_manager.cpp | 70 ++++-- TactilityC/Include/tt_app.h | 122 ---------- TactilityC/Include/tt_app_alertdialog.h | 17 +- TactilityC/Include/tt_app_fileselection.h | 15 +- TactilityC/Include/tt_app_selectiondialog.h | 11 +- TactilityC/Include/tt_bundle.h | 74 ------ TactilityC/Include/tt_preferences.h | 83 ------- TactilityC/Source/tt_app.cpp | 133 ----------- TactilityC/Source/tt_app_alertdialog.cpp | 10 +- TactilityC/Source/tt_app_fileselection.cpp | 19 +- TactilityC/Source/tt_app_selectiondialog.cpp | 8 +- TactilityC/Source/tt_bundle.cpp | 53 ----- TactilityC/Source/tt_init.cpp | 32 --- TactilityC/Source/tt_preferences.cpp | 53 ----- TactilityKernel/include/tactility/bundle.h | 64 ++++++ .../include/tactility/preferences.h | 61 +++++ .../include/tactility/properties_file.h | 57 +++++ .../include/tactility/system_event.h | 91 +++++--- TactilityKernel/source/bundle.cpp | 151 +++++++++++++ TactilityKernel/source/preferences.cpp | 211 ++++++++++++++++++ TactilityKernel/source/properties_file.cpp | 167 ++++++++++++++ TactilityKernel/source/symbols.c | 45 ++++ TactilityKernel/source/system_event.cpp | 64 +++--- Tests/TactilityKernel/Source/BundleTest.cpp | 134 +++++++++++ .../Source/PreferencesTest.cpp | 153 +++++++++++++ .../Source/PropertiesFileTest.cpp | 180 +++++++++++++++ .../Source/SystemEventTest.cpp | 102 +++++++-- Tests/app-module/Source/AppManagerTest.cpp | 25 +++ Tests/app-module/Source/Main.cpp | 5 +- 48 files changed, 2315 insertions(+), 740 deletions(-) create mode 100644 Modules/app-esp32-module/LICENSE-Apache-2.0.md create mode 100644 Modules/app-module/LICENSE-Apache-2.0.md create mode 100644 Modules/app-module/include/app/paths.h create mode 100644 Modules/app-module/source/app_paths.cpp create mode 100644 Modules/lvgl-window-manager-module/LICENSE-Apache-2.0.md delete mode 100644 TactilityC/Include/tt_app.h delete mode 100644 TactilityC/Include/tt_bundle.h delete mode 100644 TactilityC/Include/tt_preferences.h delete mode 100644 TactilityC/Source/tt_app.cpp delete mode 100644 TactilityC/Source/tt_bundle.cpp delete mode 100644 TactilityC/Source/tt_preferences.cpp create mode 100644 TactilityKernel/include/tactility/bundle.h create mode 100644 TactilityKernel/include/tactility/preferences.h create mode 100644 TactilityKernel/include/tactility/properties_file.h create mode 100644 TactilityKernel/source/bundle.cpp create mode 100644 TactilityKernel/source/preferences.cpp create mode 100644 TactilityKernel/source/properties_file.cpp create mode 100644 Tests/TactilityKernel/Source/BundleTest.cpp create mode 100644 Tests/TactilityKernel/Source/PreferencesTest.cpp create mode 100644 Tests/TactilityKernel/Source/PropertiesFileTest.cpp diff --git a/Buildscripts/TactilitySDK/CMakeLists.txt b/Buildscripts/TactilitySDK/CMakeLists.txt index ffa734c57..67107519e 100644 --- a/Buildscripts/TactilitySDK/CMakeLists.txt +++ b/Buildscripts/TactilitySDK/CMakeLists.txt @@ -7,7 +7,7 @@ idf_component_register( "Libraries/minmea/include" "Modules/lvgl-module/include" # DRIVER_INCLUDE_DIRS_PLACEHOLDER - REQUIRES esp_timer app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module + REQUIRES esp_timer minitar app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module ) # Regular and core features diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 963ffabaa..c1e06e060 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -30,7 +30,7 @@ - bluetooth: various getters for child devices do not change ref counting, but they should - Improve kernel_init.cpp (and other modules): create driver_ensure_added() and driver_ensure_destructed() - Remove and migrate `Include/Tactility/kernel/Kernel.h` into `tactility/delay.h` -- Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual modulej. +- Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual module. - LilyGO T-Dongle S3: 1 button control, stop auto-launching web server - Core2: support power off via software - Create `#define` for empty module (for modules that fully rely on device.properties and don't define drivers or have start/stop logic) @@ -53,6 +53,7 @@ ## Medium Priority - Consider using https://github.com/Graphify-Labs/graphify +- Consider implementing LVGL gridnav in apps https://lvgl.io/docs/open/9.3/details/auxiliary-modules/gridnav.html - Implement a LED kernel driver (single colour and RGB, plain GPIO and PWM) - Make USB host driver disabled by default, so it doesn't consume memory - Filtering for apps in App Hub: diff --git a/Modules/app-esp32-module/LICENSE-Apache-2.0.md b/Modules/app-esp32-module/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Modules/app-esp32-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Modules/app-module/LICENSE-Apache-2.0.md b/Modules/app-module/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Modules/app-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Modules/app-module/include/app/location.h b/Modules/app-module/include/app/location.h index 721c07772..eb8703ca0 100644 --- a/Modules/app-module/include/app/location.h +++ b/Modules/app-module/include/app/location.h @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 #pragma once #ifdef __cplusplus diff --git a/Modules/app-module/include/app/manager.h b/Modules/app-module/include/app/manager.h index 55f6c7943..e35ba5db2 100644 --- a/Modules/app-module/include/app/manager.h +++ b/Modules/app-module/include/app/manager.h @@ -31,9 +31,10 @@ error_t app_manager_remove(const char* id); const struct AppManifest* app_manager_find_manifest(const char* id); /** - * Calls @a visitor once for every registered manifest (e.g. for AppList/Settings to enumerate - * apps to show). Iteration order is unspecified. Safe to call app_manager_add()/_remove() from - * within @a visitor is NOT guaranteed - do not mutate the registry from inside the callback. + * Calls `@a` visitor once for every registered manifest. Iteration order is unspecified. + * `@warning` `@a` visitor runs with app-module's internal registry lock held. Do not call any + * app_manager_*() function from inside `@a` visitor - copy out what you need and act on it after + * this call returns. */ typedef void (*AppManifestVisitorFn)(const struct AppManifest* manifest, void* context); void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context); diff --git a/Modules/app-module/include/app/metadata.h b/Modules/app-module/include/app/metadata.h index 4b759f4ae..6cd039ec8 100644 --- a/Modules/app-module/include/app/metadata.h +++ b/Modules/app-module/include/app/metadata.h @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 #pragma once #include diff --git a/Modules/app-module/include/app/module.h b/Modules/app-module/include/app/module.h index 5bfd3ee67..d54dba82f 100644 --- a/Modules/app-module/include/app/module.h +++ b/Modules/app-module/include/app/module.h @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include + #ifdef __cplusplus extern "C" { #endif diff --git a/Modules/app-module/include/app/paths.h b/Modules/app-module/include/app/paths.h new file mode 100644 index 000000000..0042c956b --- /dev/null +++ b/Modules/app-module/include/app/paths.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Get the user data directory for an app. Survives OS upgrades. No trailing "/". + * @param[in] app_id non-null app id + * @param[out] out_path buffer to store the path + * @param[in] out_path_size size of the output buffer + * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small + * @retval ERROR_NONE on success + */ +error_t app_paths_get_user_data_directory(const char* app_id, char* out_path, size_t out_path_size); + +/** + * @brief Get a path within the user data directory for an app. + * @param[in] app_id non-null app id + * @param[in] child_path path without a "/" prefix + * @param[out] out_path buffer to store the path + * @param[in] out_path_size size of the output buffer + * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small + * @retval ERROR_NONE on success + */ +error_t app_paths_get_user_data_path(const char* app_id, const char* child_path, char* out_path, size_t out_path_size); + +/** + * @brief Get the assets directory for an app. Do not store configuration data here. No trailing "/". + * @param[in] app_id non-null app id + * @param[out] out_path buffer to store the path + * @param[in] out_path_size size of the output buffer + * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small + * @retval ERROR_NONE on success + */ +error_t app_paths_get_assets_directory(const char* app_id, char* out_path, size_t out_path_size); + +/** + * @brief Get a path within the assets directory for an app. + * @param[in] app_id non-null app id + * @param[in] child_path path without a "/" prefix + * @param[out] out_path buffer to store the path + * @param[in] out_path_size size of the output buffer + * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small + * @retval ERROR_NONE on success + */ +error_t app_paths_get_assets_path(const char* app_id, const char* child_path, char* out_path, size_t out_path_size); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/private/app/private/app_fs.h b/Modules/app-module/private/app/private/app_fs.h index 03b4d77c5..d4dff2a14 100644 --- a/Modules/app-module/private/app/private/app_fs.h +++ b/Modules/app-module/private/app/private/app_fs.h @@ -6,8 +6,7 @@ // upward on Tactility::file, so this is a small local re-implementation (see // app_metadata_parsing.cpp for the same constraint applied to properties-file loading). -#include "tactility/filesystem/file_mutex.h" - +#include #include #include @@ -35,9 +34,16 @@ inline bool app_fs_is_file(const std::string& path) { return retval; } -// Appends the full path of every direct subdirectory of @a path to @a out. No-op (not an error) -// if @a path can't be opened. +// Appends the full path of every direct subdirectory of @a path to @a out. +// No-op (not an error) if @a path can't be opened. inline void app_fs_list_direct_subdirectories(const std::string& path, std::vector& out) { + // Collect child names while the directory lock is held, then release it before classifying + // each one with app_fs_is_directory() - that function looks up and locks a FileMutex too, + // and file_mutex_get() resolves a child path to the same registered mutex as its parent + // mount. Calling it while still holding the directory's own lock would be a nested + // acquisition of that same (possibly non-recursive) mutex, and could self-deadlock. + std::vector children; + FileMutex file_mutex; file_mutex_get(&file_mutex, path.c_str()); file_mutex_lock(&file_mutex); @@ -52,12 +58,15 @@ inline void app_fs_list_direct_subdirectories(const std::string& path, std::vect if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) { continue; } - auto child_path = path + "/" + entry->d_name; - if (app_fs_is_directory(child_path)) { - out.push_back(child_path); - } + children.push_back(path + "/" + entry->d_name); } closedir(dir); file_mutex_unlock(&file_mutex); + + for (const auto& child_path : children) { + if (app_fs_is_directory(child_path)) { + out.push_back(child_path); + } + } } diff --git a/Modules/app-module/private/app/private/app_ledger.h b/Modules/app-module/private/app/private/app_ledger.h index fdac66287..c7907cb13 100644 --- a/Modules/app-module/private/app/private/app_ledger.h +++ b/Modules/app-module/private/app/private/app_ledger.h @@ -8,7 +8,7 @@ #include #include -#include +#include #include #include @@ -24,6 +24,12 @@ struct AppInstanceRecord { /** 0 for a top-level launch (app_manager_start()). Non-zero for a modal child launched via * app_manager_start_for_result() - the instance that receives this child's APP_EVENT_RESULT. */ uint32_t parent_id = 0; + + /** The task currently blocked in app_scheduler_stop() for this instance, if any - notified + * (via xTaskNotifyGive()) as the literal last action app_task_main() takes before + * vTaskDelete(), so app_scheduler_stop() can't observe completion before the task has + * actually finished running. See app_scheduler.cpp. */ + TaskHandle_t stop_waiter = nullptr; }; struct AppLedger { diff --git a/Modules/app-module/private/app/private/app_scheduler.h b/Modules/app-module/private/app/private/app_scheduler.h index 6f9069a77..d323360c3 100644 --- a/Modules/app-module/private/app/private/app_scheduler.h +++ b/Modules/app-module/private/app/private/app_scheduler.h @@ -5,8 +5,6 @@ #include -#include - /** * Owns per-app task lifecycle on behalf of app_manager_*(). AppLoaderApi implementations * stay task-agnostic; all of xTaskCreate()/vTaskDelete() happens here, as a plain FreeRTOS task diff --git a/Modules/app-module/source/app_install.cpp b/Modules/app-module/source/app_install.cpp index 3586a4034..bcbd6c8db 100644 --- a/Modules/app-module/source/app_install.cpp +++ b/Modules/app-module/source/app_install.cpp @@ -261,7 +261,7 @@ void stop_all_instances_of(const AppManifest* manifest) { } } -// Takes install_registry().mutex - caller must not already hold it. +// Caller must already hold install_registry().mutex error_t uninstall_locked(const std::string& app_id) { auto& registry = install_registry(); auto iterator = registry.apps.find(app_id); diff --git a/Modules/app-module/source/app_internal_loader.cpp b/Modules/app-module/source/app_internal_loader.cpp index d9175f188..4c447d3fe 100644 --- a/Modules/app-module/source/app_internal_loader.cpp +++ b/Modules/app-module/source/app_internal_loader.cpp @@ -39,7 +39,7 @@ void destroy_service(const ServiceManifest*, void*) { } // namespace -extern ServiceManifest app_internal_loader_service_manifest = { +ServiceManifest app_internal_loader_service_manifest = { .id = APP_LOADER_MEMORY_SERVICE_ID, .create_service = create_service, .destroy_service = destroy_service, diff --git a/Modules/app-module/source/app_paths.cpp b/Modules/app-module/source/app_paths.cpp new file mode 100644 index 000000000..eb7ac418a --- /dev/null +++ b/Modules/app-module/source/app_paths.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include + +extern "C" { + +error_t app_paths_get_user_data_directory(const char* app_id, char* out_path, size_t out_path_size) { + char root[192]; + error_t error = paths_get_user_data_path(root, sizeof(root)); + if (error != ERROR_NONE) { + return error; + } + int written = std::snprintf(out_path, out_path_size, "%s/app/%s", root, app_id); + if (written < 0 || (size_t)written >= out_path_size) { + return ERROR_BUFFER_OVERFLOW; + } + return ERROR_NONE; +} + +error_t app_paths_get_user_data_path(const char* app_id, const char* child_path, char* out_path, size_t out_path_size) { + char directory[224]; + error_t error = app_paths_get_user_data_directory(app_id, directory, sizeof(directory)); + if (error != ERROR_NONE) { + return error; + } + int written = std::snprintf(out_path, out_path_size, "%s/%s", directory, child_path); + if (written < 0 || (size_t)written >= out_path_size) { + return ERROR_BUFFER_OVERFLOW; + } + return ERROR_NONE; +} + +error_t app_paths_get_assets_directory(const char* app_id, char* out_path, size_t out_path_size) { + char directory[224]; + error_t error = app_paths_get_user_data_directory(app_id, directory, sizeof(directory)); + if (error != ERROR_NONE) { + return error; + } + int written = std::snprintf(out_path, out_path_size, "%s/assets", directory); + if (written < 0 || (size_t)written >= out_path_size) { + return ERROR_BUFFER_OVERFLOW; + } + return ERROR_NONE; +} + +error_t app_paths_get_assets_path(const char* app_id, const char* child_path, char* out_path, size_t out_path_size) { + char directory[224]; + error_t error = app_paths_get_assets_directory(app_id, directory, sizeof(directory)); + if (error != ERROR_NONE) { + return error; + } + int written = std::snprintf(out_path, out_path_size, "%s/%s", directory, child_path); + if (written < 0 || (size_t)written >= out_path_size) { + return ERROR_BUFFER_OVERFLOW; + } + return ERROR_NONE; +} + +} // extern "C" diff --git a/Modules/app-module/source/app_scheduler.cpp b/Modules/app-module/source/app_scheduler.cpp index 91f3d94b8..70ce18eaf 100644 --- a/Modules/app-module/source/app_scheduler.cpp +++ b/Modules/app-module/source/app_scheduler.cpp @@ -9,10 +9,8 @@ #include #include -#include #include #include -#include #include #include @@ -49,23 +47,31 @@ void set_state(AppInstanceId app_instance_id, AppInstanceState state) { mutex_unlock(&ledger.mutex); } -TaskHandle_t get_task(AppInstanceId app_instance_id) { +void set_task(AppInstanceId app_instance_id, TaskHandle_t task) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); auto iterator = ledger.instances.find(app_instance_id); - TaskHandle_t task = (iterator != ledger.instances.end()) ? iterator->second.task : nullptr; + if (iterator != ledger.instances.end()) { + iterator->second.task = task; + } mutex_unlock(&ledger.mutex); - return task; } -void set_task(AppInstanceId app_instance_id, TaskHandle_t task) { +// Registers the calling task to be notified when app_instance_id's task actually finishes +// running (see app_task_main()'s exit path), and reports whether there's anything to wait for. +// @return true if the instance's ledger entry still exists (a wait was registered); false if +// the task has already fully finished (and already given any notification it would have) - +// there is nothing left to wait for. +bool register_stop_waiter(AppInstanceId app_instance_id) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); auto iterator = ledger.instances.find(app_instance_id); - if (iterator != ledger.instances.end()) { - iterator->second.task = task; + bool exists = iterator != ledger.instances.end(); + if (exists) { + iterator->second.stop_waiter = xTaskGetCurrentTaskHandle(); } mutex_unlock(&ledger.mutex); + return exists; } const char* loader_service_id_for(AppLocationType type) { @@ -135,12 +141,23 @@ void app_task_main(void* context) { LOG_I(TAG, "Thread for %d finished", app_instance_id); - // Erase the ledger entry before self-deleting + // Erase the ledger entry before self-deleting, capturing whoever's blocked in + // app_scheduler_stop() for this instance (if anyone) so they can be notified afterward. auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + TaskHandle_t stop_waiter = (iterator != ledger.instances.end()) ? iterator->second.stop_waiter : nullptr; ledger.instances.erase(app_instance_id); mutex_unlock(&ledger.mutex); + // Signal completion as the literal last action before this task ceases to exist, so + // app_scheduler_stop() can't observe "stopped" one step early (see its own comment) - + // unlike watching the ledger entry disappear, this can only happen once the task is truly + // done running. + if (stop_waiter != nullptr) { + xTaskNotifyGive(stop_waiter); + } + vTaskDelete(nullptr); } @@ -176,36 +193,45 @@ error_t app_scheduler_start(AppInstanceId app_instance_id, AppLocation location, snprintf(task_name, sizeof(task_name), "app_%lu", static_cast(app_instance_id)); TaskHandle_t task_handle = nullptr; - // 8192 bytes -> stack depth in words, matching what TactilityKernel's Thread wrapper does - // with the stack size it's given. - BaseType_t create_result = xTaskCreate(app_task_main, task_name, 8192 / sizeof(StackType_t), context, APP_TASK_PRIORITY, &task_handle); + // 8192 bytes -> stack depth in words, matching what TactilityKernel's Thread wrapper does with the stack size it's given. + // Created at idle priority so it can't preempt us before vTaskSuspend() below runs, then suspended immediately - + // the ledger must record the handle (set_task()) before the task can possibly observe or erase its own entry. + // (see app_scheduler_stop()'s liveness check and app_task_main()'s exit path) + BaseType_t create_result = xTaskCreate(app_task_main, task_name, 8192 / sizeof(StackType_t), context, tskIDLE_PRIORITY, &task_handle); if (create_result != pdPASS) { delete context; loader->unload(runtime); app_ledger_free_arguments(argc, argv); return ERROR_OUT_OF_MEMORY; } + vTaskSuspend(task_handle); set_task(app_instance_id, task_handle); + vTaskPrioritySet(task_handle, APP_TASK_PRIORITY); + vTaskResume(task_handle); return ERROR_NONE; } error_t app_scheduler_stop(AppInstanceId app_instance_id, TickType_t join_timeout) { - TaskHandle_t task = get_task(app_instance_id); - if (task != nullptr) { + // Drain any stale notification credit before registering as the waiter - otherwise a + // leftover give from an unrelated earlier wait on this same task (e.g. a previous + // app_scheduler_stop() call that timed out and only got notified afterward) could make the + // take below return immediately for the wrong event. Mirrors app_event_await()'s same + // defensive drain. + ulTaskNotifyTake(pdTRUE, 0); + + if (register_stop_waiter(app_instance_id)) { AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; app_event_emit(app_instance_id, &event); - // Poll for the task to clear its own ledger entry (see app_task_main()) - plain - // FreeRTOS has no built-in task-join primitive. - TickType_t start_ticks = get_ticks(); - while (get_task(app_instance_id) != nullptr) { - delay_ticks(pdMS_TO_TICKS(10)); - if (get_ticks() - start_ticks > join_timeout) { - LOG_W(TAG, "App instance %u did not stop in time", app_instance_id); - return ERROR_TIMEOUT; - } + // Blocks until app_task_main() gives this notification as the literal last thing it + // does before vTaskDelete() - unlike polling the ledger for the task handle to clear, + // this can't observe "stopped" while the task is still mid-exit (still running its own + // cleanup/vTaskDelete()). + if (ulTaskNotifyTake(pdTRUE, join_timeout) == 0) { + LOG_W(TAG, "App instance %u did not stop in time", app_instance_id); + return ERROR_TIMEOUT; } } diff --git a/Modules/app-module/source/symbols.cpp b/Modules/app-module/source/symbols.cpp index 7a1b38a3a..a6d00b2b6 100644 --- a/Modules/app-module/source/symbols.cpp +++ b/Modules/app-module/source/symbols.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #include #include #include @@ -30,6 +31,16 @@ const ModuleSymbol app_module_symbols[] = { DEFINE_MODULE_SYMBOL(app_manager_get_state), DEFINE_MODULE_SYMBOL(app_manager_find_manifest), DEFINE_MODULE_SYMBOL(app_manager_for_each_manifest), + DEFINE_MODULE_SYMBOL(app_manager_add), + DEFINE_MODULE_SYMBOL(app_manager_remove), + DEFINE_MODULE_SYMBOL(app_manager_get_topmost_instance_id), + DEFINE_MODULE_SYMBOL(app_manager_get_topmost_app_id), + DEFINE_MODULE_SYMBOL(app_manager_install_path_add), + DEFINE_MODULE_SYMBOL(app_manager_install_path_scan), + // app/install + DEFINE_MODULE_SYMBOL(app_get_install_path), + DEFINE_MODULE_SYMBOL(app_install), + DEFINE_MODULE_SYMBOL(app_uninstall), // terminator MODULE_SYMBOL_TERMINATOR }; diff --git a/Modules/lvgl-window-manager-module/LICENSE-Apache-2.0.md b/Modules/lvgl-window-manager-module/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Modules/lvgl-window-manager-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h index 71c7022ba..7a4ba6044 100644 --- a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h +++ b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h @@ -101,6 +101,10 @@ enum WindowState window_manager_get_state(WindowId id); * Blocks the calling task until @a id's state changes away from WINDOW_STATE_GRANTED, or * @a timeout elapses. Returns immediately with WINDOW_STATE_REVOKED if @a id isn't currently * topmost (nothing to wait for). + * @warning At most one task may have an outstanding await() call per window at a time (each + * window tracks a single waiter). A second concurrent call for the same @a id asserts. Calls + * for different windows (e.g. from different app tasks in a stacked window manager) don't + * conflict with each other. * @return the state after waking (or immediately, if there was nothing to wait for) */ enum WindowState window_manager_await_state_change(WindowId id, TickType_t timeout); diff --git a/Modules/lvgl-window-manager-module/source/window_manager.cpp b/Modules/lvgl-window-manager-module/source/window_manager.cpp index dda814abe..7b1d8df31 100644 --- a/Modules/lvgl-window-manager-module/source/window_manager.cpp +++ b/Modules/lvgl-window-manager-module/source/window_manager.cpp @@ -3,11 +3,14 @@ #include +#include #include #include #include +constexpr auto* TAG = "window_manager"; + namespace { struct WindowRecord { @@ -15,6 +18,13 @@ struct WindowRecord { uint32_t app_instance_id; WindowCreateWidgetsFn create_widgets; void* user_data; + + /** Task blocked in window_manager_await_state_change() for this specific window, if any - + * see that function's @warning on at most one concurrent awaiter per window. Per-window + * rather than a single manager-wide slot, since a stacked window manager serving several + * app tasks can have more than one window (though only ever one of them topmost/GRANTED at + * a time) with a live await() call outstanding. */ + TaskHandle_t waiting_task = nullptr; }; struct WindowManagerState { @@ -44,9 +54,6 @@ struct WindowManagerState { std::vector windows; lv_obj_t* top_widget = nullptr; - /** Task blocked in window_manager_await_state_change(), if any. */ - TaskHandle_t waiting_task = nullptr; - WindowManagerState() { mutex_construct(&mutex); mutex_construct(&lifecycle_mutex); @@ -90,9 +97,19 @@ extern "C" { void window_manager_configure(WindowManagerScreenInitFn screen_init) { auto& s = state(); + + // Serializes against window_manager_start()/stop() + mutex_lock(&s.lifecycle_mutex); + mutex_lock(&s.mutex); - s.screen_init = screen_init; + if (!s.started) { + s.screen_init = screen_init; + } else { + LOG_W(TAG, "Ignoring window_manager_configure: module is already started"); + } mutex_unlock(&s.mutex); + + mutex_unlock(&s.lifecycle_mutex); } error_t window_manager_start(void) { @@ -161,16 +178,22 @@ error_t window_manager_stop(void) { return ERROR_NONE; } lv_obj_t* widget = s.real_root_widget; - TaskHandle_t waiter = s.waiting_task; + // Collect every window's waiter before clearing - normally at most the topmost window's is + // ever set, but every window is being torn down here, so every one is checked. + std::vector waiters; + for (const auto& window : s.windows) { + if (window.waiting_task != nullptr) { + waiters.push_back(window.waiting_task); + } + } s.real_root_widget = nullptr; s.content_root_widget = nullptr; s.top_widget = nullptr; s.windows.clear(); s.started = false; - s.waiting_task = nullptr; mutex_unlock(&s.mutex); - if (waiter != nullptr) { + for (TaskHandle_t waiter : waiters) { xTaskNotifyGive(waiter); } @@ -191,8 +214,13 @@ WindowId window_manager_create(uint32_t app_instance_id, WindowCreateWidgetsFn c } lv_obj_t* content = s.content_root_widget; lv_obj_t* old_top_widget = s.top_widget; - TaskHandle_t waiter = s.waiting_task; - s.waiting_task = nullptr; + // The current topmost window (if any) is about to be superseded - transfer its waiter (if + // any) here so it gets notified below, since it's no longer topmost after this. + TaskHandle_t waiter = nullptr; + if (!s.windows.empty()) { + waiter = s.windows.back().waiting_task; + s.windows.back().waiting_task = nullptr; + } s.top_widget = nullptr; WindowId new_id = s.next_id++; s.windows.push_back(WindowRecord { new_id, app_instance_id, create_widgets, user_data }); @@ -231,6 +259,11 @@ void window_manager_remove(WindowId id) { return; } bool was_topmost = (iterator + 1 == s.windows.end()); + // The window being removed owns its own waiter (if any) - a waiter is only ever registered + // while its window is topmost (see window_manager_await_state_change()), and if this window + // later stopped being topmost without being removed, window_manager_create() would already + // have transferred/cleared it - so a buried window's waiting_task is always already null. + TaskHandle_t waiter = iterator->waiting_task; s.windows.erase(iterator); lv_obj_t* content = s.content_root_widget; @@ -239,7 +272,6 @@ void window_manager_remove(WindowId id) { void* next_user_data = nullptr; WindowId next_id = 0; bool has_next = false; - TaskHandle_t waiter = nullptr; if (was_topmost) { old_widget = s.top_widget; @@ -250,11 +282,6 @@ void window_manager_remove(WindowId id) { next_id = s.windows.back().id; has_next = true; } - // Only the topmost window's state can actually change here - a waiter blocked in - // window_manager_await_state_change() is always waiting on the current top window (see - // that function), so removing a buried window never affects what it's waiting for. - waiter = s.waiting_task; - s.waiting_task = nullptr; } mutex_unlock(&s.mutex); @@ -298,17 +325,22 @@ WindowState window_manager_await_state_change(WindowId id, TickType_t timeout) { mutex_unlock(&s.mutex); return WINDOW_STATE_REVOKED; } - s.waiting_task = xTaskGetCurrentTaskHandle(); + // At most one concurrent awaiter per window - see the @warning on this function. + check(s.windows.back().waiting_task == nullptr); + s.windows.back().waiting_task = xTaskGetCurrentTaskHandle(); mutex_unlock(&s.mutex); ulTaskNotifyTake(pdTRUE, timeout); /* Deregister ourselves if a create()/remove() hasn't already claimed us (the ordinary, intended wakeup) * Otherwise a later create()/remove() could notify a task that's no longer waiting here: - * a use-after-exit on the handle if this task is gone, or a stale wakeup the next time it waits. */ + * a use-after-exit on the handle if this task is gone, or a stale wakeup the next time it waits. + * Re-locate the record by id - it may have been erased (window_manager_remove()) while we waited. */ mutex_lock(&s.mutex); - if (s.waiting_task == xTaskGetCurrentTaskHandle()) { - s.waiting_task = nullptr; + auto iterator = std::find_if(s.windows.begin(), s.windows.end(), + [id](const WindowRecord& window) { return window.id == id; }); + if (iterator != s.windows.end() && iterator->waiting_task == xTaskGetCurrentTaskHandle()) { + iterator->waiting_task = nullptr; } mutex_unlock(&s.mutex); diff --git a/TactilityC/Include/tt_app.h b/TactilityC/Include/tt_app.h deleted file mode 100644 index 874cf4d6b..000000000 --- a/TactilityC/Include/tt_app.h +++ /dev/null @@ -1,122 +0,0 @@ -#pragma once - -#include - -#include -#include - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -typedef void* AppHandle; - -/** Important: These values must map to tt::app::Result values exactly */ -typedef enum { - APP_RESULT_OK = 0, - APP_RESULT_CANCELLED = 1, - APP_RESULT_ERROR = 2 -} AppResult; - -typedef unsigned int AppLaunchId; - -/** Important: These function types must map to t::app types exactly. All void* data is nullable. */ -typedef void* (*AppCreateData)(); -typedef void (*AppDestroyData)(void* data); -typedef void (*AppOnCreate)(AppHandle app, void* data); -typedef void (*AppOnDestroy)(AppHandle app, void* data); -typedef void (*AppOnShow)(AppHandle app, void* data, lv_obj_t* parent); -typedef void (*AppOnHide)(AppHandle app, void* data); -typedef void (*AppOnResult)(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData); - -/** All callback types are nullable */ -typedef struct { - /** The application can allocate data to re-use later (e.g. struct with state) */ - AppCreateData createData; - /** If createData is specified, this one must be specified too */ - AppDestroyData destroyData; - /** Called when the app is launched (started) */ - AppOnCreate onCreate; - /** Called when the app is exited (stopped) */ - AppOnDestroy onDestroy; - /** Called when the app is about to be shown to the user (app becomes visible) */ - AppOnShow onShow; - /** Called when the app is about to be invisible to the user (e.g. other app was launched by this app, and this app goes to the background) */ - AppOnHide onHide; - /** Called when the app receives a result after launching another app */ - AppOnResult onResult; -} AppRegistration; - -/** This is used to register the manifest of an external app. */ -void tt_app_register(const AppRegistration app); - -/** @return the bundle that belongs to this application, or null if it wasn't started with parameters. */ -BundleHandle tt_app_get_parameters(AppHandle handle); - -/** - * Set the result before closing an app. - * The result and bundle are passed along to the app that launched this app, when this app is closed. - * @param[in] handle the app handle to set the result for - * @param[in] result the result state to set - * @param[in] bundle the result bundle to set (can be null) - */ -void tt_app_set_result(AppHandle handle, AppResult result, BundleHandle bundle); - -/** @return true if a result was set for this app context */ -bool tt_app_has_result(AppHandle handle); - -/** Get the path to the user data directory for this app. - * The app can store user-specific (mutable) data in there such as app settings. - * @param[in] handle the app handle - * @param[out] buffer the output buffer (recommended size is 256 bytes) - * @param[inout] size used as input for maximum buffer size (including null terminator) and is set with the path string length by this function - */ -void tt_app_get_user_data_path(AppHandle handle, char* buffer, size_t* size); - -/** Resolve a child path in the user directory of this app. - * The app can store user-specific (mutable) data in there such as app settings. - * @param[in] handle the app handle - * @param[in] childPath the child path to resolve - * @param[out] buffer the output buffer (recommended size is 256 bytes) - * @param[inout] size used as input for maximum buffer size (including null terminator) and is set with the path string length by this function - */ -void tt_app_get_user_data_child_path(AppHandle handle, const char* childPath, char* buffer, size_t* size); - -/** Get the path to the assets directory of this app. - * The content in this path should be treated as read-only. - * @param[in] handle the app handle - * @param[out] buffer the output buffer (recommended size is 256 bytes) - * @param[inout] size used as input for maximum buffer size (including null terminator) and is set with the path string length by this function - */ -void tt_app_get_assets_path(AppHandle handle, char* buffer, size_t* size); - -/** Resolve a child path in the assets directory of this app. - * The content in this path should be treated as read-only. - * @param[in] handle the app handle - * @param[in] childPath the child path to resolve - * @param[out] buffer the output buffer (recommended size is 256 bytes) - * @param[inout] size used as input for maximum buffer size (including null terminator) and is set with the path string length by this function - */ -void tt_app_get_assets_child_path(AppHandle handle, const char* childPath, char* buffer, size_t* size); - -/** - * Start an app by id. - * @param[in] appId the app manifest id - */ -void tt_app_start(const char* appId); - -/** Stop the currently running app */ -void tt_app_stop(); - -/** - * Start an app by id and bundle. - * @param[in] appId the app manifest id - * @param[in] parameters the parameters to pass onto the starting app - */ -void tt_app_start_with_bundle(const char* appId, BundleHandle parameters); - -#ifdef __cplusplus -} -#endif \ No newline at end of file diff --git a/TactilityC/Include/tt_app_alertdialog.h b/TactilityC/Include/tt_app_alertdialog.h index ff57c2eb3..6f476bdb1 100644 --- a/TactilityC/Include/tt_app_alertdialog.h +++ b/TactilityC/Include/tt_app_alertdialog.h @@ -1,7 +1,6 @@ #pragma once -#include "tt_app.h" -#include "tt_bundle.h" +#include #ifdef __cplusplus extern "C" { @@ -11,18 +10,18 @@ extern "C" { /** * Show a dialog with the provided title, message and 0, 1 or more buttons. + * @warning AlertDialog is now a new-model app (see Modules/app-module); it delivers its result + * via APP_EVENT_RESULT to a caller's app_instance_id, which side-loaded ELF apps don't have. + * The dialog will show, but this app's onResult callback will NOT be invoked with the button + * that was pressed - there is currently no bridge back to the old ELF app result mechanism. + * @param[in] parent_id parent app ID or 0 * @param[in] title the title to show in the toolbar * @param[in] message the message to display * @param[in] buttonLabels the buttons to show, or null when there are none to show * @param[in] buttonLabelCount the amount of buttons (0 or more) - * @return the launch ID of the dialog, which can be compared in onResult to identify the source + * @return the launch ID of the dialog (kept for source compatibility; no onResult will follow) */ -AppLaunchId tt_app_alertdialog_start(const char* title, const char* message, const char* buttonLabels[], uint32_t buttonLabelCount); - -/** - * @return the index of the button that was clicked (the index in the array when start() was called) - */ -int32_t tt_app_alertdialog_get_result_index(BundleHandle handle); +AppInstanceId tt_app_alertdialog_start(AppInstanceId parent_id, const char* title, const char* message, const char* buttonLabels[], uint32_t buttonLabelCount); #ifdef __cplusplus } diff --git a/TactilityC/Include/tt_app_fileselection.h b/TactilityC/Include/tt_app_fileselection.h index 453347324..659a06d28 100644 --- a/TactilityC/Include/tt_app_fileselection.h +++ b/TactilityC/Include/tt_app_fileselection.h @@ -1,7 +1,6 @@ #pragma once -#include "tt_app.h" -#include "tt_bundle.h" +#include #ifdef __cplusplus extern "C" { @@ -11,21 +10,23 @@ extern "C" { * Show a file selection dialog that allows the user to select an existing file. * @return the launch ID of the dialog, which can be compared in onResult to identify the source */ -AppLaunchId tt_app_fileselection_start_for_existing_file(); +AppInstanceId tt_app_fileselection_start_for_existing_file(AppInstanceId app_id); /** * Show a file selection dialog that allows the user to select a new or existing file. * @return the launch ID of the dialog, which can be compared in onResult to identify the source */ -AppLaunchId tt_app_fileselection_start_for_existing_or_new_file(); +AppInstanceId tt_app_fileselection_start_for_existing_or_new_file(AppInstanceId app_id); /** - * @param[in] handle the result bundle passed to onResult + * @return the path picked by the last FileSelection dialog that closed with result == Ok (see + * tt::app::fileselection::getLastPath()). Only one dialog is expected to be open at a time. * @param[out] buffer the buffer to store the selected path in * @param[in] bufferSize the size of the buffer (must include room for the null terminator) - * @return true if a path was selected and written to buffer, false otherwise + * @retval false @a bufferSize was too small - @a buffer is left untouched + * @retval true @a buffer was filled */ -bool tt_app_fileselection_get_result_path(BundleHandle handle, char* buffer, uint32_t bufferSize); +bool tt_app_fileselection_get_result_path(char* buffer, uint32_t bufferSize); #ifdef __cplusplus } diff --git a/TactilityC/Include/tt_app_selectiondialog.h b/TactilityC/Include/tt_app_selectiondialog.h index 795772cdb..005409cab 100644 --- a/TactilityC/Include/tt_app_selectiondialog.h +++ b/TactilityC/Include/tt_app_selectiondialog.h @@ -1,7 +1,6 @@ #pragma once -#include "tt_app.h" -#include "tt_bundle.h" +#include #ifdef __cplusplus extern "C" { @@ -9,15 +8,13 @@ extern "C" { /** * Start an app that displays a list of items and allows the user to select one. + * @param[in] parent_id parent app ID or 0 * @param[in] title the title to show in the toolbar * @param[in] argc the amount of items that the list contains * @param[in] argv the labels of the items in the list - * @return the launch ID of the dialog, which can be compared in onResult to identify the source + * @return the app instance ID of the dialog, which can be compared in onResult to identify the source */ -AppLaunchId tt_app_selectiondialog_start(const char* title, int argc, const char* argv[]); - -/** @return the index of the item that was clicked by the user, or -1 when the user didn't select anything */ -int32_t tt_app_selectiondialog_get_result_index(BundleHandle handle); +AppInstanceId tt_app_selectiondialog_start(AppInstanceId parent_id, const char* title, int argc, const char* argv[]); #ifdef __cplusplus } diff --git a/TactilityC/Include/tt_bundle.h b/TactilityC/Include/tt_bundle.h deleted file mode 100644 index 3c285071b..000000000 --- a/TactilityC/Include/tt_bundle.h +++ /dev/null @@ -1,74 +0,0 @@ -#pragma once - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -/** The handle that represents a bundle instance */ -typedef void* BundleHandle; - -/** @return a new bundle instance */ -BundleHandle tt_bundle_alloc(); - -/** Dealloc an existing bundle instance */ -void tt_bundle_free(BundleHandle handle); - -/** - * Try to get a boolean value from a Bundle - * @param[in] handle the handle that represents the bundle - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[out] out the output value (only set when return value is set to true) - * @return true if "out" was set - */ -bool tt_bundle_opt_bool(BundleHandle handle, const char* key, bool* out); - -/** - * Try to get an int32_t value from a Bundle - * @param[in] handle the handle that represents the bundle - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[out] out the output value (only set when return value is set to true) - * @return true if "out" was set - */ -bool tt_bundle_opt_int32(BundleHandle handle, const char* key, int32_t* out); - -/** - * Try to get a string from a Bundle - * @warning outSize must be large enough to include null terminator. This means that your string has to be the expected text length + 1 extra character. - * @param[in] handle the handle that represents the bundle - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[out] out the buffer to store the string in - * @param[in] outSize the size of the buffer - * @return true if "out" was set - */ -bool tt_bundle_opt_string(BundleHandle handle, const char* key, char* out, uint32_t outSize); - -/** - * Store a boolean value in a Bundle - * @param[in] handle the handle that represents the bundle - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[in] value the value to store - */ -void tt_bundle_put_bool(BundleHandle handle, const char* key, bool value); - -/** - * Store an int32_t value in a Bundle - * @param[in] handle the handle that represents the bundle - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[in] value the value to store - */ -void tt_bundle_put_int32(BundleHandle handle, const char* key, int32_t value); - -/** - * Store a string value in a Bundle - * @param[in] handle the handle that represents the bundle - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[in] value the value to store - */ -void tt_bundle_put_string(BundleHandle handle, const char* key, const char* value); - -#ifdef __cplusplus -} -#endif \ No newline at end of file diff --git a/TactilityC/Include/tt_preferences.h b/TactilityC/Include/tt_preferences.h deleted file mode 100644 index 735982b35..000000000 --- a/TactilityC/Include/tt_preferences.h +++ /dev/null @@ -1,83 +0,0 @@ -#pragma once - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -/** - * Note that on ESP32, there are limitations: - * - namespace name is limited by NVS_NS_NAME_MAX_SIZE (generally 16 characters) - * - key is limited by NVS_KEY_NAME_MAX_SIZE (generally 16 characters) - */ - -/** The handle that represents a Preferences instance */ -typedef void* PreferencesHandle; - -/** - * @param[in] identifier the name of the preferences. This determines the NVS namespace on ESP. - * @return a new preferences instance - */ -PreferencesHandle tt_preferences_alloc(const char* identifier); - -/** Dealloc an existing preferences instance */ -void tt_preferences_free(PreferencesHandle handle); - -/** - * Try to get a boolean value - * @param[in] handle the handle that represents the preferences - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[out] out the output value (only set when return value is set to true) - * @return true if "out" was set - */ -bool tt_preferences_opt_bool(PreferencesHandle handle, const char* key, bool* out); - -/** - * Try to get an int32_t value - * @param[in] handle the handle that represents the preferences - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[out] out the output value (only set when return value is set to true) - * @return true if "out" was set - */ -bool tt_preferences_opt_int32(PreferencesHandle handle, const char* key, int32_t* out); - -/** - * Try to get a string - * @warning outSize must be large enough to include null terminator. This means that your string has to be the expected text length + 1 extra character. - * @param[in] handle the handle that represents the preferences - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[out] out the buffer to store the string in - * @param[in] outSize the size of the buffer - * @return true if "out" was set - */ -bool tt_preferences_opt_string(PreferencesHandle handle, const char* key, char* out, uint32_t outSize); - -/** - * Store a boolean value - * @param[in] handle the handle that represents the preferences - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[in] value the value to store - */ -void tt_preferences_put_bool(PreferencesHandle handle, const char* key, bool value); - -/** - * Store an int32_t value - * @param[in] handle the handle that represents the preferences - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[in] value the value to store - */ -void tt_preferences_put_int32(PreferencesHandle handle, const char* key, int32_t value); - -/** - * Store a string value - * @param[in] handle the handle that represents the preferences - * @param[in] key the identifier that represents the stored value (~variable name) - * @param[in] value the value to store - */ -void tt_preferences_put_string(PreferencesHandle handle, const char* key, const char* value); - -#ifdef __cplusplus -} -#endif \ No newline at end of file diff --git a/TactilityC/Source/tt_app.cpp b/TactilityC/Source/tt_app.cpp deleted file mode 100644 index ad703b497..000000000 --- a/TactilityC/Source/tt_app.cpp +++ /dev/null @@ -1,133 +0,0 @@ -#include "tt_app.h" -#include -#include -#include -#include -#include - -#include - -constexpr auto* TAG = "tt_app"; - -extern "C" { - -#define HANDLE_AS_APP_CONTEXT(handle) ((tt::app::AppContext*)(handle)) - -void tt_app_register( - const AppRegistration appRegistration -) { -#ifdef ESP_PLATFORM - assert((appRegistration.createData == nullptr) == (appRegistration.destroyData == nullptr)); - tt::app::setElfAppParameters( - appRegistration.createData, - appRegistration.destroyData, - appRegistration.onCreate, - appRegistration.onDestroy, - appRegistration.onShow, - appRegistration.onHide, - reinterpret_cast(appRegistration.onResult) - ); -#else - check(false, "TactilityC is not intended for PC/Simulator"); -#endif -} - -BundleHandle tt_app_get_parameters(AppHandle handle) { - return (BundleHandle)HANDLE_AS_APP_CONTEXT(handle)->getParameters().get(); -} - -void tt_app_set_result(AppHandle handle, AppResult result, BundleHandle bundle) { - auto shared_bundle = std::unique_ptr(static_cast(bundle)); - HANDLE_AS_APP_CONTEXT(handle)->getApp()->setResult(static_cast(result), std::move(shared_bundle)); -} - -bool tt_app_has_result(AppHandle handle) { - return HANDLE_AS_APP_CONTEXT(handle)->getApp()->hasResult(); -} - -void tt_app_start(const char* appId) { - tt::app::start(appId); -} - -void tt_app_start_with_bundle(const char* appId, BundleHandle parameters) { - tt::app::start(appId, std::shared_ptr(static_cast(parameters))); -} - -void tt_app_stop() { - tt::app::stop(); -} - -void tt_app_get_user_data_path(AppHandle handle, char* buffer, size_t* size) { - assert(buffer != nullptr); - assert(size != nullptr); - assert(*size > 0); - const auto paths = HANDLE_AS_APP_CONTEXT(handle)->getPaths(); - const auto data_path = paths->getUserDataPath(); - const auto expected_length = data_path.length() + 1; - if (*size < expected_length) { - LOG_E(TAG, "Path buffer not large enough (%u < %u)", (unsigned)*size, (unsigned)expected_length); - *size = 0; - buffer[0] = 0; - return; - } - - strcpy(buffer, data_path.c_str()); - *size = data_path.length(); -} - -void tt_app_get_user_data_child_path(AppHandle handle, const char* childPath, char* buffer, size_t* size) { - assert(buffer != nullptr); - assert(size != nullptr); - assert(*size > 0); - const auto paths = HANDLE_AS_APP_CONTEXT(handle)->getPaths(); - const auto resolved_path = paths->getUserDataPath(childPath); - const auto resolved_path_length = resolved_path.length(); - if (*size < (resolved_path_length + 1)) { - LOG_E(TAG, "Path buffer not large enough (%u < %u)", (unsigned)*size, (unsigned)(resolved_path_length + 1)); - *size = 0; - buffer[0] = 0; - return; - } - - strcpy(buffer, resolved_path.c_str()); - *size = resolved_path_length; -} - -void tt_app_get_assets_path(AppHandle handle, char* buffer, size_t* size) { - assert(buffer != nullptr); - assert(size != nullptr); - assert(*size > 0); - const auto paths = HANDLE_AS_APP_CONTEXT(handle)->getPaths(); - const auto assets_path = paths->getAssetsPath(); - const auto expected_length = assets_path.length() + 1; - if (*size < expected_length) { - LOG_E(TAG, "Path buffer not large enough (%u < %u)", (unsigned)*size, (unsigned)expected_length); - *size = 0; - buffer[0] = 0; - return; - } - - strcpy(buffer, assets_path.c_str()); - *size = assets_path.length(); -} - -void tt_app_get_assets_child_path(AppHandle handle, const char* childPath, char* buffer, size_t* size) { - assert(buffer != nullptr); - assert(size != nullptr); - assert(*size > 0); - const auto paths = HANDLE_AS_APP_CONTEXT(handle)->getPaths(); - const auto resolved_path = paths->getAssetsPath(childPath); - const auto resolved_path_length = resolved_path.length(); - if (*size < (resolved_path_length + 1)) { - LOG_E(TAG, "Path buffer not large enough (%u < %u)", (unsigned)*size, (unsigned)(resolved_path_length + 1)); - *size = 0; - buffer[0] = 0; - return; - } - - strcpy(buffer, resolved_path.c_str()); - *size = resolved_path_length; - -} - -} \ No newline at end of file diff --git a/TactilityC/Source/tt_app_alertdialog.cpp b/TactilityC/Source/tt_app_alertdialog.cpp index dd8d86dec..63cb6084b 100644 --- a/TactilityC/Source/tt_app_alertdialog.cpp +++ b/TactilityC/Source/tt_app_alertdialog.cpp @@ -1,18 +1,16 @@ #include "tt_app_alertdialog.h" + #include extern "C" { -AppLaunchId tt_app_alertdialog_start(const char* title, const char* message, const char* buttonLabels[], uint32_t buttonLabelCount) { +AppInstanceId tt_app_alertdialog_start(AppInstanceId parent_id, const char* title, const char* message, const char* buttonLabels[], uint32_t buttonLabelCount) { std::vector list; for (int i = 0; i < buttonLabelCount; i++) { list.emplace_back(buttonLabels[i]); } - return tt::app::alertdialog::start(title, message, list); -} - -int32_t tt_app_alertdialog_get_result_index(BundleHandle handle) { - return tt::app::alertdialog::getResultIndex(*(tt::Bundle*)handle); + // TODO: Get caller app instance id from task context? + return tt::app::alertdialog::start(parent_id, title, message, list); } } diff --git a/TactilityC/Source/tt_app_fileselection.cpp b/TactilityC/Source/tt_app_fileselection.cpp index acdbf1e4b..7a8e64c43 100644 --- a/TactilityC/Source/tt_app_fileselection.cpp +++ b/TactilityC/Source/tt_app_fileselection.cpp @@ -1,28 +1,25 @@ #include "tt_app_fileselection.h" -#include #include -#include #include #include extern "C" { -AppLaunchId tt_app_fileselection_start_for_existing_file() { - return tt::app::fileselection::startForExistingFile(); +AppInstanceId tt_app_fileselection_start_for_existing_file(AppInstanceId app_id) { + return tt::app::fileselection::startForExistingFile(app_id); } -AppLaunchId tt_app_fileselection_start_for_existing_or_new_file() { - return tt::app::fileselection::startForExistingOrNewFile(); +AppInstanceId tt_app_fileselection_start_for_existing_or_new_file(AppInstanceId app_id) { + return tt::app::fileselection::startForExistingOrNewFile(app_id); } -bool tt_app_fileselection_get_result_path(BundleHandle handle, char* buffer, uint32_t bufferSize) { - auto path = tt::app::fileselection::getResultPath(*(tt::Bundle*)handle); - if (path.empty() || bufferSize == 0) { +bool tt_app_fileselection_get_result_path(char* buffer, uint32_t bufferSize) { + const std::string path = tt::app::fileselection::getLastPath(); + if (path.length() + 1 > bufferSize) { return false; } - strncpy(buffer, path.c_str(), bufferSize - 1); - buffer[bufferSize - 1] = '\0'; + std::strcpy(buffer, path.c_str()); return true; } diff --git a/TactilityC/Source/tt_app_selectiondialog.cpp b/TactilityC/Source/tt_app_selectiondialog.cpp index a67df0867..73b91d883 100644 --- a/TactilityC/Source/tt_app_selectiondialog.cpp +++ b/TactilityC/Source/tt_app_selectiondialog.cpp @@ -3,16 +3,12 @@ extern "C" { -AppLaunchId tt_app_selectiondialog_start(const char* title, int argc, const char* argv[]) { +AppInstanceId tt_app_selectiondialog_start(AppInstanceId parent_id, const char* title, int argc, const char* argv[]) { std::vector list; for (int i = 0; i < argc; i++) { list.emplace_back(argv[i]); } - return tt::app::selectiondialog::start(title, list); -} - -int32_t tt_app_selectiondialog_get_result_index(BundleHandle handle) { - return tt::app::selectiondialog::getResultIndex(*(tt::Bundle*)handle); + return tt::app::selectiondialog::start(parent_id, title, list); } } diff --git a/TactilityC/Source/tt_bundle.cpp b/TactilityC/Source/tt_bundle.cpp deleted file mode 100644 index 4172bf2c0..000000000 --- a/TactilityC/Source/tt_bundle.cpp +++ /dev/null @@ -1,53 +0,0 @@ -#include "tt_bundle.h" -#include -#include - -#define HANDLE_AS_BUNDLE(handle) ((tt::Bundle*)(handle)) - -extern "C" { - -BundleHandle tt_bundle_alloc() { - return new tt::Bundle(); -} - -void tt_bundle_free(BundleHandle handle) { - delete HANDLE_AS_BUNDLE(handle); -} - -bool tt_bundle_opt_bool(BundleHandle handle, const char* key, bool* out) { - return HANDLE_AS_BUNDLE(handle)->optBool(key, *out); -} - -bool tt_bundle_opt_int32(BundleHandle handle, const char* key, int32_t* out) { - return HANDLE_AS_BUNDLE(handle)->optInt32(key, *out); -} -bool tt_bundle_opt_string(BundleHandle handle, const char* key, char* out, uint32_t outSize) { - std::string out_string; - - if (!HANDLE_AS_BUNDLE(handle)->optString(key, out_string)) { - return false; - } - - if (out_string.length() >= outSize) { - // Need 1 byte to add 0 at the end - return false; - } - - memcpy(out, out_string.c_str(), out_string.length()); - out[out_string.length()] = 0x00; - return true; -} - -void tt_bundle_put_bool(BundleHandle handle, const char* key, bool value) { - HANDLE_AS_BUNDLE(handle)->putBool(key, value); -} - -void tt_bundle_put_int32(BundleHandle handle, const char* key, int32_t value) { - HANDLE_AS_BUNDLE(handle)->putInt32(key, value); -} - -void tt_bundle_put_string(BundleHandle handle, const char* key, const char* value) { - HANDLE_AS_BUNDLE(handle)->putString(key, value); -} - -} \ No newline at end of file diff --git a/TactilityC/Source/tt_init.cpp b/TactilityC/Source/tt_init.cpp index 296b9f68b..74f8bea41 100644 --- a/TactilityC/Source/tt_init.cpp +++ b/TactilityC/Source/tt_init.cpp @@ -1,11 +1,8 @@ #ifdef ESP_PLATFORM -#include "tt_app.h" #include "tt_app_alertdialog.h" #include "tt_app_fileselection.h" #include "tt_app_selectiondialog.h" -#include "tt_bundle.h" -#include "tt_preferences.h" #include "tt_time.h" #include "symbols/cplusplus.h" @@ -260,40 +257,11 @@ const esp_elfsym main_symbols[] { ESP_ELFSYM_EXPORT(esp_log_timestamp), ESP_ELFSYM_EXPORT(esp_err_to_name), // Tactility - ESP_ELFSYM_EXPORT(tt_app_start), - ESP_ELFSYM_EXPORT(tt_app_start_with_bundle), - ESP_ELFSYM_EXPORT(tt_app_stop), - ESP_ELFSYM_EXPORT(tt_app_register), - ESP_ELFSYM_EXPORT(tt_app_get_parameters), - ESP_ELFSYM_EXPORT(tt_app_set_result), - ESP_ELFSYM_EXPORT(tt_app_has_result), ESP_ELFSYM_EXPORT(tt_app_fileselection_start_for_existing_file), ESP_ELFSYM_EXPORT(tt_app_fileselection_start_for_existing_or_new_file), ESP_ELFSYM_EXPORT(tt_app_fileselection_get_result_path), ESP_ELFSYM_EXPORT(tt_app_selectiondialog_start), - ESP_ELFSYM_EXPORT(tt_app_selectiondialog_get_result_index), ESP_ELFSYM_EXPORT(tt_app_alertdialog_start), - ESP_ELFSYM_EXPORT(tt_app_alertdialog_get_result_index), - ESP_ELFSYM_EXPORT(tt_app_get_user_data_path), - ESP_ELFSYM_EXPORT(tt_app_get_user_data_child_path), - ESP_ELFSYM_EXPORT(tt_app_get_assets_path), - ESP_ELFSYM_EXPORT(tt_app_get_assets_child_path), - ESP_ELFSYM_EXPORT(tt_bundle_alloc), - ESP_ELFSYM_EXPORT(tt_bundle_free), - ESP_ELFSYM_EXPORT(tt_bundle_opt_bool), - ESP_ELFSYM_EXPORT(tt_bundle_opt_int32), - ESP_ELFSYM_EXPORT(tt_bundle_opt_string), - ESP_ELFSYM_EXPORT(tt_bundle_put_bool), - ESP_ELFSYM_EXPORT(tt_bundle_put_int32), - ESP_ELFSYM_EXPORT(tt_bundle_put_string), - ESP_ELFSYM_EXPORT(tt_preferences_alloc), - ESP_ELFSYM_EXPORT(tt_preferences_free), - ESP_ELFSYM_EXPORT(tt_preferences_opt_bool), - ESP_ELFSYM_EXPORT(tt_preferences_opt_int32), - ESP_ELFSYM_EXPORT(tt_preferences_opt_string), - ESP_ELFSYM_EXPORT(tt_preferences_put_bool), - ESP_ELFSYM_EXPORT(tt_preferences_put_int32), - ESP_ELFSYM_EXPORT(tt_preferences_put_string), ESP_ELFSYM_EXPORT(tt_timezone_set), ESP_ELFSYM_EXPORT(tt_timezone_get_name), ESP_ELFSYM_EXPORT(tt_timezone_get_code), diff --git a/TactilityC/Source/tt_preferences.cpp b/TactilityC/Source/tt_preferences.cpp deleted file mode 100644 index 5aeb6d1d7..000000000 --- a/TactilityC/Source/tt_preferences.cpp +++ /dev/null @@ -1,53 +0,0 @@ -#include "tt_preferences.h" -#include -#include - -#define HANDLE_AS_PREFERENCES(handle) ((tt::Preferences*)(handle)) - -extern "C" { - -PreferencesHandle tt_preferences_alloc(const char* identifier) { - return new tt::Preferences(identifier); -} - -void tt_preferences_free(PreferencesHandle handle) { - delete HANDLE_AS_PREFERENCES(handle); -} - -bool tt_preferences_opt_bool(PreferencesHandle handle, const char* key, bool* out) { - return HANDLE_AS_PREFERENCES(handle)->optBool(key, *out); -} - -bool tt_preferences_opt_int32(PreferencesHandle handle, const char* key, int32_t* out) { - return HANDLE_AS_PREFERENCES(handle)->optInt32(key, *out); -} -bool tt_preferences_opt_string(PreferencesHandle handle, const char* key, char* out, uint32_t outSize) { - std::string out_string; - - if (!HANDLE_AS_PREFERENCES(handle)->optString(key, out_string)) { - return false; - } - - if (out_string.length() >= outSize) { - // Need 1 byte to add 0 at the end - return false; - } - - memcpy(out, out_string.c_str(), out_string.length()); - out[out_string.length()] = 0x00; - return true; -} - -void tt_preferences_put_bool(PreferencesHandle handle, const char* key, bool value) { - HANDLE_AS_PREFERENCES(handle)->putBool(key, value); -} - -void tt_preferences_put_int32(PreferencesHandle handle, const char* key, int32_t value) { - HANDLE_AS_PREFERENCES(handle)->putInt32(key, value); -} - -void tt_preferences_put_string(PreferencesHandle handle, const char* key, const char* value) { - HANDLE_AS_PREFERENCES(handle)->putString(key, value); -} - -} \ No newline at end of file diff --git a/TactilityKernel/include/tactility/bundle.h b/TactilityKernel/include/tactility/bundle.h new file mode 100644 index 000000000..553ad8517 --- /dev/null +++ b/TactilityKernel/include/tactility/bundle.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * @brief key-value storage for general purpose. + * Maps strings on a fixed set of data types. + */ +#pragma once + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * A dictionary that maps keys (strings) onto several atomary types. + * Opaque handle - allocate with bundle_alloc(), release with bundle_free(). + */ +typedef struct Bundle Bundle; + +Bundle* bundle_alloc(void); +Bundle* bundle_clone(const Bundle* bundle); +void bundle_free(Bundle* bundle); + +/** @warning Undefined if @a key is absent or not a bool - check with bundle_has_bool()/bundle_opt_bool() first. */ +bool bundle_get_bool(const Bundle* bundle, const char* key); +/** @warning Undefined if @a key is absent or not an int32 - check with bundle_has_int32()/bundle_opt_int32() first. */ +int32_t bundle_get_int32(const Bundle* bundle, const char* key); +/** @warning Undefined if @a key is absent or not an int64 - check with bundle_has_int64()/bundle_opt_int64() first. */ +int64_t bundle_get_int64(const Bundle* bundle, const char* key); +/** + * @warning Undefined if @a key is absent or not a string - check with bundle_has_string()/bundle_opt_string() first. + * @retval ERROR_BUFFER_OVERFLOW out_value_size is too small + * @retval ERROR_NONE on success + */ +error_t bundle_get_string(const Bundle* bundle, const char* key, char* out_value, size_t out_value_size); + +bool bundle_has_bool(const Bundle* bundle, const char* key); +bool bundle_has_int32(const Bundle* bundle, const char* key); +bool bundle_has_int64(const Bundle* bundle, const char* key); +bool bundle_has_string(const Bundle* bundle, const char* key); + +bool bundle_opt_bool(const Bundle* bundle, const char* key, bool* out_value); +bool bundle_opt_int32(const Bundle* bundle, const char* key, int32_t* out_value); +bool bundle_opt_int64(const Bundle* bundle, const char* key, int64_t* out_value); +/** + * @retval ERROR_NOT_FOUND @a key is absent or not a string - @a out_value is left untouched + * @retval ERROR_BUFFER_OVERFLOW out_value_size is too small - @a out_value is left untouched + * @retval ERROR_NONE on success + */ +error_t bundle_opt_string(const Bundle* bundle, const char* key, char* out_value, size_t out_value_size); + +void bundle_put_bool(Bundle* bundle, const char* key, bool value); +void bundle_put_int32(Bundle* bundle, const char* key, int32_t value); +void bundle_put_int64(Bundle* bundle, const char* key, int64_t value); +void bundle_put_string(Bundle* bundle, const char* key, const char* value); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/include/tactility/preferences.h b/TactilityKernel/include/tactility/preferences.h new file mode 100644 index 000000000..908086bf4 --- /dev/null +++ b/TactilityKernel/include/tactility/preferences.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * @brief Key-value settings, persisted as a .properties file on disk (instead of NVS/in-memory). + */ +#pragma once + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Opaque handle - open with preferences_open(), release with preferences_close(). + */ +typedef struct Preferences Preferences; + +/** + * Open (or create) a preferences store backed by the properties file at @a path. The file is + * read into memory now; changes made with preferences_put_*() are only written back to disk by + * preferences_close(). + * @param[in] path absolute or relative file path (e.g. "/data/settings.properties") - the + * parent directory must already exist + * @return the new instance, or NULL on allocation failure + */ +Preferences* preferences_open(const char* path); + +/** Writes any pending preferences_put_*() changes to the backing file, then releases the + * instance. */ +void preferences_close(Preferences* preferences); + +bool preferences_has_bool(const Preferences* preferences, const char* key); +bool preferences_has_int32(const Preferences* preferences, const char* key); +bool preferences_has_int64(const Preferences* preferences, const char* key); +bool preferences_has_string(const Preferences* preferences, const char* key); + +bool preferences_opt_bool(const Preferences* preferences, const char* key, bool* out_value); +bool preferences_opt_int32(const Preferences* preferences, const char* key, int32_t* out_value); +bool preferences_opt_int64(const Preferences* preferences, const char* key, int64_t* out_value); +/** + * @retval ERROR_NOT_FOUND @a key is absent or not a string - @a out_value is left untouched + * @retval ERROR_BUFFER_OVERFLOW out_value_size is too small - @a out_value is left untouched + * @retval ERROR_NONE on success + */ +error_t preferences_opt_string(const Preferences* preferences, const char* key, char* out_value, size_t out_value_size); + +/** Sets the value in the in-memory cache; only persisted to the backing file by + * preferences_close(). */ +void preferences_put_bool(Preferences* preferences, const char* key, bool value); +void preferences_put_int32(Preferences* preferences, const char* key, int32_t value); +void preferences_put_int64(Preferences* preferences, const char* key, int64_t value); +void preferences_put_string(Preferences* preferences, const char* key, const char* value); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/include/tactility/properties_file.h b/TactilityKernel/include/tactility/properties_file.h new file mode 100644 index 000000000..a08499c03 --- /dev/null +++ b/TactilityKernel/include/tactility/properties_file.h @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * @brief Generic string key-value ".properties" file. + * @note Safely acquires/releases the filesystem mutex registered for the file's path (see + * tactility/filesystem/file_mutex.h) - manual locking isn't needed. + */ +#pragma once + +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Opaque handle - open with properties_file_open(), release with properties_file_close(). + */ +typedef struct PropertiesFile PropertiesFile; + +/** + * Open (or create) a properties file at @a path. The file is read into memory now; changes + * made with properties_file_set() are only written back to disk by properties_file_close(). + * @param[in] path absolute or relative file path (e.g. "/data/settings.properties") - the + * parent directory must already exist + * @return the new instance, or NULL on allocation failure + */ +PropertiesFile* properties_file_open(const char* path); + +/** Writes any pending properties_file_set() changes to the backing file, then releases the + * instance. */ +void properties_file_close(PropertiesFile* file); + +bool properties_file_has(const PropertiesFile* file, const char* key); + +/** + * @retval ERROR_NOT_FOUND @a key is absent - @a out_value is left untouched + * @retval ERROR_BUFFER_OVERFLOW out_value_size is too small - @a out_value is left untouched + * @retval ERROR_NONE on success + */ +error_t properties_file_get(const PropertiesFile* file, const char* key, char* out_value, size_t out_value_size); + +/** Sets the value in the in-memory cache; only persisted to the backing file by + * properties_file_close(). */ +void properties_file_set(PropertiesFile* file, const char* key, const char* value); + +typedef void (*PropertiesFileVisitorFn)(const char* key, const char* value, void* context); + +/** Invokes @a visitor for every key currently cached, in unspecified order. */ +void properties_file_for_each(const PropertiesFile* file, PropertiesFileVisitorFn visitor, void* context); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/include/tactility/system_event.h b/TactilityKernel/include/tactility/system_event.h index 9b4cb967b..826da6542 100644 --- a/TactilityKernel/include/tactility/system_event.h +++ b/TactilityKernel/include/tactility/system_event.h @@ -28,20 +28,6 @@ enum SystemEventType { KERNEL_EVENT_TIME_CHANGED, // No data - fired whenever system time is set (NTP sync, RTC restore, manual change) }; -/** - * A system-wide event as delivered to a system_event_callback_t. - * `data` points at the type-specific struct documented next to `type`'s enum value - * in SystemEventType (or is NULL when none is documented). - * It is only valid for the duration of the callback. - */ -struct SystemEvent { - enum SystemEventType type; - /** Microseconds since boot, from get_micros_since_boot(). */ - uint64_t timestamp; - const void *data; - size_t data_len; -}; - /** Data for KERNEL_EVENT_NETWORK_CONNECTED. */ struct NetworkConnectedEvent { struct Device* device; @@ -74,6 +60,26 @@ struct ServiceStoppedEvent { const char* id; }; +/** Size of the largest type-specific event struct documented in SystemEventType, i.e. the + * embedded buffer size needed by SystemEvent/SystemEventSubscription to hold any event's + * payload by value. */ +#define SYSTEM_EVENT_MAX_DATA_SIZE (sizeof(struct NetworkConnectedEvent)) + +/** + * A system-wide event as delivered to a system_event_callback_t. + * `data` (up to `data_len` bytes, `SYSTEM_EVENT_MAX_DATA_SIZE` max) is a by-value copy of the + * type-specific struct documented next to `type`'s enum value in SystemEventType (or unused, + * with `data_len` 0, when none is documented) - like SystemEventSubscription's `data`, but only + * valid for the duration of the callback rather than for the subscription's lifetime. + */ +struct SystemEvent { + enum SystemEventType type; + /** Microseconds since boot, from get_micros_since_boot(). */ + uint64_t timestamp; + uint8_t data[SYSTEM_EVENT_MAX_DATA_SIZE]; + size_t data_len; +}; + /** * @param[in] event the event being delivered; only valid for the duration of the call * @param[in] context the context pointer passed to system_event_callback_add() @@ -133,31 +139,30 @@ error_t system_event_emit( #define SYSTEM_EVENT_MAX_DATA_SIZE (sizeof(struct NetworkConnectedEvent)) /** - * gps.h-style poll subscription: caller-owned node, registered with system_event_subscribe() - * and polled with system_event_await(). Unlike system_event_callback_t, the payload is copied - * by value into @a data (up to SYSTEM_EVENT_MAX_DATA_SIZE bytes) so it remains valid after - * system_event_emit() returns. - * @warning Fields other than `type` are for internal use only; do not read or write them - * directly. + * Poll subscription: caller-owned node, registered with system_event_subscribe() + * and polled with system_event_await(). Unlike system_event_callback_t, `event` is a by-value + * copy that remains valid for the subscription's lifetime (until the next matching event + * overwrites it), not just for the duration of a callback. */ struct SystemEventSubscription { - /** Event type to subscribe to; set by the caller before system_event_subscribe(). */ - enum SystemEventType type; - - /** Own wakeup signal, not the subscribing task's shared default notification value - a - * task with more than one poll subscription would otherwise have events for one - * subscription wake (and consume the notification meant for) system_event_await() calls - * on another. */ - SemaphoreHandle_t semaphore; - - uint64_t timestamp; - uint8_t data[SYSTEM_EVENT_MAX_DATA_SIZE]; - size_t data_len; - - uint32_t sequence; - uint32_t consumed_sequence; - - struct SystemEventSubscription* next; + /** `event.type` is the event type to subscribe to; set by the caller before + * system_event_subscribe(). The rest of `event` (timestamp/data/data_len) is populated by + * each matching system_event_emit() - see the @warning above. */ + struct SystemEvent event; + + /** Implementation-only bookkeeping; do not read or write directly. */ + struct { + /** Own wakeup signal, not the subscribing task's shared default notification value - a + * task with more than one poll subscription would otherwise have events for one + * subscription wake (and consume the notification meant for) system_event_await() + * calls on another. */ + SemaphoreHandle_t semaphore; + + uint32_t sequence; + uint32_t consumed_sequence; + + struct SystemEventSubscription* next; + } internal; }; /** @@ -188,6 +193,18 @@ error_t system_event_unsubscribe(struct SystemEventSubscription* sub); */ error_t system_event_await(struct SystemEventSubscription* sub, TickType_t timeout); +/** + * Copies @a sub's current event payload (the data from the most recent system_event_emit() + * that reached it) into @a data. + * @param[in] sub subscription to read the payload from, as passed to system_event_subscribe() + * @param[out] data buffer to copy the payload into + * @param[in] data_len size of @a data + * @retval ERROR_NONE on success + * @retval ERROR_BUFFER_OVERFLOW @a data_len is smaller than the stored payload - @a data is + * left untouched + */ +error_t system_event_get_data(struct SystemEventSubscription* sub, uint8_t* data, size_t data_len); + #ifdef __cplusplus } #endif diff --git a/TactilityKernel/source/bundle.cpp b/TactilityKernel/source/bundle.cpp new file mode 100644 index 000000000..92b49984f --- /dev/null +++ b/TactilityKernel/source/bundle.cpp @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include +#include + +namespace { + +enum class Type { + Bool, + Int32, + Int64, + String, +}; + +struct Value { + Type type; + union { + bool value_bool; + int32_t value_int32; + int64_t value_int64; + }; + std::string value_string; +}; + +} // namespace + +// Definition of the opaque handle declared in tactility/bundle.h - C callers only ever see it +// through a Bundle* pointer, never its members. +struct Bundle { + std::unordered_map entries; +}; + +extern "C" { + +Bundle* bundle_alloc(void) { + return new (std::nothrow) Bundle(); +} + +Bundle* bundle_clone(const Bundle* bundle) { + auto* clone = new (std::nothrow) Bundle(); + if (clone != nullptr) { + clone->entries = bundle->entries; + } + return clone; +} + +void bundle_free(Bundle* bundle) { + delete bundle; +} + +bool bundle_get_bool(const Bundle* bundle, const char* key) { + return bundle->entries.find(key)->second.value_bool; +} + +int32_t bundle_get_int32(const Bundle* bundle, const char* key) { + return bundle->entries.find(key)->second.value_int32; +} + +int64_t bundle_get_int64(const Bundle* bundle, const char* key) { + return bundle->entries.find(key)->second.value_int64; +} + +error_t bundle_get_string(const Bundle* bundle, const char* key, char* out_value, size_t out_value_size) { + const std::string& value = bundle->entries.find(key)->second.value_string; + if (value.size() + 1 > out_value_size) { + return ERROR_BUFFER_OVERFLOW; + } + std::memcpy(out_value, value.c_str(), value.size() + 1); + return ERROR_NONE; +} + +bool bundle_has_bool(const Bundle* bundle, const char* key) { + auto entry = bundle->entries.find(key); + return entry != bundle->entries.end() && entry->second.type == Type::Bool; +} + +bool bundle_has_int32(const Bundle* bundle, const char* key) { + auto entry = bundle->entries.find(key); + return entry != bundle->entries.end() && entry->second.type == Type::Int32; +} + +bool bundle_has_int64(const Bundle* bundle, const char* key) { + auto entry = bundle->entries.find(key); + return entry != bundle->entries.end() && entry->second.type == Type::Int64; +} + +bool bundle_has_string(const Bundle* bundle, const char* key) { + auto entry = bundle->entries.find(key); + return entry != bundle->entries.end() && entry->second.type == Type::String; +} + +bool bundle_opt_bool(const Bundle* bundle, const char* key, bool* out_value) { + auto entry = bundle->entries.find(key); + if (entry != bundle->entries.end() && entry->second.type == Type::Bool) { + *out_value = entry->second.value_bool; + return true; + } + return false; +} + +bool bundle_opt_int32(const Bundle* bundle, const char* key, int32_t* out_value) { + auto entry = bundle->entries.find(key); + if (entry != bundle->entries.end() && entry->second.type == Type::Int32) { + *out_value = entry->second.value_int32; + return true; + } + return false; +} + +bool bundle_opt_int64(const Bundle* bundle, const char* key, int64_t* out_value) { + auto entry = bundle->entries.find(key); + if (entry != bundle->entries.end() && entry->second.type == Type::Int64) { + *out_value = entry->second.value_int64; + return true; + } + return false; +} + +error_t bundle_opt_string(const Bundle* bundle, const char* key, char* out_value, size_t out_value_size) { + auto entry = bundle->entries.find(key); + if (entry == bundle->entries.end() || entry->second.type != Type::String) { + return ERROR_NOT_FOUND; + } + const std::string& value = entry->second.value_string; + if (value.size() + 1 > out_value_size) { + return ERROR_BUFFER_OVERFLOW; + } + std::memcpy(out_value, value.c_str(), value.size() + 1); + return ERROR_NONE; +} + +void bundle_put_bool(Bundle* bundle, const char* key, bool value) { + bundle->entries[key] = Value { .type = Type::Bool, .value_bool = value, .value_string = "" }; +} + +void bundle_put_int32(Bundle* bundle, const char* key, int32_t value) { + bundle->entries[key] = Value { .type = Type::Int32, .value_int32 = value, .value_string = "" }; +} + +void bundle_put_int64(Bundle* bundle, const char* key, int64_t value) { + bundle->entries[key] = Value { .type = Type::Int64, .value_int64 = value, .value_string = "" }; +} + +void bundle_put_string(Bundle* bundle, const char* key, const char* value) { + bundle->entries[key] = Value { .type = Type::String, .value_bool = false, .value_string = value }; +} + +} // extern "C" diff --git a/TactilityKernel/source/preferences.cpp b/TactilityKernel/source/preferences.cpp new file mode 100644 index 000000000..ee77e436b --- /dev/null +++ b/TactilityKernel/source/preferences.cpp @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Escapes '\\' and '\n' so a string value can never break properties_file's one-entry-per-line +// on-disk format, regardless of its content. +std::string escape(const std::string& value) { + std::string result; + result.reserve(value.size()); + for (char c : value) { + if (c == '\\') { + result += "\\\\"; + } else if (c == '\n') { + result += "\\n"; + } else { + result += c; + } + } + return result; +} + +std::string unescape(const std::string& value) { + std::string result; + result.reserve(value.size()); + for (size_t i = 0; i < value.size(); i++) { + if (value[i] == '\\' && i + 1 < value.size()) { + i++; + result += (value[i] == 'n') ? '\n' : value[i]; + } else { + result += value[i]; + } + } + return result; +} + +// Splits a tagged value ("b:1", "i32:42", "i64:123", "s:escaped text") into its type tag and +// raw payload. Returns false if there's no ':' separator (malformed/missing). +bool split_tag(const std::string& tagged_value, std::string& tag, std::string& raw_value) { + size_t colon = tagged_value.find(':'); + if (colon == std::string::npos) { + return false; + } + tag = tagged_value.substr(0, colon); + raw_value = tagged_value.substr(colon + 1); + return true; +} + +bool ensure_directory(const std::string& path) { + struct stat info {}; + if (stat(path.c_str(), &info) == 0) { + return (info.st_mode & S_IFMT) == S_IFDIR; + } + return mkdir(path.c_str(), 0777) == 0; +} + +// mkdir -p. +bool ensure_directory_recursive(const std::string& path) { + for (size_t index = path.find('/', 1); index != std::string::npos; index = path.find('/', index + 1)) { + if (!ensure_directory(path.substr(0, index))) { + return false; + } + } + return ensure_directory(path); +} + +} // namespace + +// Definition of the opaque handle declared in tactility/preferences.h - C callers only ever +// see it through a Preferences* pointer, never its members. Backed by a PropertiesFile +// (tactility/properties_file.h) rather than its own file I/O - each value is stored as a +// tagged string ("b:1", "i32:42", "i64:123", "s:escaped text") since PropertiesFile only knows +// about plain strings. +struct Preferences { + PropertiesFile* file; +}; + +namespace { + +// Grow-and-retry: properties_file_get() needs a bounded buffer, and a string preference's +// value (unlike bool/int32/int64's short encodings) can be arbitrarily long. +bool try_get_tagged(const PropertiesFile* file, const char* key, std::string& tag, std::string& raw_value) { + std::vector buffer(32); + while (true) { + error_t error = properties_file_get(file, key, buffer.data(), buffer.size()); + if (error == ERROR_NONE) { + return split_tag(std::string(buffer.data()), tag, raw_value); + } + if (error == ERROR_NOT_FOUND) { + return false; + } + buffer.resize(buffer.size() * 2); + } +} + +} // namespace + +extern "C" { + +Preferences* preferences_open(const char* path) { + PropertiesFile* file = properties_file_open(path); + if (file == nullptr) { + return nullptr; + } + + auto* preferences = new (std::nothrow) Preferences { file }; + if (preferences == nullptr) { + properties_file_close(file); + return nullptr; + } + return preferences; +} + +void preferences_close(Preferences* preferences) { + properties_file_close(preferences->file); + delete preferences; +} + +bool preferences_has_bool(const Preferences* preferences, const char* key) { + std::string tag, raw_value; + return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "b"; +} + +bool preferences_has_int32(const Preferences* preferences, const char* key) { + std::string tag, raw_value; + return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "i32"; +} + +bool preferences_has_int64(const Preferences* preferences, const char* key) { + std::string tag, raw_value; + return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "i64"; +} + +bool preferences_has_string(const Preferences* preferences, const char* key) { + std::string tag, raw_value; + return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "s"; +} + +bool preferences_opt_bool(const Preferences* preferences, const char* key, bool* out_value) { + std::string tag, raw_value; + if (!try_get_tagged(preferences->file, key, tag, raw_value) || tag != "b") { + return false; + } + *out_value = (raw_value == "1"); + return true; +} + +bool preferences_opt_int32(const Preferences* preferences, const char* key, int32_t* out_value) { + std::string tag, raw_value; + if (!try_get_tagged(preferences->file, key, tag, raw_value) || tag != "i32") { + return false; + } + *out_value = static_cast(std::strtol(raw_value.c_str(), nullptr, 10)); + return true; +} + +bool preferences_opt_int64(const Preferences* preferences, const char* key, int64_t* out_value) { + std::string tag, raw_value; + if (!try_get_tagged(preferences->file, key, tag, raw_value) || tag != "i64") { + return false; + } + *out_value = static_cast(std::strtoll(raw_value.c_str(), nullptr, 10)); + return true; +} + +error_t preferences_opt_string(const Preferences* preferences, const char* key, char* out_value, size_t out_value_size) { + std::string tag, raw_value; + if (!try_get_tagged(preferences->file, key, tag, raw_value) || tag != "s") { + return ERROR_NOT_FOUND; + } + std::string value = unescape(raw_value); + if (value.size() + 1 > out_value_size) { + return ERROR_BUFFER_OVERFLOW; + } + std::memcpy(out_value, value.c_str(), value.size() + 1); + return ERROR_NONE; +} + +void preferences_put_bool(Preferences* preferences, const char* key, bool value) { + properties_file_set(preferences->file, key, value ? "b:1" : "b:0"); +} + +void preferences_put_int32(Preferences* preferences, const char* key, int32_t value) { + char buffer[32]; + std::snprintf(buffer, sizeof(buffer), "i32:%" PRId32, value); + properties_file_set(preferences->file, key, buffer); +} + +void preferences_put_int64(Preferences* preferences, const char* key, int64_t value) { + char buffer[40]; + std::snprintf(buffer, sizeof(buffer), "i64:%" PRId64, value); + properties_file_set(preferences->file, key, buffer); +} + +void preferences_put_string(Preferences* preferences, const char* key, const char* value) { + std::string tagged = "s:" + escape(value); + properties_file_set(preferences->file, key, tagged.c_str()); +} + +} // extern "C" diff --git a/TactilityKernel/source/properties_file.cpp b/TactilityKernel/source/properties_file.cpp new file mode 100644 index 000000000..418bb191b --- /dev/null +++ b/TactilityKernel/source/properties_file.cpp @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +constexpr auto* TAG = "properties_file"; + +namespace { + +std::string trim(const std::string& value, const char* chars) { + size_t start = value.find_first_not_of(chars); + if (start == std::string::npos) { + return ""; + } + size_t end = value.find_last_not_of(chars); + return value.substr(start, end - start + 1); +} + +bool split_key_value(const std::string& line, std::string& key, std::string& value) { + size_t index = line.find('='); + if (index == std::string::npos) { + return false; + } + key = line.substr(0, index); + value = line.substr(index + 1); + return true; +} + +} // namespace + +// Definition of the opaque handle declared in tactility/properties_file.h - C callers only +// ever see it through a PropertiesFile* pointer, never its members. +struct PropertiesFile { + std::string path; + std::unordered_map entries; +}; + +namespace { + +// Missing file is not an error - a fresh instance just starts out empty and gets created on +// close(). Mirrors Tactility's loadPropertiesFile(): "#"-prefixed and blank lines are skipped; +// a "[section]" line becomes a literal prefix (verbatim, brackets included) prepended to every +// subsequent key, until the next "[section]" line replaces it. +void load_from_file(PropertiesFile* file) { + FileMutex mutex {}; + file_mutex_get(&mutex, file->path.c_str()); + file_mutex_lock(&mutex); + + FILE* handle = std::fopen(file->path.c_str(), "r"); + if (handle == nullptr) { + file_mutex_unlock(&mutex); + return; + } + + std::string key_prefix; + std::string raw_line; + uint32_t line_number = 0; + + auto flush_line = [&]() { + line_number++; + std::string trimmed_line = trim(raw_line, " \t\r\n"); + raw_line.clear(); + + if (trimmed_line.empty() || trimmed_line.starts_with("#")) { + return; + } + if (trimmed_line.starts_with("[")) { + key_prefix = trimmed_line; + return; + } + + std::string key, value; + if (!split_key_value(trimmed_line, key, value)) { + LOG_E(TAG, "Failed to parse line %u of %s (skipped)", line_number, file->path.c_str()); + return; + } + file->entries[key_prefix + trim(key, " \t")] = trim(value, " \t"); + }; + + int c; + while ((c = std::fgetc(handle)) != EOF) { + if (c == '\n') { + flush_line(); + } else { + raw_line += static_cast(c); + } + } + flush_line(); + + std::fclose(handle); + file_mutex_unlock(&mutex); +} + +void save_to_file(const PropertiesFile* file) { + FileMutex mutex {}; + file_mutex_get(&mutex, file->path.c_str()); + file_mutex_lock(&mutex); + + FILE* handle = std::fopen(file->path.c_str(), "w"); + if (handle == nullptr) { + LOG_E(TAG, "Failed to open %s", file->path.c_str()); + file_mutex_unlock(&mutex); + return; + } + + for (const auto& [key, value] : file->entries) { + std::fprintf(handle, "%s=%s\n", key.c_str(), value.c_str()); + } + + std::fclose(handle); + file_mutex_unlock(&mutex); +} + +} // namespace + +extern "C" { + +PropertiesFile* properties_file_open(const char* path) { + auto* file = new (std::nothrow) PropertiesFile(); + if (file == nullptr) { + return nullptr; + } + file->path = path; + load_from_file(file); + return file; +} + +void properties_file_close(PropertiesFile* file) { + save_to_file(file); + delete file; +} + +bool properties_file_has(const PropertiesFile* file, const char* key) { + return file->entries.contains(key); +} + +error_t properties_file_get(const PropertiesFile* file, const char* key, char* out_value, size_t out_value_size) { + auto entry = file->entries.find(key); + if (entry == file->entries.end()) { + return ERROR_NOT_FOUND; + } + const std::string& value = entry->second; + if (value.size() + 1 > out_value_size) { + return ERROR_BUFFER_OVERFLOW; + } + std::memcpy(out_value, value.c_str(), value.size() + 1); + return ERROR_NONE; +} + +void properties_file_set(PropertiesFile* file, const char* key, const char* value) { + file->entries[key] = value; +} + +void properties_file_for_each(const PropertiesFile* file, PropertiesFileVisitorFn visitor, void* context) { + for (const auto& [key, value] : file->entries) { + visitor(key.c_str(), value.c_str(), context); + } +} + +} // extern "C" diff --git a/TactilityKernel/source/symbols.c b/TactilityKernel/source/symbols.c index 48c500737..6928bd486 100644 --- a/TactilityKernel/source/symbols.c +++ b/TactilityKernel/source/symbols.c @@ -1,3 +1,4 @@ +#include #include #include #include @@ -41,6 +42,8 @@ #include #include #include +#include +#include #include #ifndef ESP_PLATFORM @@ -470,6 +473,48 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(module_is_started), DEFINE_MODULE_SYMBOL(module_resolve_symbol), DEFINE_MODULE_SYMBOL(module_resolve_symbol_global), + // bundle + DEFINE_MODULE_SYMBOL(bundle_alloc), + DEFINE_MODULE_SYMBOL(bundle_clone), + DEFINE_MODULE_SYMBOL(bundle_free), + DEFINE_MODULE_SYMBOL(bundle_get_bool), + DEFINE_MODULE_SYMBOL(bundle_get_int32), + DEFINE_MODULE_SYMBOL(bundle_get_int64), + DEFINE_MODULE_SYMBOL(bundle_get_string), + DEFINE_MODULE_SYMBOL(bundle_has_bool), + DEFINE_MODULE_SYMBOL(bundle_has_int32), + DEFINE_MODULE_SYMBOL(bundle_has_int64), + DEFINE_MODULE_SYMBOL(bundle_has_string), + DEFINE_MODULE_SYMBOL(bundle_opt_bool), + DEFINE_MODULE_SYMBOL(bundle_opt_int32), + DEFINE_MODULE_SYMBOL(bundle_opt_int64), + DEFINE_MODULE_SYMBOL(bundle_opt_string), + DEFINE_MODULE_SYMBOL(bundle_put_bool), + DEFINE_MODULE_SYMBOL(bundle_put_int32), + DEFINE_MODULE_SYMBOL(bundle_put_int64), + DEFINE_MODULE_SYMBOL(bundle_put_string), + // preferences + DEFINE_MODULE_SYMBOL(preferences_open), + DEFINE_MODULE_SYMBOL(preferences_close), + DEFINE_MODULE_SYMBOL(preferences_has_bool), + DEFINE_MODULE_SYMBOL(preferences_has_int32), + DEFINE_MODULE_SYMBOL(preferences_has_int64), + DEFINE_MODULE_SYMBOL(preferences_has_string), + DEFINE_MODULE_SYMBOL(preferences_opt_bool), + DEFINE_MODULE_SYMBOL(preferences_opt_int32), + DEFINE_MODULE_SYMBOL(preferences_opt_int64), + DEFINE_MODULE_SYMBOL(preferences_opt_string), + DEFINE_MODULE_SYMBOL(preferences_put_bool), + DEFINE_MODULE_SYMBOL(preferences_put_int32), + DEFINE_MODULE_SYMBOL(preferences_put_int64), + DEFINE_MODULE_SYMBOL(preferences_put_string), + // properties_file + DEFINE_MODULE_SYMBOL(properties_file_open), + DEFINE_MODULE_SYMBOL(properties_file_close), + DEFINE_MODULE_SYMBOL(properties_file_has), + DEFINE_MODULE_SYMBOL(properties_file_get), + DEFINE_MODULE_SYMBOL(properties_file_set), + DEFINE_MODULE_SYMBOL(properties_file_for_each), // terminator MODULE_SYMBOL_TERMINATOR }; diff --git a/TactilityKernel/source/system_event.cpp b/TactilityKernel/source/system_event.cpp index 679d5e3e1..403845b7a 100644 --- a/TactilityKernel/source/system_event.cpp +++ b/TactilityKernel/source/system_event.cpp @@ -78,16 +78,16 @@ static void notify_poll_subscribers( ) { mutex_lock(&poll_subscriptions_mutex.handle); - for (SystemEventSubscription* sub = poll_subscriptions; sub != nullptr; sub = sub->next) { - if (sub->type == type) { - sub->timestamp = timestamp; + for (SystemEventSubscription* sub = poll_subscriptions; sub != nullptr; sub = sub->internal.next) { + if (sub->event.type == type) { + sub->event.timestamp = timestamp; const size_t copied_len = std::min(data_len, SYSTEM_EVENT_MAX_DATA_SIZE); if (copied_len > 0) { - std::memcpy(sub->data, data, copied_len); + std::memcpy(sub->event.data, data, copied_len); } - sub->data_len = copied_len; - sub->sequence++; - xSemaphoreGive(sub->semaphore); + sub->event.data_len = copied_len; + sub->internal.sequence++; + xSemaphoreGive(sub->internal.semaphore); } } @@ -148,12 +148,14 @@ error_t system_event_emit( const void* data, size_t data_len ) { - SystemEvent event = { - .type = type, - .timestamp = get_micros_since_boot(), - .data = data, - .data_len = data_len, - }; + SystemEvent event {}; + event.type = type; + event.timestamp = get_micros_since_boot(); + const size_t copied_len = std::min(data_len, SYSTEM_EVENT_MAX_DATA_SIZE); + if (copied_len > 0) { + std::memcpy(event.data, data, copied_len); + } + event.data_len = copied_len; notify_poll_subscribers(type, event.timestamp, data, data_len); auto error = notify_listeners(event); @@ -173,7 +175,7 @@ error_t system_event_subscribe(SystemEventSubscription* sub) { // Check-and-insert in one critical section: registering the same `sub` twice would link // it into a list that already contains it, creating a cycle that notify_poll_subscribers() // would then traverse forever while holding this same mutex. - for (SystemEventSubscription* existing = poll_subscriptions; existing != nullptr; existing = existing->next) { + for (SystemEventSubscription* existing = poll_subscriptions; existing != nullptr; existing = existing->internal.next) { if (existing == sub) { mutex_unlock(&poll_subscriptions_mutex.handle); vSemaphoreDelete(semaphore); @@ -181,11 +183,11 @@ error_t system_event_subscribe(SystemEventSubscription* sub) { } } - sub->semaphore = semaphore; - sub->sequence = 0; - sub->consumed_sequence = 0; - sub->data_len = 0; - sub->next = poll_subscriptions; + sub->internal.semaphore = semaphore; + sub->internal.sequence = 0; + sub->internal.consumed_sequence = 0; + sub->event.data_len = 0; + sub->internal.next = poll_subscriptions; poll_subscriptions = sub; mutex_unlock(&poll_subscriptions_mutex.handle); @@ -197,9 +199,9 @@ error_t system_event_unsubscribe(SystemEventSubscription* sub) { error_t result = ERROR_NOT_FOUND; mutex_lock(&poll_subscriptions_mutex.handle); - for (SystemEventSubscription** link = &poll_subscriptions; *link != nullptr; link = &(*link)->next) { + for (SystemEventSubscription** link = &poll_subscriptions; *link != nullptr; link = &(*link)->internal.next) { if (*link == sub) { - *link = sub->next; + *link = sub->internal.next; result = ERROR_NONE; break; } @@ -209,23 +211,31 @@ error_t system_event_unsubscribe(SystemEventSubscription* sub) { if (result == ERROR_NONE) { // Unlinked first, so notify_poll_subscribers() can no longer reach this semaphore // before it's deleted. - vSemaphoreDelete(sub->semaphore); - sub->semaphore = nullptr; + vSemaphoreDelete(sub->internal.semaphore); + sub->internal.semaphore = nullptr; } return result; } error_t system_event_await(SystemEventSubscription* sub, TickType_t timeout) { - uint32_t old_sequence = sub->sequence; + uint32_t old_sequence = sub->internal.sequence; - while (sub->sequence == old_sequence) { - if (xSemaphoreTake(sub->semaphore, timeout) == pdFALSE) { + while (sub->internal.sequence == old_sequence) { + if (xSemaphoreTake(sub->internal.semaphore, timeout) == pdFALSE) { return ERROR_TIMEOUT; } } - sub->consumed_sequence = sub->sequence; + sub->internal.consumed_sequence = sub->internal.sequence; + return ERROR_NONE; +} + +error_t system_event_get_data(SystemEventSubscription* sub, uint8_t* data, size_t data_len) { + if (data_len < sub->event.data_len) { + return ERROR_BUFFER_OVERFLOW; + } + std::memcpy(data, sub->event.data, sub->event.data_len); return ERROR_NONE; } diff --git a/Tests/TactilityKernel/Source/BundleTest.cpp b/Tests/TactilityKernel/Source/BundleTest.cpp new file mode 100644 index 000000000..f1195a345 --- /dev/null +++ b/Tests/TactilityKernel/Source/BundleTest.cpp @@ -0,0 +1,134 @@ +#include "doctest.h" +#include + +#include + +TEST_CASE("bundle_alloc/bundle_free round-trip") { + Bundle* bundle = bundle_alloc(); + CHECK_NE(bundle, nullptr); + bundle_free(bundle); +} + +TEST_CASE("bool can be stored and retrieved") { + Bundle* bundle = bundle_alloc(); + bundle_put_bool(bundle, "key", true); + + CHECK(bundle_has_bool(bundle, "key")); + CHECK_EQ(bundle_get_bool(bundle, "key"), true); + + bool out = false; + CHECK(bundle_opt_bool(bundle, "key", &out)); + CHECK_EQ(out, true); + + bundle_free(bundle); +} + +TEST_CASE("int32 can be stored and retrieved") { + Bundle* bundle = bundle_alloc(); + bundle_put_int32(bundle, "key", -42); + + CHECK(bundle_has_int32(bundle, "key")); + CHECK_EQ(bundle_get_int32(bundle, "key"), -42); + + int32_t out = 0; + CHECK(bundle_opt_int32(bundle, "key", &out)); + CHECK_EQ(out, -42); + + bundle_free(bundle); +} + +TEST_CASE("int64 can be stored and retrieved") { + Bundle* bundle = bundle_alloc(); + bundle_put_int64(bundle, "key", 123456789012345LL); + + CHECK(bundle_has_int64(bundle, "key")); + CHECK_EQ(bundle_get_int64(bundle, "key"), 123456789012345LL); + + int64_t out = 0; + CHECK(bundle_opt_int64(bundle, "key", &out)); + CHECK_EQ(out, 123456789012345LL); + + bundle_free(bundle); +} + +TEST_CASE("string can be stored and retrieved") { + Bundle* bundle = bundle_alloc(); + bundle_put_string(bundle, "key", "hello world"); + + CHECK(bundle_has_string(bundle, "key")); + + char buffer[32]; + CHECK_EQ(bundle_get_string(bundle, "key", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "hello world"), 0); + + char tiny[4]; + CHECK_EQ(bundle_get_string(bundle, "key", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW); + + char out[32]; + CHECK_EQ(bundle_opt_string(bundle, "key", out, sizeof(out)), ERROR_NONE); + CHECK_EQ(std::strcmp(out, "hello world"), 0); + + bundle_free(bundle); +} + +TEST_CASE("has_*/opt_* reject a key stored with a different type") { + Bundle* bundle = bundle_alloc(); + bundle_put_bool(bundle, "key", true); + + CHECK_FALSE(bundle_has_int32(bundle, "key")); + CHECK_FALSE(bundle_has_int64(bundle, "key")); + CHECK_FALSE(bundle_has_string(bundle, "key")); + + int32_t out_int32 = 0; + CHECK_FALSE(bundle_opt_int32(bundle, "key", &out_int32)); + + char out_string[8]; + CHECK_EQ(bundle_opt_string(bundle, "key", out_string, sizeof(out_string)), ERROR_NOT_FOUND); + + bundle_free(bundle); +} + +TEST_CASE("opt_string reports ERROR_NOT_FOUND for a missing key") { + Bundle* bundle = bundle_alloc(); + char out[8]; + CHECK_EQ(bundle_opt_string(bundle, "missing", out, sizeof(out)), ERROR_NOT_FOUND); + bundle_free(bundle); +} + +TEST_CASE("bundle_clone makes an independent deep copy") { + Bundle* original = bundle_alloc(); + bundle_put_bool(original, "bool", true); + bundle_put_int32(original, "int32", 123); + bundle_put_string(original, "string", "text"); + + Bundle* clone = bundle_clone(original); + bundle_free(original); // clone must not be affected + + CHECK_EQ(bundle_get_bool(clone, "bool"), true); + CHECK_EQ(bundle_get_int32(clone, "int32"), 123); + + char buffer[16]; + CHECK_EQ(bundle_get_string(clone, "string", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "text"), 0); + + // Mutating the clone must not affect a re-clone of the (already-freed) original's data. + bundle_put_int32(clone, "int32", 456); + CHECK_EQ(bundle_get_int32(clone, "int32"), 456); + + bundle_free(clone); +} + +TEST_CASE("put overwrites a previously stored value, including across types") { + Bundle* bundle = bundle_alloc(); + bundle_put_int32(bundle, "key", 1); + bundle_put_string(bundle, "key", "now a string"); + + CHECK_FALSE(bundle_has_int32(bundle, "key")); + CHECK(bundle_has_string(bundle, "key")); + + char buffer[32]; + CHECK_EQ(bundle_get_string(bundle, "key", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "now a string"), 0); + + bundle_free(bundle); +} diff --git a/Tests/TactilityKernel/Source/PreferencesTest.cpp b/Tests/TactilityKernel/Source/PreferencesTest.cpp new file mode 100644 index 000000000..2c1d28987 --- /dev/null +++ b/Tests/TactilityKernel/Source/PreferencesTest.cpp @@ -0,0 +1,153 @@ +#include "doctest.h" +#include + +#include +#include + +namespace { + +const char* TEST_PATH = "/tmp/tactility_kernel_preferences_test.properties"; + +struct ScratchFile { + ScratchFile() { std::remove(TEST_PATH); } + ~ScratchFile() { std::remove(TEST_PATH); } +}; + +bool file_exists(const char* path) { + FILE* file = std::fopen(path, "r"); + if (file == nullptr) { + return false; + } + std::fclose(file); + return true; +} + +} // namespace + +TEST_CASE("preferences_open_path on a missing file starts out empty, without creating it") { + ScratchFile scratch; + + Preferences* preferences = preferences_open(TEST_PATH); + CHECK_NE(preferences, nullptr); + CHECK_FALSE(preferences_has_bool(preferences, "key")); + CHECK_FALSE(file_exists(TEST_PATH)); + + preferences_close(preferences); +} + +TEST_CASE("put_*/has_*/opt_* round-trip all four types") { + ScratchFile scratch; + + Preferences* preferences = preferences_open(TEST_PATH); + preferences_put_bool(preferences, "flag", true); + preferences_put_int32(preferences, "count", -42); + preferences_put_int64(preferences, "big", 123456789012345LL); + preferences_put_string(preferences, "text", "hello world"); + + CHECK(preferences_has_bool(preferences, "flag")); + bool bool_out = false; + CHECK(preferences_opt_bool(preferences, "flag", &bool_out)); + CHECK_EQ(bool_out, true); + + CHECK(preferences_has_int32(preferences, "count")); + int32_t int32_out = 0; + CHECK(preferences_opt_int32(preferences, "count", &int32_out)); + CHECK_EQ(int32_out, -42); + + CHECK(preferences_has_int64(preferences, "big")); + int64_t int64_out = 0; + CHECK(preferences_opt_int64(preferences, "big", &int64_out)); + CHECK_EQ(int64_out, 123456789012345LL); + + CHECK(preferences_has_string(preferences, "text")); + char buffer[32]; + CHECK_EQ(preferences_opt_string(preferences, "text", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "hello world"), 0); + + preferences_close(preferences); +} + +TEST_CASE("opt_string reports ERROR_BUFFER_OVERFLOW and ERROR_NOT_FOUND") { + ScratchFile scratch; + + Preferences* preferences = preferences_open(TEST_PATH); + preferences_put_string(preferences, "text", "hello world"); + + char tiny[4]; + CHECK_EQ(preferences_opt_string(preferences, "text", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW); + + char buffer[32]; + CHECK_EQ(preferences_opt_string(preferences, "missing", buffer, sizeof(buffer)), ERROR_NOT_FOUND); + + preferences_close(preferences); +} + +TEST_CASE("has_*/opt_* reject a key stored with a different type") { + ScratchFile scratch; + + Preferences* preferences = preferences_open(TEST_PATH); + preferences_put_bool(preferences, "key", true); + + CHECK_FALSE(preferences_has_int32(preferences, "key")); + CHECK_FALSE(preferences_has_int64(preferences, "key")); + CHECK_FALSE(preferences_has_string(preferences, "key")); + + int32_t out = 0; + CHECK_FALSE(preferences_opt_int32(preferences, "key", &out)); + + preferences_close(preferences); +} + +TEST_CASE("a string value with embedded newlines and backslashes survives a reopen") { + ScratchFile scratch; + + { + Preferences* preferences = preferences_open(TEST_PATH); + preferences_put_string(preferences, "text", "line1\nline2 with \\ backslash"); + preferences_close(preferences); + } + { + Preferences* preferences = preferences_open(TEST_PATH); + char buffer[64]; + CHECK_EQ(preferences_opt_string(preferences, "text", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "line1\nline2 with \\ backslash"), 0); + preferences_close(preferences); + } +} + +TEST_CASE("preferences_close persists changes, and only close persists them") { + ScratchFile scratch; + + Preferences* preferences = preferences_open(TEST_PATH); + preferences_put_bool(preferences, "flag", true); + + // Not persisted yet - only preferences_close() writes to disk. + CHECK_FALSE(file_exists(TEST_PATH)); + + preferences_close(preferences); + CHECK(file_exists(TEST_PATH)); + + Preferences* reopened = preferences_open(TEST_PATH); + CHECK(preferences_has_bool(reopened, "flag")); + preferences_close(reopened); +} + +TEST_CASE("put_* on an already-closed value is visible without reopening") { + ScratchFile scratch; + + Preferences* preferences = preferences_open(TEST_PATH); + preferences_put_int32(preferences, "count", 1); + preferences_close(preferences); + + Preferences* reopened = preferences_open(TEST_PATH); + preferences_put_int32(reopened, "count", 2); + int32_t out = 0; + CHECK(preferences_opt_int32(reopened, "count", &out)); + CHECK_EQ(out, 2); + preferences_close(reopened); + + Preferences* final_instance = preferences_open(TEST_PATH); + CHECK(preferences_opt_int32(final_instance, "count", &out)); + CHECK_EQ(out, 2); + preferences_close(final_instance); +} diff --git a/Tests/TactilityKernel/Source/PropertiesFileTest.cpp b/Tests/TactilityKernel/Source/PropertiesFileTest.cpp new file mode 100644 index 000000000..6f7a4b579 --- /dev/null +++ b/Tests/TactilityKernel/Source/PropertiesFileTest.cpp @@ -0,0 +1,180 @@ +#include "doctest.h" +#include + +#include +#include +#include +#include +#include + +namespace { + +const char* TEST_PATH = "/tmp/tactility_kernel_properties_file_test.properties"; + +struct ScratchFile { + ScratchFile() { std::remove(TEST_PATH); } + ~ScratchFile() { std::remove(TEST_PATH); } +}; + +bool file_exists(const char* path) { + FILE* file = std::fopen(path, "r"); + if (file == nullptr) { + return false; + } + std::fclose(file); + return true; +} + +void write_raw(const char* path, const char* content) { + FILE* file = std::fopen(path, "w"); + std::fputs(content, file); + std::fclose(file); +} + +} // namespace + +TEST_CASE("properties_file_open on a missing file starts out empty, without creating it") { + ScratchFile scratch; + + PropertiesFile* file = properties_file_open(TEST_PATH); + CHECK_NE(file, nullptr); + CHECK_FALSE(properties_file_has(file, "key")); + CHECK_FALSE(file_exists(TEST_PATH)); + + properties_file_close(file); +} + +TEST_CASE("set/has/get round-trip, and close persists while unclosed changes don't") { + ScratchFile scratch; + + PropertiesFile* file = properties_file_open(TEST_PATH); + properties_file_set(file, "key", "value"); + + CHECK(properties_file_has(file, "key")); + char buffer[32]; + CHECK_EQ(properties_file_get(file, "key", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "value"), 0); + + // Not persisted yet - only properties_file_close() writes to disk. + CHECK_FALSE(file_exists(TEST_PATH)); + + properties_file_close(file); + CHECK(file_exists(TEST_PATH)); + + PropertiesFile* reopened = properties_file_open(TEST_PATH); + CHECK(properties_file_has(reopened, "key")); + properties_file_close(reopened); +} + +TEST_CASE("properties_file_get reports ERROR_BUFFER_OVERFLOW and ERROR_NOT_FOUND") { + ScratchFile scratch; + + PropertiesFile* file = properties_file_open(TEST_PATH); + properties_file_set(file, "key", "value"); + + char tiny[3]; + CHECK_EQ(properties_file_get(file, "key", tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW); + + char buffer[32]; + CHECK_EQ(properties_file_get(file, "missing", buffer, sizeof(buffer)), ERROR_NOT_FOUND); + + properties_file_close(file); +} + +TEST_CASE("set overwrites a previously stored value") { + ScratchFile scratch; + + PropertiesFile* file = properties_file_open(TEST_PATH); + properties_file_set(file, "key", "first"); + properties_file_set(file, "key", "second"); + + char buffer[32]; + CHECK_EQ(properties_file_get(file, "key", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "second"), 0); + + properties_file_close(file); +} + +TEST_CASE("comments and blank lines are skipped, keys and values are trimmed") { + ScratchFile scratch; + write_raw(TEST_PATH, + "# Comment\n" + " \t# Indented comment\n" + "\n" + "key1=value1\n" + " \tkey 2\t = \tvalue 2\t \n"); + + PropertiesFile* file = properties_file_open(TEST_PATH); + + char buffer[32]; + CHECK_EQ(properties_file_get(file, "key1", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "value1"), 0); + + // Only leading/trailing whitespace is trimmed - the internal space in "key 2"/"value 2" + // survives. + CHECK_EQ(properties_file_get(file, "key 2", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "value 2"), 0); + + properties_file_close(file); +} + +TEST_CASE("a malformed line (no '=') is skipped without aborting the rest of the file") { + ScratchFile scratch; + write_raw(TEST_PATH, "not_a_key_value_pair\nkey=value\n"); + + PropertiesFile* file = properties_file_open(TEST_PATH); + CHECK(properties_file_has(file, "key")); + CHECK_FALSE(properties_file_has(file, "not_a_key_value_pair")); + properties_file_close(file); +} + +TEST_CASE("a [section] line prefixes every following key until the next section") { + ScratchFile scratch; + write_raw(TEST_PATH, + "[app]\n" + "id=one.tactility.helloworld\n" + "name=Hello\n" + "[other]\n" + "id=x\n"); + + PropertiesFile* file = properties_file_open(TEST_PATH); + + char buffer[64]; + CHECK_EQ(properties_file_get(file, "[app]id", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "one.tactility.helloworld"), 0); + + CHECK_EQ(properties_file_get(file, "[app]name", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "Hello"), 0); + + CHECK_EQ(properties_file_get(file, "[other]id", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "x"), 0); + + CHECK_FALSE(properties_file_has(file, "id")); + + properties_file_close(file); +} + +TEST_CASE("properties_file_for_each visits every key exactly once") { + ScratchFile scratch; + + PropertiesFile* file = properties_file_open(TEST_PATH); + properties_file_set(file, "a", "1"); + properties_file_set(file, "b", "2"); + properties_file_set(file, "c", "3"); + + std::vector> seen; + properties_file_for_each(file, [](const char* key, const char* value, void* context) { + auto* out = static_cast>*>(context); + out->emplace_back(key, value); + }, &seen); + + CHECK_EQ(seen.size(), 3); + for (const auto& [key, value] : seen) { + if (key == "a") CHECK_EQ(value, "1"); + else if (key == "b") CHECK_EQ(value, "2"); + else if (key == "c") CHECK_EQ(value, "3"); + else FAIL("unexpected key: " << key); + } + + properties_file_close(file); +} diff --git a/Tests/TactilityKernel/Source/SystemEventTest.cpp b/Tests/TactilityKernel/Source/SystemEventTest.cpp index 0ea1b7157..fa7c8e09d 100644 --- a/Tests/TactilityKernel/Source/SystemEventTest.cpp +++ b/Tests/TactilityKernel/Source/SystemEventTest.cpp @@ -15,8 +15,10 @@ struct RecordedCall { void* context; SystemEventType type; - const void* data; - size_t data_len; + // Copied out of event->data during the callback - event->data is only valid for the + // duration of the callback (it lives in system_event_emit()'s own stack frame), so a bare + // pointer/length pair recorded here would dangle by the time a TEST_CASE inspects it. + std::vector data; uint64_t timestamp; }; @@ -24,11 +26,11 @@ static std::vector calls_a; static std::vector calls_b; static void listener_a(SystemEvent* event, void* context) { - calls_a.push_back({ context, event->type, event->data, event->data_len, event->timestamp }); + calls_a.push_back({ context, event->type, std::vector(event->data, event->data + event->data_len), event->timestamp }); } static void listener_b(SystemEvent* event, void* context) { - calls_b.push_back({ context, event->type, event->data, event->data_len, event->timestamp }); + calls_b.push_back({ context, event->type, std::vector(event->data, event->data + event->data_len), event->timestamp }); } static void reset_calls() { @@ -71,7 +73,7 @@ TEST_CASE("system_event_emit only invokes subscribers registered for the emitted system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a); } -TEST_CASE("system_event_emit passes the data pointer and length through unchanged") { +TEST_CASE("system_event_emit copies the data into the delivered event") { reset_calls(); int context_a = 1; struct Payload { int value; } payload { 42 }; @@ -80,14 +82,13 @@ TEST_CASE("system_event_emit passes the data pointer and length through unchange system_event_emit(KERNEL_EVENT_TIME_CHANGED, &payload, sizeof(payload)); REQUIRE_EQ(calls_a.size(), 1); - CHECK_EQ(calls_a[0].data, &payload); - CHECK_EQ(calls_a[0].data_len, sizeof(payload)); - CHECK_EQ(static_cast(calls_a[0].data)->value, 42); + REQUIRE_EQ(calls_a[0].data.size(), sizeof(payload)); + CHECK_EQ(reinterpret_cast(calls_a[0].data.data())->value, 42); system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, listener_a); } -TEST_CASE("system_event_emit with no data passes a null pointer and zero length") { +TEST_CASE("system_event_emit with no data delivers an empty payload") { reset_calls(); int context_a = 1; system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, listener_a, &context_a); @@ -95,8 +96,7 @@ TEST_CASE("system_event_emit with no data passes a null pointer and zero length" system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); REQUIRE_EQ(calls_a.size(), 1); - CHECK_EQ(calls_a[0].data, nullptr); - CHECK_EQ(calls_a[0].data_len, 0); + CHECK(calls_a[0].data.empty()); system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, listener_a); } @@ -175,7 +175,7 @@ TEST_CASE("system_event_emit stamps the event with the current boot-relative tim static bool reentrant_add_triggered = false; static void reentrant_listener(SystemEvent* event, void* context) { - calls_a.push_back({ context, event->type, event->data, event->data_len, event->timestamp }); + calls_a.push_back({ context, event->type, std::vector(event->data, event->data + event->data_len), event->timestamp }); if (!reentrant_add_triggered) { reentrant_add_triggered = true; // Subscribing from within a notification must not deadlock: emit() releases the @@ -225,7 +225,7 @@ TEST_CASE("system_event_emit is safe when a callback subscribes, unsubscribes an TEST_CASE("system_event_subscribe/_await deliver the event payload by value") { SystemEventSubscription sub {}; - sub.type = KERNEL_EVENT_NETWORK_CONNECTED; + sub.event.type = KERNEL_EVENT_NETWORK_CONNECTED; CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE); NetworkConnectedEvent connected { .device = nullptr, .ipv4_addr = 0x0A000001, .gateway = 0x0A0000FE }; @@ -245,10 +245,10 @@ TEST_CASE("system_event_subscribe/_await deliver the event payload by value") { CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); - const auto* received = reinterpret_cast(sub.data); - CHECK_EQ(received->ipv4_addr, connected.ipv4_addr); - CHECK_EQ(received->gateway, connected.gateway); - CHECK_EQ(sub.data_len, sizeof(connected)); + NetworkConnectedEvent received {}; + CHECK_EQ(system_event_get_data(&sub, reinterpret_cast(&received), sizeof(received)), ERROR_NONE); + CHECK_EQ(received.ipv4_addr, connected.ipv4_addr); + CHECK_EQ(received.gateway, connected.gateway); CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE); thread_free(thread); @@ -259,7 +259,7 @@ TEST_CASE("system_event_subscribe/_await deliver the event payload by value") { TEST_CASE("system_event_await times out when no matching event has arrived") { SystemEventSubscription sub {}; - sub.type = KERNEL_EVENT_TIME_CHANGED; + sub.event.type = KERNEL_EVENT_TIME_CHANGED; system_event_subscribe(&sub); CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT); @@ -269,7 +269,7 @@ TEST_CASE("system_event_await times out when no matching event has arrived") { TEST_CASE("system_event_emit does not notify a poll subscriber of a different type") { SystemEventSubscription sub {}; - sub.type = KERNEL_EVENT_BOOT_COMPLETED; + sub.event.type = KERNEL_EVENT_BOOT_COMPLETED; system_event_subscribe(&sub); system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); @@ -277,3 +277,67 @@ TEST_CASE("system_event_emit does not notify a poll subscriber of a different ty system_event_unsubscribe(&sub); } + +TEST_CASE("system_event_get_data reports ERROR_BUFFER_OVERFLOW and leaves the buffer untouched") { + SystemEventSubscription sub {}; + sub.event.type = KERNEL_EVENT_NETWORK_DISCONNECTED; + CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE); + + // system_event_await() only detects sequence increments that happen *after* it starts + // waiting (see the comment above), so the emit must come from another task while this one + // is already blocked in await() - same pattern as the payload-delivery test above. + NetworkDisconnectedEvent disconnected { .device = nullptr }; + auto* thread = thread_alloc_full( + "system-event-emitter", + 4096, + [](void* context) { + delay_millis(20); + auto* disconnected_ptr = static_cast(context); + system_event_emit(KERNEL_EVENT_NETWORK_DISCONNECTED, disconnected_ptr, sizeof(*disconnected_ptr)); + return 0; + }, + &disconnected, + -1 + ); + CHECK_EQ(thread_start(thread), ERROR_NONE); + CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); + CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE); + thread_free(thread); + + uint8_t tiny[1] = { 0xAA }; + CHECK_EQ(system_event_get_data(&sub, tiny, sizeof(tiny)), ERROR_BUFFER_OVERFLOW); + CHECK_EQ(tiny[0], 0xAA); + + uint8_t exact[sizeof(NetworkDisconnectedEvent)]; + CHECK_EQ(system_event_get_data(&sub, exact, sizeof(exact)), ERROR_NONE); + + system_event_unsubscribe(&sub); +} + +TEST_CASE("system_event_get_data on a subscription with no payload copies nothing and succeeds") { + SystemEventSubscription sub {}; + sub.event.type = KERNEL_EVENT_BOOT_COMPLETED; + CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE); + + auto* thread = thread_alloc_full( + "system-event-emitter", + 4096, + [](void*) { + delay_millis(20); + system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); + return 0; + }, + nullptr, + -1 + ); + CHECK_EQ(thread_start(thread), ERROR_NONE); + CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); + CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE); + thread_free(thread); + + uint8_t buffer[1] = { 0x42 }; + CHECK_EQ(system_event_get_data(&sub, buffer, 0), ERROR_NONE); + CHECK_EQ(buffer[0], 0x42); // untouched - nothing to copy + + system_event_unsubscribe(&sub); +} diff --git a/Tests/app-module/Source/AppManagerTest.cpp b/Tests/app-module/Source/AppManagerTest.cpp index 43f999dd1..5cd439fa1 100644 --- a/Tests/app-module/Source/AppManagerTest.cpp +++ b/Tests/app-module/Source/AppManagerTest.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -30,12 +31,22 @@ error_t fake_load(AppLocation, void** out_runtime) { int last_received_argc = -1; std::vector last_received_argv; +// fake_run() runs on the app's own task; stash_received_arguments() writes +// last_received_argc/last_received_argv there while a test thread reads them - wait_for_state() +// only establishes that the instance reached APP_INSTANCE_STATE_ACTIVE (set before +// AppLoaderApi::run() is even called, i.e. before fake_run() runs at all), not that +// stash_received_arguments() has finished writing. This flag is the actual ordering: reset +// before starting the app, set (release) as the last step of stash_received_arguments(), waited +// on (acquire) before a test reads the stashed values. +std::atomic arguments_stashed { false }; + void stash_received_arguments(int argc, char* argv[]) { last_received_argc = argc; last_received_argv.clear(); for (int i = 0; i < argc; i++) { last_received_argv.emplace_back(argv[i]); } + arguments_stashed.store(true, std::memory_order_release); } // A minimal stand-in for a real app's main(): subscribes to its own app_event stream and exits @@ -141,6 +152,18 @@ bool wait_for_state(uint32_t instance_id, AppInstanceState target, uint32_t time return app_manager_get_state(instance_id) == target; } +bool wait_for_arguments_stashed(uint32_t timeout_ms) { + uint32_t waited = 0; + while (waited < timeout_ms) { + if (arguments_stashed.load(std::memory_order_acquire)) { + return true; + } + delay_millis(10); + waited += 10; + } + return arguments_stashed.load(std::memory_order_acquire); +} + } // namespace TEST_CASE("app_manager_start activates an app instance, app_manager_stop terminates it") { @@ -217,6 +240,7 @@ TEST_CASE("app_manager_start_with_parameters deep-copies argv before the app ins REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); uint32_t instance_id = 0; + arguments_stashed.store(false, std::memory_order_relaxed); { // Caller's argv is stack-local and goes out of scope immediately after this block - // proves app-module made its own copy rather than aliasing the caller's strings. @@ -226,6 +250,7 @@ TEST_CASE("app_manager_start_with_parameters deep-copies argv before the app ins REQUIRE_EQ(app_manager_start_with_parameters("test.app.args", 2, argv, &instance_id), ERROR_NONE); } CHECK(wait_for_state(instance_id, APP_INSTANCE_STATE_ACTIVE, 1000)); + REQUIRE(wait_for_arguments_stashed(1000)); REQUIRE_EQ(last_received_argc, 2); REQUIRE_EQ(last_received_argv.size(), 2u); diff --git a/Tests/app-module/Source/Main.cpp b/Tests/app-module/Source/Main.cpp index acd1df905..731b8be76 100644 --- a/Tests/app-module/Source/Main.cpp +++ b/Tests/app-module/Source/Main.cpp @@ -43,7 +43,10 @@ int main(int argc, char** argv) { 1, nullptr ); - assert(task_result == pdPASS); + + if (task_result != pdPASS) { + return 1; + } vTaskStartScheduler(); From ba59437f44f686e70dec4058bc6fe4280dc63c5d Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 18:28:52 +0200 Subject: [PATCH 05/31] Fixes --- Firmware/CMakeLists.txt | 2 + .../source/app_esp32_loader_service.cpp | 2 +- .../private/app/private/app_ledger.h | 28 ++- Modules/app-module/source/app_scheduler.cpp | 115 +++++++---- Modules/app-module/source/manager.cpp | 48 +++-- .../lvgl_window_manager/window_manager.h | 6 +- .../source/window_manager.cpp | 181 +++++++++++++----- Tactility/CMakeLists.txt | 3 + TactilityC/Include/tt_app_fileselection.h | 4 +- .../include/tactility/preferences.h | 9 +- .../include/tactility/properties_file.h | 17 +- .../include/tactility/system_event.h | 25 ++- TactilityKernel/source/bundle.cpp | 13 +- TactilityKernel/source/preferences.cpp | 88 ++++++++- TactilityKernel/source/properties_file.cpp | 61 +++++- TactilityKernel/source/system_event.cpp | 106 ++++++++-- .../Source/PreferencesTest.cpp | 90 +++++++++ .../Source/PropertiesFileTest.cpp | 72 +++++++ .../Source/SystemEventTest.cpp | 96 ++++++++++ 19 files changed, 816 insertions(+), 150 deletions(-) diff --git a/Firmware/CMakeLists.txt b/Firmware/CMakeLists.txt index 5efbbefe6..ea82c6574 100644 --- a/Firmware/CMakeLists.txt +++ b/Firmware/CMakeLists.txt @@ -96,6 +96,8 @@ else () Tactility TactilityFreeRtos lvgl-module + lvgl-window-manager-module + app-module crypt-module gps-module gps-generic-module diff --git a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp index ee510d818..ab8009828 100644 --- a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp +++ b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp @@ -151,7 +151,7 @@ void destroy_service(const ServiceManifest*, void*) { } // namespace -extern ServiceManifest loader_service_manifest = { +ServiceManifest loader_service_manifest = { .id = APP_LOADER_PATH_SERVICE_ID, .create_service = create_service, .destroy_service = destroy_service, diff --git a/Modules/app-module/private/app/private/app_ledger.h b/Modules/app-module/private/app/private/app_ledger.h index c7907cb13..61f38d714 100644 --- a/Modules/app-module/private/app/private/app_ledger.h +++ b/Modules/app-module/private/app/private/app_ledger.h @@ -6,12 +6,32 @@ #include #include +#include #include #include #include #include +/** + * A dedicated (not the task's shared default FreeRTOS notification, which app_event.cpp's + * AppEventSubscription also uses - an unrelated event delivered to the same task could + * otherwise unblock a waiter early) completion signal for one app instance's task, given as the + * literal last action app_task_main() takes before vTaskDelete(). Heap-allocated with its own + * refcount (protected by app_ledger().mutex, not atomic) rather than owned by the ledger + * entry, since app_task_main() always erases that entry - and may run its exit path entirely - + * before app_scheduler_stop() ever looks for it: whichever side (the exiting task, or a + * concurrent app_scheduler_stop() that found the entry in time and is waiting on `semaphore`) + * finishes with it last is the one that deletes `semaphore` and frees this struct. + */ +struct AppCompletionSignal { + SemaphoreHandle_t semaphore; + /** Starts at 1, owned by app_task_main() until its own exit. app_scheduler_stop() takes an + * additional reference for as long as it's waiting on `semaphore`, if it finds the instance + * still running. Reaching 0 means deletion. */ + int refcount = 1; +}; + /** A registered/running app instance, as tracked internally by app-module. */ struct AppInstanceRecord { uint32_t id; @@ -25,11 +45,9 @@ struct AppInstanceRecord { * app_manager_start_for_result() - the instance that receives this child's APP_EVENT_RESULT. */ uint32_t parent_id = 0; - /** The task currently blocked in app_scheduler_stop() for this instance, if any - notified - * (via xTaskNotifyGive()) as the literal last action app_task_main() takes before - * vTaskDelete(), so app_scheduler_stop() can't observe completion before the task has - * actually finished running. See app_scheduler.cpp. */ - TaskHandle_t stop_waiter = nullptr; + /** This instance's completion signal - see AppCompletionSignal. Set once by + * app_scheduler_start(), never reassigned. */ + AppCompletionSignal* completion = nullptr; }; struct AppLedger { diff --git a/Modules/app-module/source/app_scheduler.cpp b/Modules/app-module/source/app_scheduler.cpp index 70ce18eaf..948222e04 100644 --- a/Modules/app-module/source/app_scheduler.cpp +++ b/Modules/app-module/source/app_scheduler.cpp @@ -35,6 +35,7 @@ struct TaskContext { AppInstanceId app_instance_id; int argc; char** argv; + AppCompletionSignal* completion; }; void set_state(AppInstanceId app_instance_id, AppInstanceState state) { @@ -57,21 +58,44 @@ void set_task(AppInstanceId app_instance_id, TaskHandle_t task) { mutex_unlock(&ledger.mutex); } -// Registers the calling task to be notified when app_instance_id's task actually finishes -// running (see app_task_main()'s exit path), and reports whether there's anything to wait for. -// @return true if the instance's ledger entry still exists (a wait was registered); false if -// the task has already fully finished (and already given any notification it would have) - -// there is nothing left to wait for. -bool register_stop_waiter(AppInstanceId app_instance_id) { +void set_completion(AppInstanceId app_instance_id, AppCompletionSignal* completion) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); auto iterator = ledger.instances.find(app_instance_id); - bool exists = iterator != ledger.instances.end(); - if (exists) { - iterator->second.stop_waiter = xTaskGetCurrentTaskHandle(); + if (iterator != ledger.instances.end()) { + iterator->second.completion = completion; + } + mutex_unlock(&ledger.mutex); +} + +// Takes a reference on app_instance_id's completion signal (see AppCompletionSignal), for the +// caller to wait on. @return the signal to wait on, or NULL if the instance has already fully +// finished (its ledger entry - and so its reference to the signal - is already gone) and so +// there's nothing left to wait for. +AppCompletionSignal* acquire_completion_signal(AppInstanceId app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + AppCompletionSignal* completion = nullptr; + if (iterator != ledger.instances.end()) { + completion = iterator->second.completion; + completion->refcount++; } mutex_unlock(&ledger.mutex); - return exists; + return completion; +} + +// Releases a reference taken by acquire_completion_signal(), deleting the signal (and its +// semaphore) if this was the last one. +void release_completion_signal(AppCompletionSignal* completion) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + bool should_delete = (--completion->refcount == 0); + mutex_unlock(&ledger.mutex); + if (should_delete) { + vSemaphoreDelete(completion->semaphore); + delete completion; + } } const char* loader_service_id_for(AppLocationType type) { @@ -137,26 +161,28 @@ void app_task_main(void* context) { app_ledger_free_arguments(ctx->argc, ctx->argv); AppInstanceId app_instance_id = ctx->app_instance_id; + AppCompletionSignal* completion = ctx->completion; delete ctx; LOG_I(TAG, "Thread for %d finished", app_instance_id); - // Erase the ledger entry before self-deleting, capturing whoever's blocked in - // app_scheduler_stop() for this instance (if anyone) so they can be notified afterward. + // Erase the ledger entry before self-deleting - see "Reap self-terminated app tasks": + // nothing else is guaranteed to ever call app_scheduler_stop() for this instance (the + // common case is the app just closing itself), so this can't wait for that to happen. auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); - auto iterator = ledger.instances.find(app_instance_id); - TaskHandle_t stop_waiter = (iterator != ledger.instances.end()) ? iterator->second.stop_waiter : nullptr; ledger.instances.erase(app_instance_id); mutex_unlock(&ledger.mutex); // Signal completion as the literal last action before this task ceases to exist, so - // app_scheduler_stop() can't observe "stopped" one step early (see its own comment) - - // unlike watching the ledger entry disappear, this can only happen once the task is truly - // done running. - if (stop_waiter != nullptr) { - xTaskNotifyGive(stop_waiter); - } + // app_scheduler_stop() can't observe "stopped" one step early - unlike watching the ledger + // entry disappear, this can only happen once the task is truly done running. A dedicated + // semaphore rather than this task's default FreeRTOS notification, since app_event.cpp's + // AppEventSubscription also uses that shared slot - an unrelated event (e.g. a child's + // APP_EVENT_RESULT) delivered to this same task could otherwise unblock a concurrent + // app_scheduler_stop() early. + xSemaphoreGive(completion->semaphore); + release_completion_signal(completion); // releases app_task_main()'s own reference vTaskDelete(nullptr); } @@ -181,9 +207,27 @@ error_t app_scheduler_start(AppInstanceId app_instance_id, AppLocation location, return load_result; } - auto* context = new (std::nothrow) TaskContext { loader, runtime, app_instance_id, argc, argv }; + auto* completion = new (std::nothrow) AppCompletionSignal(); + if (completion == nullptr) { + LOG_E(TAG, "Failed to allocate app"); + loader->unload(runtime); + app_ledger_free_arguments(argc, argv); + return ERROR_OUT_OF_MEMORY; + } + completion->semaphore = xSemaphoreCreateBinary(); + if (completion->semaphore == nullptr) { + LOG_E(TAG, "Failed to allocate app"); + delete completion; + loader->unload(runtime); + app_ledger_free_arguments(argc, argv); + return ERROR_OUT_OF_MEMORY; + } + + auto* context = new (std::nothrow) TaskContext { loader, runtime, app_instance_id, argc, argv, completion }; if (context == nullptr) { LOG_E(TAG, "Failed to allocate app"); + vSemaphoreDelete(completion->semaphore); + delete completion; loader->unload(runtime); app_ledger_free_arguments(argc, argv); return ERROR_OUT_OF_MEMORY; @@ -200,6 +244,8 @@ error_t app_scheduler_start(AppInstanceId app_instance_id, AppLocation location, BaseType_t create_result = xTaskCreate(app_task_main, task_name, 8192 / sizeof(StackType_t), context, tskIDLE_PRIORITY, &task_handle); if (create_result != pdPASS) { delete context; + vSemaphoreDelete(completion->semaphore); + delete completion; loader->unload(runtime); app_ledger_free_arguments(argc, argv); return ERROR_OUT_OF_MEMORY; @@ -207,6 +253,7 @@ error_t app_scheduler_start(AppInstanceId app_instance_id, AppLocation location, vTaskSuspend(task_handle); set_task(app_instance_id, task_handle); + set_completion(app_instance_id, completion); vTaskPrioritySet(task_handle, APP_TASK_PRIORITY); vTaskResume(task_handle); @@ -214,22 +261,22 @@ error_t app_scheduler_start(AppInstanceId app_instance_id, AppLocation location, } error_t app_scheduler_stop(AppInstanceId app_instance_id, TickType_t join_timeout) { - // Drain any stale notification credit before registering as the waiter - otherwise a - // leftover give from an unrelated earlier wait on this same task (e.g. a previous - // app_scheduler_stop() call that timed out and only got notified afterward) could make the - // take below return immediately for the wrong event. Mirrors app_event_await()'s same - // defensive drain. - ulTaskNotifyTake(pdTRUE, 0); - - if (register_stop_waiter(app_instance_id)) { + AppCompletionSignal* completion = acquire_completion_signal(app_instance_id); + if (completion != nullptr) { AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; app_event_emit(app_instance_id, &event); - // Blocks until app_task_main() gives this notification as the literal last thing it - // does before vTaskDelete() - unlike polling the ledger for the task handle to clear, - // this can't observe "stopped" while the task is still mid-exit (still running its own - // cleanup/vTaskDelete()). - if (ulTaskNotifyTake(pdTRUE, join_timeout) == 0) { + // Blocks until app_task_main() gives this dedicated semaphore as the literal last + // thing it does before vTaskDelete() - unlike polling the ledger for the task handle to + // clear, this can't observe "stopped" while the task is still mid-exit (still running + // its own cleanup/vTaskDelete()). A dedicated semaphore rather than this task's default + // FreeRTOS notification, since app_event.cpp's AppEventSubscription also uses that + // shared slot - an unrelated event (e.g. a different child's APP_EVENT_RESULT) + // delivered to this same task could otherwise unblock this early. + BaseType_t taken = xSemaphoreTake(completion->semaphore, join_timeout); + release_completion_signal(completion); + + if (taken == pdFALSE) { LOG_W(TAG, "App instance %u did not stop in time", app_instance_id); return ERROR_TIMEOUT; } diff --git a/Modules/app-module/source/manager.cpp b/Modules/app-module/source/manager.cpp index 3c13821e7..ae91cbb0f 100644 --- a/Modules/app-module/source/manager.cpp +++ b/Modules/app-module/source/manager.cpp @@ -259,8 +259,16 @@ void app_manager_install_path_scan(void) { app_fs_list_direct_subdirectories(root, found_app_dirs); } + // Snapshot of what's already registered, taken once so the rest of this scan can run without holding registry.mutex mutex_lock(®istry.mutex); + std::unordered_map known_paths; // id -> path + for (const auto& [id, record] : registry.scanned) { + known_paths.emplace(id, record->path); + } + mutex_unlock(®istry.mutex); + // Stat each manifest and parse it entirely without registry.mutex held (due to filesystem IO being slow) + std::vector> new_records; for (const auto& app_dir : found_app_dirs) { auto manifest_path = app_dir + "/manifest.properties"; if (!app_fs_is_file(manifest_path)) { @@ -273,7 +281,7 @@ void app_manager_install_path_scan(void) { continue; } - if (registry.scanned.contains(metadata.app_id)) { + if (known_paths.contains(metadata.app_id)) { continue; // already registered by an earlier scan } @@ -288,29 +296,41 @@ void app_manager_install_path_scan(void) { .location = { APP_LOCATION_PATH, const_cast(record->path.c_str()) }, .flags = 0, }; - - if (app_manager_add(&record->manifest) != ERROR_NONE) { - LOG_E(TAG, "Failed to register app %s (duplicate id?)", record->id.c_str()); - continue; - } - - registry.scanned[record->id] = std::move(record); + new_records.push_back(std::move(record)); } - // Anything a previous scan registered whose directory has since disappeared (e.g. an SD - // card was removed) just gets unregistered - no file deletion, no touching running - // instances, that's app_install()/app_uninstall()'s job, not scanning's. + // Anything a previous scan registered whose directory has since disappeared gets unregistered below. std::vector missing_ids; - for (const auto& [id, record] : registry.scanned) { - if (!app_fs_is_directory(record->path)) { + for (const auto& [id, path] : known_paths) { + if (!app_fs_is_directory(path)) { missing_ids.push_back(id); } } + + // app_manager_add()/app_manager_remove() take app-module's own ledger mutex internally - + // calling them while holding registry.mutex would establish a registry.mutex -> ledger- + // mutex lock order that any future opposite-order path would deadlock against, so these + // also run with registry.mutex released. registry.mutex is taken only afterward, briefly, + // to publish the results (plain in-memory map updates, no I/O or other locks involved). for (const auto& id : missing_ids) { app_manager_remove(id.c_str()); - registry.scanned.erase(id); + } + std::vector> added_records; + for (auto& record : new_records) { + if (app_manager_add(&record->manifest) == ERROR_NONE) { + added_records.push_back(std::move(record)); + } else { + LOG_E(TAG, "Failed to register app %s (duplicate id?)", record->id.c_str()); + } } + mutex_lock(®istry.mutex); + for (const auto& id : missing_ids) { + registry.scanned.erase(id); + } + for (auto& record : added_records) { + registry.scanned[record->id] = std::move(record); + } mutex_unlock(®istry.mutex); } diff --git a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h index 7a4ba6044..7a5c243e0 100644 --- a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h +++ b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h @@ -1,6 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include "../../../app-module/include/app/instance.h" + + #include #include @@ -80,12 +83,13 @@ typedef void (*WindowCreateWidgetsFn)(lv_obj_t* root, void* user_data); * Creates a new window on top of the stack (last created = topmost). Deletes the previously * topmost window's widgets (if any) and builds this window's widgets immediately via * @a create_widgets - only the topmost window ever has live widgets. + * @param[in] app_instance_id the application instance this window belongs to, should not be 0 * @param[in] user_data opaque; passed back to @a create_widgets on every call, including a * later rebuild triggered by window_manager_remove() - see its @warning about which thread that * can run on. Typically the calling app's own Context*. * @return the new window's id, or 0 if window_manager_start() hasn't been called */ -WindowId window_manager_create(uint32_t app_instance_id, WindowCreateWidgetsFn create_widgets, void* user_data); +WindowId window_manager_create(AppInstanceId app_instance_id, WindowCreateWidgetsFn create_widgets, void* user_data); /** * Removes a window, wherever it is in the stack - not necessarily the topmost one. If it was diff --git a/Modules/lvgl-window-manager-module/source/window_manager.cpp b/Modules/lvgl-window-manager-module/source/window_manager.cpp index 7b1d8df31..f182f2149 100644 --- a/Modules/lvgl-window-manager-module/source/window_manager.cpp +++ b/Modules/lvgl-window-manager-module/source/window_manager.cpp @@ -1,42 +1,66 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include "../../app-module/include/app/instance.h" + #include #include #include +#include #include +#include #include constexpr auto* TAG = "window_manager"; namespace { +/** + * A dedicated (not the waiting task's shared default FreeRTOS notification, which other + * subsystems - e.g. app_event.cpp's AppEventSubscription - also use; an unrelated notification + * delivered to the same task could otherwise unblock a wait early) completion signal for one + * window_manager_await_state_change() call. Heap-allocated with its own refcount (protected by + * WindowManagerState::mutex, not atomic) rather than owned solely by the WindowRecord, since + * window_manager_create()/remove() claim (read + clear) a window's signal under the lock but + * give it after releasing that lock - refcounting lets whichever side (the waiting task waking + * up, or the claimer after it gives) finishes last safely delete it. + */ +struct WindowWaitSignal { + SemaphoreHandle_t semaphore; + /** Starts at 1, owned by window_manager_await_state_change() until it's done waiting. + * Whoever claims this signal from a WindowRecord (see claim_waiter_locked()) takes an + * additional reference for as long as it takes to give the semaphore. Reaching 0 means + * deletion. */ + int refcount = 1; +}; + struct WindowRecord { WindowId id; uint32_t app_instance_id; WindowCreateWidgetsFn create_widgets; void* user_data; - /** Task blocked in window_manager_await_state_change() for this specific window, if any - - * see that function's @warning on at most one concurrent awaiter per window. Per-window - * rather than a single manager-wide slot, since a stacked window manager serving several - * app tasks can have more than one window (though only ever one of them topmost/GRANTED at - * a time) with a live await() call outstanding. */ - TaskHandle_t waiting_task = nullptr; + /** Set by window_manager_await_state_change() for this specific window, if a task is + * currently blocked there - see that function's @warning on at most one concurrent awaiter + * per window. Per-window rather than a single manager-wide slot, since a stacked window + * manager serving several app tasks can have more than one window (though only ever one of + * them topmost/GRANTED at a time) with a live await() call outstanding. */ + WindowWaitSignal* waiting_signal = nullptr; }; struct WindowManagerState { /** Mutex for read/write operations. Shortly held. */ Mutex mutex {}; - /** Serializes the full start()/stop() transition (including the LVGL work done with - * `mutex` released) so two concurrent starts can't both pass the `started` check and each - * create their own root widget, and a concurrent stop can't run while a start is still - * mid-flight. Never held across a create_widgets()/screen_init() callback - those only - * reach window_manager_create()/remove(), not start()/stop() - so there's no lock-order - * risk with `mutex` or the LVGL lock. */ + /** Serializes the full start()/stop()/create()/remove() transitions against each other, + * including the LVGL work done with `mutex` released (and any create_widgets()/ + * screen_init() callback invoked as part of that work). Without this, e.g. + * window_manager_stop() could delete real_root_widget/content_root_widget/top_widget + * between a concurrent create()/remove() capturing one of those pointers under `mutex` and + * actually using it via build_window_widget()/delete_widget() after releasing `mutex` - + * touching an LVGL object it no longer holds a valid reference to. */ Mutex lifecycle_mutex {}; bool started = false; @@ -91,6 +115,38 @@ void delete_widget(lv_obj_t* widget) { lvgl_unlock(); } +// Call while holding WindowManagerState::mutex. Transfers ownership of `window`'s waiting +// signal (if any) to the caller, taking an additional reference on the caller's behalf - the +// caller must eventually pass the result to give_and_release() exactly once, outside the lock. +WindowWaitSignal* claim_waiter_locked(WindowRecord& window) { + WindowWaitSignal* signal = window.waiting_signal; + window.waiting_signal = nullptr; + if (signal != nullptr) { + signal->refcount++; + } + return signal; +} + +// Gives `signal`'s semaphore (waking window_manager_await_state_change() if it's still +// waiting) and releases the caller's reference (see claim_waiter_locked()), deleting the +// signal if that was the last one. No-op if `signal` is NULL. +void give_and_release(WindowWaitSignal* signal) { + if (signal == nullptr) { + return; + } + + xSemaphoreGive(signal->semaphore); + + auto& s = state(); + mutex_lock(&s.mutex); + bool should_delete = (--signal->refcount == 0); + mutex_unlock(&s.mutex); + if (should_delete) { + vSemaphoreDelete(signal->semaphore); + delete signal; + } +} + } // namespace extern "C" { @@ -178,12 +234,12 @@ error_t window_manager_stop(void) { return ERROR_NONE; } lv_obj_t* widget = s.real_root_widget; - // Collect every window's waiter before clearing - normally at most the topmost window's is + // Claim every window's waiter before clearing - normally at most the topmost window's is // ever set, but every window is being torn down here, so every one is checked. - std::vector waiters; - for (const auto& window : s.windows) { - if (window.waiting_task != nullptr) { - waiters.push_back(window.waiting_task); + std::vector waiters; + for (auto& window : s.windows) { + if (auto* signal = claim_waiter_locked(window); signal != nullptr) { + waiters.push_back(signal); } } s.real_root_widget = nullptr; @@ -193,8 +249,8 @@ error_t window_manager_stop(void) { s.started = false; mutex_unlock(&s.mutex); - for (TaskHandle_t waiter : waiters) { - xTaskNotifyGive(waiter); + for (WindowWaitSignal* waiter : waiters) { + give_and_release(waiter); } // Deleting the real widget cascades to everything under it - chrome and top_widget alike. @@ -204,31 +260,35 @@ error_t window_manager_stop(void) { return ERROR_NONE; } -WindowId window_manager_create(uint32_t app_instance_id, WindowCreateWidgetsFn create_widgets, void* user_data) { +WindowId window_manager_create(AppInstanceId app_instance_id, WindowCreateWidgetsFn create_widgets, void* user_data) { + if (app_instance_id == 0) { + return 0; + } + auto& s = state(); + // See lifecycle_mutex's comment - blocks a concurrent window_manager_stop() (or another + // create()/remove()) from touching real_root_widget/content_root_widget/top_widget while + // this call still holds pointers to them. + mutex_lock(&s.lifecycle_mutex); + mutex_lock(&s.mutex); if (!s.started) { mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); return 0; } lv_obj_t* content = s.content_root_widget; lv_obj_t* old_top_widget = s.top_widget; - // The current topmost window (if any) is about to be superseded - transfer its waiter (if + // The current topmost window (if any) is about to be superseded - claim its waiter (if // any) here so it gets notified below, since it's no longer topmost after this. - TaskHandle_t waiter = nullptr; - if (!s.windows.empty()) { - waiter = s.windows.back().waiting_task; - s.windows.back().waiting_task = nullptr; - } + WindowWaitSignal* waiter = !s.windows.empty() ? claim_waiter_locked(s.windows.back()) : nullptr; s.top_widget = nullptr; WindowId new_id = s.next_id++; s.windows.push_back(WindowRecord { new_id, app_instance_id, create_widgets, user_data }); mutex_unlock(&s.mutex); - if (waiter != nullptr) { - xTaskNotifyGive(waiter); - } + give_and_release(waiter); delete_widget(old_top_widget); lv_obj_t* new_widget = build_window_widget(content, create_widgets, user_data); @@ -245,25 +305,32 @@ WindowId window_manager_create(uint32_t app_instance_id, WindowCreateWidgetsFn c // another app thread) - discard what we just made. delete_widget(new_widget); + mutex_unlock(&s.lifecycle_mutex); return new_id; } void window_manager_remove(WindowId id) { auto& s = state(); + // See lifecycle_mutex's comment - blocks a concurrent window_manager_stop() (or another + // create()/remove()) from touching real_root_widget/content_root_widget/top_widget while + // this call still holds pointers to them. + mutex_lock(&s.lifecycle_mutex); + mutex_lock(&s.mutex); auto iterator = std::find_if(s.windows.begin(), s.windows.end(), [id](const WindowRecord& window) { return window.id == id; }); if (iterator == s.windows.end()) { mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); return; } bool was_topmost = (iterator + 1 == s.windows.end()); // The window being removed owns its own waiter (if any) - a waiter is only ever registered // while its window is topmost (see window_manager_await_state_change()), and if this window // later stopped being topmost without being removed, window_manager_create() would already - // have transferred/cleared it - so a buried window's waiting_task is always already null. - TaskHandle_t waiter = iterator->waiting_task; + // have claimed/cleared it - so a buried window's waiting_signal is always already null. + WindowWaitSignal* waiter = claim_waiter_locked(*iterator); s.windows.erase(iterator); lv_obj_t* content = s.content_root_widget; @@ -285,12 +352,11 @@ void window_manager_remove(WindowId id) { } mutex_unlock(&s.mutex); - if (waiter != nullptr) { - xTaskNotifyGive(waiter); - } + give_and_release(waiter); if (!was_topmost) { // A buried window was removed - the topmost window's widgets are unaffected. + mutex_unlock(&s.lifecycle_mutex); return; } @@ -306,6 +372,8 @@ void window_manager_remove(WindowId id) { mutex_unlock(&s.mutex); delete_widget(new_widget); + + mutex_unlock(&s.lifecycle_mutex); } WindowState window_manager_get_state(WindowId id) { @@ -319,36 +387,51 @@ WindowState window_manager_get_state(WindowId id) { WindowState window_manager_await_state_change(WindowId id, TickType_t timeout) { auto& s = state(); + // Dedicated semaphore rather than this task's default FreeRTOS notification - other + // subsystems (e.g. app_event.cpp's AppEventSubscription) use that same shared slot, so an + // unrelated notification delivered to this task could otherwise wake this wait early. + auto* signal = new (std::nothrow) WindowWaitSignal(); + if (signal == nullptr) { + return window_manager_get_state(id); + } + signal->semaphore = xSemaphoreCreateBinary(); + if (signal->semaphore == nullptr) { + delete signal; + return window_manager_get_state(id); + } + mutex_lock(&s.mutex); bool is_top = !s.windows.empty() && s.windows.back().id == id; if (!is_top) { mutex_unlock(&s.mutex); + vSemaphoreDelete(signal->semaphore); + delete signal; return WINDOW_STATE_REVOKED; } // At most one concurrent awaiter per window - see the @warning on this function. - check(s.windows.back().waiting_task == nullptr); - s.windows.back().waiting_task = xTaskGetCurrentTaskHandle(); + check(s.windows.back().waiting_signal == nullptr); + s.windows.back().waiting_signal = signal; mutex_unlock(&s.mutex); - ulTaskNotifyTake(pdTRUE, timeout); + xSemaphoreTake(signal->semaphore, timeout); - /* Deregister ourselves if a create()/remove() hasn't already claimed us (the ordinary, intended wakeup) - * Otherwise a later create()/remove() could notify a task that's no longer waiting here: - * a use-after-exit on the handle if this task is gone, or a stale wakeup the next time it waits. - * Re-locate the record by id - it may have been erased (window_manager_remove()) while we waited. */ + // Deregister ourselves if a create()/remove() hasn't already claimed us (the ordinary, + // intended wakeup) - otherwise a later create()/remove() could read a signal that's already + // been given away here. Re-locate the record by id - it may have been erased + // (window_manager_remove()) while we waited. Either way, release our own reference: + // whichever side (us or a claimer) does this last is the one that actually deletes it. mutex_lock(&s.mutex); auto iterator = std::find_if(s.windows.begin(), s.windows.end(), [id](const WindowRecord& window) { return window.id == id; }); - if (iterator != s.windows.end() && iterator->waiting_task == xTaskGetCurrentTaskHandle()) { - iterator->waiting_task = nullptr; + if (iterator != s.windows.end() && iterator->waiting_signal == signal) { + iterator->waiting_signal = nullptr; } + bool should_delete = (--signal->refcount == 0); mutex_unlock(&s.mutex); - - /* create()/remove() read+clear `waiting_task` under the lock but call xTaskNotifyGive() - * after releasing it, so a notification can still land on us right around the timeout - * boundary regardless of which branch above ran. Drain it now (non-blocking) so it doesn't - * linger and cause a spurious immediate return the next time this task awaits. */ - ulTaskNotifyTake(pdTRUE, 0); + if (should_delete) { + vSemaphoreDelete(signal->semaphore); + delete signal; + } return window_manager_get_state(id); } diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index e1668da93..5a6897538 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -8,6 +8,9 @@ list(APPEND REQUIRES_LIST TactilityKernel TactilityFreeRtos lvgl-module + lvgl-window-manager-module + app-module + app-esp32-module crypt-module gps-module gps-generic-module diff --git a/TactilityC/Include/tt_app_fileselection.h b/TactilityC/Include/tt_app_fileselection.h index 659a06d28..5916a067f 100644 --- a/TactilityC/Include/tt_app_fileselection.h +++ b/TactilityC/Include/tt_app_fileselection.h @@ -8,13 +8,13 @@ extern "C" { /** * Show a file selection dialog that allows the user to select an existing file. - * @return the launch ID of the dialog, which can be compared in onResult to identify the source + * @return the launch ID of the dialog */ AppInstanceId tt_app_fileselection_start_for_existing_file(AppInstanceId app_id); /** * Show a file selection dialog that allows the user to select a new or existing file. - * @return the launch ID of the dialog, which can be compared in onResult to identify the source + * @return the launch ID of the dialog */ AppInstanceId tt_app_fileselection_start_for_existing_or_new_file(AppInstanceId app_id); diff --git a/TactilityKernel/include/tactility/preferences.h b/TactilityKernel/include/tactility/preferences.h index 908086bf4..bea2031cd 100644 --- a/TactilityKernel/include/tactility/preferences.h +++ b/TactilityKernel/include/tactility/preferences.h @@ -21,12 +21,13 @@ extern "C" { typedef struct Preferences Preferences; /** - * Open (or create) a preferences store backed by the properties file at @a path. The file is + * Open (or create) a preferences store backed by the properties file at @a path. The parent + * directory is created (recursively, like mkdir -p) if it doesn't already exist. The file is * read into memory now; changes made with preferences_put_*() are only written back to disk by * preferences_close(). - * @param[in] path absolute or relative file path (e.g. "/data/settings.properties") - the - * parent directory must already exist - * @return the new instance, or NULL on allocation failure + * @param[in] path absolute or relative file path (e.g. "/data/settings.properties") + * @return the new instance, or NULL if the parent directory couldn't be created, or on + * allocation failure */ Preferences* preferences_open(const char* path); diff --git a/TactilityKernel/include/tactility/properties_file.h b/TactilityKernel/include/tactility/properties_file.h index a08499c03..2df582f3a 100644 --- a/TactilityKernel/include/tactility/properties_file.h +++ b/TactilityKernel/include/tactility/properties_file.h @@ -26,13 +26,22 @@ typedef struct PropertiesFile PropertiesFile; * made with properties_file_set() are only written back to disk by properties_file_close(). * @param[in] path absolute or relative file path (e.g. "/data/settings.properties") - the * parent directory must already exist - * @return the new instance, or NULL on allocation failure + * @return the new instance, or NULL on allocation failure, or NULL if @a path exists but a + * genuine I/O error interrupted reading it (a missing file is not an error - the instance + * starts out empty in that case) */ PropertiesFile* properties_file_open(const char* path); -/** Writes any pending properties_file_set() changes to the backing file, then releases the - * instance. */ -void properties_file_close(PropertiesFile* file); +/** + * Writes any pending properties_file_set() changes to the backing file (atomically - via a + * temporary file in the same directory, renamed over the real path - so a write failure leaves + * the previous on-disk content untouched rather than a truncated/partial file), then releases + * the instance either way. + * @retval ERROR_NONE the backing file was fully updated + * @retval ERROR_RESOURCE writing failed (full filesystem, I/O error, ...) - the previous + * on-disk content, if any, is unchanged; the in-memory changes are lost along with the instance + */ +error_t properties_file_close(PropertiesFile* file); bool properties_file_has(const PropertiesFile* file, const char* key); diff --git a/TactilityKernel/include/tactility/system_event.h b/TactilityKernel/include/tactility/system_event.h index 826da6542..2d5356f1c 100644 --- a/TactilityKernel/include/tactility/system_event.h +++ b/TactilityKernel/include/tactility/system_event.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include #include #include @@ -161,6 +162,15 @@ struct SystemEventSubscription { uint32_t sequence; uint32_t consumed_sequence; + /** Number of tasks currently blocked in system_event_await() on `semaphore` - + * system_event_unsubscribe() waits for this to reach 0 before deleting it, since + * FreeRTOS requires no task be blocked on a semaphore when it's deleted. */ + int waiter_count; + /** Set by system_event_unsubscribe() before it gives `semaphore` and waits, so a task + * already blocked in system_event_await() bails out (ERROR_INVALID_STATE) instead of + * waiting out its full timeout. Reset on the next system_event_subscribe(). */ + bool cancelled; + struct SystemEventSubscription* next; } internal; }; @@ -180,6 +190,10 @@ error_t system_event_subscribe(struct SystemEventSubscription* sub); /** * Remove a previously registered poll subscription. * @warning Does not work in ISR context. + * @warning Blocks (briefly - not for the full duration of anyone's timeout) until any task + * currently blocked in system_event_await() on @a sub has woken up and left, so it's safe to + * delete the subscription's semaphore before this call returns. A blocked awaiter is woken + * (with ERROR_INVALID_STATE) as part of this call rather than left to time out on its own. * @param[in] sub subscription to remove, as passed to system_event_subscribe() * @return ERROR_NONE on success, ERROR_NOT_FOUND if no matching subscription exists */ @@ -187,9 +201,18 @@ error_t system_event_unsubscribe(struct SystemEventSubscription* sub); /** * Blocks the calling task until a new event arrives for @a sub, or timeout elapses. + * @warning Poll subscriptions coalesce to the latest event, they are not a queue: if + * system_event_emit() is called more than once for @a sub->event.type between two + * system_event_await() calls, only the most recent event's data/timestamp is visible via + * system_event_get_data()/system_event_get_timestamp() afterward - intermediate events are + * silently overwritten, never delivered. Use system_event_callback_add() instead if every + * individual event matters. * @param[in,out] sub subscription to wait on, as passed to system_event_subscribe() * @param[in] timeout max ticks to wait - * @return ERROR_NONE if an event arrived, ERROR_TIMEOUT if the timeout elapsed + * @retval ERROR_NONE an event arrived + * @retval ERROR_TIMEOUT @a timeout elapsed first + * @retval ERROR_INVALID_STATE another task called system_event_unsubscribe() on @a sub while + * this call was blocked */ error_t system_event_await(struct SystemEventSubscription* sub, TickType_t timeout); diff --git a/TactilityKernel/source/bundle.cpp b/TactilityKernel/source/bundle.cpp index 92b49984f..20f0eff82 100644 --- a/TactilityKernel/source/bundle.cpp +++ b/TactilityKernel/source/bundle.cpp @@ -41,8 +41,19 @@ Bundle* bundle_alloc(void) { Bundle* bundle_clone(const Bundle* bundle) { auto* clone = new (std::nothrow) Bundle(); - if (clone != nullptr) { + if (clone == nullptr) { + return nullptr; + } + // The Bundle allocation above is nothrow, but copy-assigning `entries` (allocating a node + // and copying the key/value_string for every entry) is not - std::bad_alloc could still + // escape mid-copy. Callers only ever check for a NULL return, so convert that into the + // documented nullptr-on-failure contract instead of letting it propagate out of this + // extern "C" function (which would be undefined behavior). + try { clone->entries = bundle->entries; + } catch (...) { + delete clone; + return nullptr; } return clone; } diff --git a/TactilityKernel/source/preferences.cpp b/TactilityKernel/source/preferences.cpp index ee77e436b..555b36f78 100644 --- a/TactilityKernel/source/preferences.cpp +++ b/TactilityKernel/source/preferences.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -57,6 +58,60 @@ bool split_tag(const std::string& tagged_value, std::string& tag, std::string& r return true; } +// Rejects anything but an exact "0" or "1" - a manually edited or corrupted properties file +// could otherwise have e.g. "b:garbage" silently read back as false. +bool parse_bool(const std::string& raw_value, bool& out) { + if (raw_value == "0") { + out = false; + return true; + } + if (raw_value == "1") { + out = true; + return true; + } + return false; +} + +// strtol()'s own error signaling (errno/end pointer) is easy to get wrong by omission: called +// naively, it silently accepts trailing garbage ("42abc"), out-of-range input (clamped to +// LONG_MIN/LONG_MAX instead of failing), and - since `long` can be wider than int32_t (e.g. on +// the posix simulator, where `long` is 64-bit) - a value that overflows int32_t but not `long` +// would silently truncate on the narrowing cast instead of being rejected. +bool parse_int32(const std::string& raw_value, int32_t& out) { + if (raw_value.empty()) { + return false; + } + errno = 0; + char* end = nullptr; + long parsed = std::strtol(raw_value.c_str(), &end, 10); + if (errno == ERANGE || end != raw_value.c_str() + raw_value.size()) { + return false; + } + if (parsed < INT32_MIN || parsed > INT32_MAX) { + return false; + } + out = static_cast(parsed); + return true; +} + +// See parse_int32() - same reasoning, with strtoll()/`long long`/int64_t. +bool parse_int64(const std::string& raw_value, int64_t& out) { + if (raw_value.empty()) { + return false; + } + errno = 0; + char* end = nullptr; + long long parsed = std::strtoll(raw_value.c_str(), &end, 10); + if (errno == ERANGE || end != raw_value.c_str() + raw_value.size()) { + return false; + } + if (parsed < INT64_MIN || parsed > INT64_MAX) { + return false; + } + out = static_cast(parsed); + return true; +} + bool ensure_directory(const std::string& path) { struct stat info {}; if (stat(path.c_str(), &info) == 0) { @@ -75,6 +130,16 @@ bool ensure_directory_recursive(const std::string& path) { return ensure_directory(path); } +// "" if @a path has no directory component (e.g. a bare filename) - nothing to create in that +// case, the current/root directory already exists. +std::string parent_directory(const std::string& path) { + size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) { + return ""; + } + return path.substr(0, slash); +} + } // namespace // Definition of the opaque handle declared in tactility/preferences.h - C callers only ever @@ -109,6 +174,11 @@ bool try_get_tagged(const PropertiesFile* file, const char* key, std::string& ta extern "C" { Preferences* preferences_open(const char* path) { + std::string directory = parent_directory(path); + if (!directory.empty() && !ensure_directory_recursive(directory)) { + return nullptr; + } + PropertiesFile* file = properties_file_open(path); if (file == nullptr) { return nullptr; @@ -129,17 +199,20 @@ void preferences_close(Preferences* preferences) { bool preferences_has_bool(const Preferences* preferences, const char* key) { std::string tag, raw_value; - return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "b"; + bool value; + return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "b" && parse_bool(raw_value, value); } bool preferences_has_int32(const Preferences* preferences, const char* key) { std::string tag, raw_value; - return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "i32"; + int32_t value; + return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "i32" && parse_int32(raw_value, value); } bool preferences_has_int64(const Preferences* preferences, const char* key) { std::string tag, raw_value; - return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "i64"; + int64_t value; + return try_get_tagged(preferences->file, key, tag, raw_value) && tag == "i64" && parse_int64(raw_value, value); } bool preferences_has_string(const Preferences* preferences, const char* key) { @@ -152,8 +225,7 @@ bool preferences_opt_bool(const Preferences* preferences, const char* key, bool* if (!try_get_tagged(preferences->file, key, tag, raw_value) || tag != "b") { return false; } - *out_value = (raw_value == "1"); - return true; + return parse_bool(raw_value, *out_value); } bool preferences_opt_int32(const Preferences* preferences, const char* key, int32_t* out_value) { @@ -161,8 +233,7 @@ bool preferences_opt_int32(const Preferences* preferences, const char* key, int3 if (!try_get_tagged(preferences->file, key, tag, raw_value) || tag != "i32") { return false; } - *out_value = static_cast(std::strtol(raw_value.c_str(), nullptr, 10)); - return true; + return parse_int32(raw_value, *out_value); } bool preferences_opt_int64(const Preferences* preferences, const char* key, int64_t* out_value) { @@ -170,8 +241,7 @@ bool preferences_opt_int64(const Preferences* preferences, const char* key, int6 if (!try_get_tagged(preferences->file, key, tag, raw_value) || tag != "i64") { return false; } - *out_value = static_cast(std::strtoll(raw_value.c_str(), nullptr, 10)); - return true; + return parse_int64(raw_value, *out_value); } error_t preferences_opt_string(const Preferences* preferences, const char* key, char* out_value, size_t out_value_size) { diff --git a/TactilityKernel/source/properties_file.cpp b/TactilityKernel/source/properties_file.cpp index 418bb191b..c2203bab5 100644 --- a/TactilityKernel/source/properties_file.cpp +++ b/TactilityKernel/source/properties_file.cpp @@ -48,7 +48,10 @@ namespace { // close(). Mirrors Tactility's loadPropertiesFile(): "#"-prefixed and blank lines are skipped; // a "[section]" line becomes a literal prefix (verbatim, brackets included) prepended to every // subsequent key, until the next "[section]" line replaces it. -void load_from_file(PropertiesFile* file) { +// @return false if the file exists but a genuine I/O error interrupted reading it (fgetc()'s +// EOF return doesn't by itself distinguish clean end-of-file from a read error - ferror() after +// the loop does); true otherwise, including for a missing file. +bool load_from_file(PropertiesFile* file) { FileMutex mutex {}; file_mutex_get(&mutex, file->path.c_str()); file_mutex_lock(&mutex); @@ -56,7 +59,7 @@ void load_from_file(PropertiesFile* file) { FILE* handle = std::fopen(file->path.c_str(), "r"); if (handle == nullptr) { file_mutex_unlock(&mutex); - return; + return true; } std::string key_prefix; @@ -94,28 +97,62 @@ void load_from_file(PropertiesFile* file) { } flush_line(); + bool read_ok = std::ferror(handle) == 0; std::fclose(handle); file_mutex_unlock(&mutex); + + if (!read_ok) { + LOG_E(TAG, "Failed to read %s", file->path.c_str()); + } + return read_ok; } -void save_to_file(const PropertiesFile* file) { +// Writes to a temporary file in the same directory, then atomically replaces the real path - +// opening the real path directly with "w" would truncate it immediately, so any failure +// partway through (full filesystem, I/O error, a reset before close) would discard the +// previously-good content instead of leaving it intact. Same directory so rename() stays on one +// filesystem, which is what makes it atomic. +// @return true if the backing file was fully replaced with the current entries; false (leaving +// the previous on-disk content untouched) if any step failed. +bool save_to_file(const PropertiesFile* file) { FileMutex mutex {}; file_mutex_get(&mutex, file->path.c_str()); file_mutex_lock(&mutex); - FILE* handle = std::fopen(file->path.c_str(), "w"); + std::string temp_path = file->path + ".tmp"; + + FILE* handle = std::fopen(temp_path.c_str(), "w"); if (handle == nullptr) { - LOG_E(TAG, "Failed to open %s", file->path.c_str()); + LOG_E(TAG, "Failed to open %s", temp_path.c_str()); file_mutex_unlock(&mutex); - return; + return false; } for (const auto& [key, value] : file->entries) { std::fprintf(handle, "%s=%s\n", key.c_str(), value.c_str()); } - std::fclose(handle); + // Order matters: ferror()/fflush() need the still-open handle, fclose() consumes it. + bool write_ok = std::ferror(handle) == 0; + bool flush_ok = std::fflush(handle) == 0; + bool close_ok = std::fclose(handle) == 0; + + if (!write_ok || !flush_ok || !close_ok) { + LOG_E(TAG, "Failed to write %s", temp_path.c_str()); + std::remove(temp_path.c_str()); + file_mutex_unlock(&mutex); + return false; + } + + if (std::rename(temp_path.c_str(), file->path.c_str()) != 0) { + LOG_E(TAG, "Failed to replace %s", file->path.c_str()); + std::remove(temp_path.c_str()); + file_mutex_unlock(&mutex); + return false; + } + file_mutex_unlock(&mutex); + return true; } } // namespace @@ -128,13 +165,17 @@ PropertiesFile* properties_file_open(const char* path) { return nullptr; } file->path = path; - load_from_file(file); + if (!load_from_file(file)) { + delete file; + return nullptr; + } return file; } -void properties_file_close(PropertiesFile* file) { - save_to_file(file); +error_t properties_file_close(PropertiesFile* file) { + bool saved = save_to_file(file); delete file; + return saved ? ERROR_NONE : ERROR_RESOURCE; } bool properties_file_has(const PropertiesFile* file, const char* key) { diff --git a/TactilityKernel/source/system_event.cpp b/TactilityKernel/source/system_event.cpp index 403845b7a..4207482ac 100644 --- a/TactilityKernel/source/system_event.cpp +++ b/TactilityKernel/source/system_event.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -186,6 +187,8 @@ error_t system_event_subscribe(SystemEventSubscription* sub) { sub->internal.semaphore = semaphore; sub->internal.sequence = 0; sub->internal.consumed_sequence = 0; + sub->internal.waiter_count = 0; + sub->internal.cancelled = false; sub->event.data_len = 0; sub->internal.next = poll_subscriptions; poll_subscriptions = sub; @@ -197,6 +200,7 @@ error_t system_event_subscribe(SystemEventSubscription* sub) { error_t system_event_unsubscribe(SystemEventSubscription* sub) { error_t result = ERROR_NOT_FOUND; + SemaphoreHandle_t semaphore_to_delete = nullptr; mutex_lock(&poll_subscriptions_mutex.handle); for (SystemEventSubscription** link = &poll_subscriptions; *link != nullptr; link = &(*link)->internal.next) { @@ -206,37 +210,109 @@ error_t system_event_unsubscribe(SystemEventSubscription* sub) { break; } } - mutex_unlock(&poll_subscriptions_mutex.handle); - if (result == ERROR_NONE) { - // Unlinked first, so notify_poll_subscribers() can no longer reach this semaphore - // before it's deleted. - vSemaphoreDelete(sub->internal.semaphore); + // Unlinked first, so notify_poll_subscribers() can no longer reach this subscription. + // Mark it cancelled (checked by system_event_await()'s loop) and capture the semaphore + // handle into a local variable rather than deleting it via sub->internal.semaphore + // directly - a concurrent system_event_subscribe() re-registering this same `sub` after + // this point would overwrite that field with a freshly created semaphore, and we must + // not delete the wrong (newly active) one. + sub->internal.cancelled = true; + semaphore_to_delete = sub->internal.semaphore; sub->internal.semaphore = nullptr; } + mutex_unlock(&poll_subscriptions_mutex.handle); - return result; + if (result != ERROR_NONE) { + return result; + } + + // Nudge any task already blocked in system_event_await() (it captured its own local copy + // of this same semaphore handle before this point, so it's unaffected by the field having + // just been cleared above) so it re-checks `cancelled` and bails out now instead of waiting + // out its full timeout, then wait for it to actually leave the semaphore before deleting it + // - FreeRTOS requires no task be blocked on a semaphore when it's deleted. + xSemaphoreGive(semaphore_to_delete); + while (true) { + mutex_lock(&poll_subscriptions_mutex.handle); + bool still_waiting = sub->internal.waiter_count > 0; + mutex_unlock(&poll_subscriptions_mutex.handle); + + if (!still_waiting) { + break; + } + delay_ticks(pdMS_TO_TICKS(10)); + } + + vSemaphoreDelete(semaphore_to_delete); + + // Reset so a future system_event_subscribe() re-registering this same `sub` isn't left + // pre-cancelled (subscribe() also resets this itself, defensively). + sub->internal.cancelled = false; + + return ERROR_NONE; } error_t system_event_await(SystemEventSubscription* sub, TickType_t timeout) { - uint32_t old_sequence = sub->internal.sequence; + mutex_lock(&poll_subscriptions_mutex.handle); + SemaphoreHandle_t semaphore = sub->internal.semaphore; + sub->internal.waiter_count++; + mutex_unlock(&poll_subscriptions_mutex.handle); - while (sub->internal.sequence == old_sequence) { - if (xSemaphoreTake(sub->internal.semaphore, timeout) == pdFALSE) { - return ERROR_TIMEOUT; + error_t result = ERROR_NONE; + + // sequence/consumed_sequence are written by notify_poll_subscribers() under + // poll_subscriptions_mutex - read (and, on a match, updated) under the same lock each + // iteration, rather than compared lock-free, so a concurrent emit can't land between an + // unlocked read and this loop acting on it. + // + // Compare against consumed_sequence, not a sequence snapshot taken now - an emit that + // landed between system_event_subscribe() and this call already incremented sequence and + // gave the semaphore, so that event is pending but unconsumed. Snapshotting "now" would + // make the loop wait for yet another event instead of returning this already-pending one. + while (true) { + mutex_lock(&poll_subscriptions_mutex.handle); + bool pending = sub->internal.sequence != sub->internal.consumed_sequence; + bool cancelled = sub->internal.cancelled; + if (pending) { + sub->internal.consumed_sequence = sub->internal.sequence; + } + mutex_unlock(&poll_subscriptions_mutex.handle); + + if (pending) { + break; + } + if (cancelled) { + result = ERROR_INVALID_STATE; + break; + } + if (xSemaphoreTake(semaphore, timeout) == pdFALSE) { + result = ERROR_TIMEOUT; + break; } } - sub->internal.consumed_sequence = sub->internal.sequence; - return ERROR_NONE; + mutex_lock(&poll_subscriptions_mutex.handle); + sub->internal.waiter_count--; + mutex_unlock(&poll_subscriptions_mutex.handle); + + return result; } error_t system_event_get_data(SystemEventSubscription* sub, uint8_t* data, size_t data_len) { + // sub->event.* is written by notify_poll_subscribers() under poll_subscriptions_mutex - + // the length check and the copy must happen as one snapshot under the same lock, otherwise + // a concurrent emit could grow data_len (or overwrite data) between the check and the + // memcpy below. + mutex_lock(&poll_subscriptions_mutex.handle); + error_t result = ERROR_NONE; if (data_len < sub->event.data_len) { - return ERROR_BUFFER_OVERFLOW; + result = ERROR_BUFFER_OVERFLOW; + } else { + std::memcpy(data, sub->event.data, sub->event.data_len); } - std::memcpy(data, sub->event.data, sub->event.data_len); - return ERROR_NONE; + mutex_unlock(&poll_subscriptions_mutex.handle); + return result; } } // extern "C" diff --git a/Tests/TactilityKernel/Source/PreferencesTest.cpp b/Tests/TactilityKernel/Source/PreferencesTest.cpp index 2c1d28987..635c7afd4 100644 --- a/Tests/TactilityKernel/Source/PreferencesTest.cpp +++ b/Tests/TactilityKernel/Source/PreferencesTest.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include namespace { @@ -22,6 +24,19 @@ bool file_exists(const char* path) { return true; } +bool is_directory(const char* path) { + struct stat info {}; + return stat(path, &info) == 0 && (info.st_mode & S_IFMT) == S_IFDIR; +} + +// Writes a raw properties file directly (bypassing preferences_put_*()) so a test can exercise +// a hand-crafted/corrupted payload that preferences_put_*() itself would never produce. +void write_raw(const char* path, const char* content) { + FILE* file = std::fopen(path, "w"); + std::fputs(content, file); + std::fclose(file); +} + } // namespace TEST_CASE("preferences_open_path on a missing file starts out empty, without creating it") { @@ -98,6 +113,47 @@ TEST_CASE("has_*/opt_* reject a key stored with a different type") { preferences_close(preferences); } +TEST_CASE("has_*/opt_* reject malformed scalar payloads instead of misparsing them") { + ScratchFile scratch; + write_raw(TEST_PATH, + "bad_bool=b:garbage\n" + "bad_bool_2=b:2\n" + "trailing_junk=i32:42abc\n" + "int32_overflow=i32:5000000000\n" + "int64_overflow=i64:99999999999999999999\n" + "empty_int=i32:\n"); + + Preferences* preferences = preferences_open(TEST_PATH); + + // "b:garbage" must not silently read back as false - has_bool()/opt_bool() must agree it's + // not a valid bool at all. + CHECK_FALSE(preferences_has_bool(preferences, "bad_bool")); + bool bool_out = true; + CHECK_FALSE(preferences_opt_bool(preferences, "bad_bool", &bool_out)); + CHECK_FALSE(preferences_has_bool(preferences, "bad_bool_2")); + CHECK_FALSE(preferences_opt_bool(preferences, "bad_bool_2", &bool_out)); + + // "42abc" must not silently parse as 42 - the full payload must be consumed. + CHECK_FALSE(preferences_has_int32(preferences, "trailing_junk")); + int32_t int32_out = 0; + CHECK_FALSE(preferences_opt_int32(preferences, "trailing_junk", &int32_out)); + + // Fits in a (64-bit, on this platform) `long` but overflows int32_t - must not silently + // truncate on the narrowing cast. + CHECK_FALSE(preferences_has_int32(preferences, "int32_overflow")); + CHECK_FALSE(preferences_opt_int32(preferences, "int32_overflow", &int32_out)); + + // Overflows even a 64-bit integer - strtoll() itself reports ERANGE. + CHECK_FALSE(preferences_has_int64(preferences, "int64_overflow")); + int64_t int64_out = 0; + CHECK_FALSE(preferences_opt_int64(preferences, "int64_overflow", &int64_out)); + + CHECK_FALSE(preferences_has_int32(preferences, "empty_int")); + CHECK_FALSE(preferences_opt_int32(preferences, "empty_int", &int32_out)); + + preferences_close(preferences); +} + TEST_CASE("a string value with embedded newlines and backslashes survives a reopen") { ScratchFile scratch; @@ -151,3 +207,37 @@ TEST_CASE("put_* on an already-closed value is visible without reopening") { CHECK_EQ(out, 2); preferences_close(final_instance); } + +TEST_CASE("preferences_open creates missing parent directories (recursively) and persists into them") { + const char* nested_dir_a = "/tmp/tactility_kernel_preferences_test_nested"; + const char* nested_dir_b = "/tmp/tactility_kernel_preferences_test_nested/a"; + const char* nested_dir_c = "/tmp/tactility_kernel_preferences_test_nested/a/b"; + const char* nested_path = "/tmp/tactility_kernel_preferences_test_nested/a/b/settings.properties"; + + std::remove(nested_path); + rmdir(nested_dir_c); + rmdir(nested_dir_b); + rmdir(nested_dir_a); + REQUIRE_FALSE(is_directory(nested_dir_a)); + + Preferences* preferences = preferences_open(nested_path); + REQUIRE_NE(preferences, nullptr); + CHECK(is_directory(nested_dir_a)); + CHECK(is_directory(nested_dir_b)); + CHECK(is_directory(nested_dir_c)); + + preferences_put_int32(preferences, "count", 7); + preferences_close(preferences); + CHECK(file_exists(nested_path)); + + Preferences* reopened = preferences_open(nested_path); + int32_t out = 0; + CHECK(preferences_opt_int32(reopened, "count", &out)); + CHECK_EQ(out, 7); + preferences_close(reopened); + + std::remove(nested_path); + rmdir(nested_dir_c); + rmdir(nested_dir_b); + rmdir(nested_dir_a); +} diff --git a/Tests/TactilityKernel/Source/PropertiesFileTest.cpp b/Tests/TactilityKernel/Source/PropertiesFileTest.cpp index 6f7a4b579..520b9b47d 100644 --- a/Tests/TactilityKernel/Source/PropertiesFileTest.cpp +++ b/Tests/TactilityKernel/Source/PropertiesFileTest.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include @@ -44,6 +46,19 @@ TEST_CASE("properties_file_open on a missing file starts out empty, without crea properties_file_close(file); } +TEST_CASE("properties_file_open returns NULL when a genuine I/O error interrupts reading") { + // fopen() on a directory succeeds on Linux, but the first read fails with EISDIR and sets + // the stream's error indicator - a deterministic way to exercise load_from_file()'s + // ferror() check without needing real storage-hardware fault injection. + const char* dir_path = "/tmp/tactility_kernel_properties_file_test_is_a_directory"; + rmdir(dir_path); + REQUIRE_EQ(mkdir(dir_path, 0777), 0); + + CHECK_EQ(properties_file_open(dir_path), nullptr); + + rmdir(dir_path); +} + TEST_CASE("set/has/get round-trip, and close persists while unclosed changes don't") { ScratchFile scratch; @@ -154,6 +169,63 @@ TEST_CASE("a [section] line prefixes every following key until the next section" properties_file_close(file); } +TEST_CASE("properties_file_close reports ERROR_NONE on success") { + ScratchFile scratch; + + PropertiesFile* file = properties_file_open(TEST_PATH); + properties_file_set(file, "key", "value"); + + CHECK_EQ(properties_file_close(file), ERROR_NONE); +} + +TEST_CASE("properties_file_close reports ERROR_RESOURCE when the parent directory doesn't exist") { + const char* path = "/tmp/tactility_kernel_properties_file_test_missing_dir/settings.properties"; + std::remove(path); // no-op if the directory doesn't exist, which is the point of this test + + // Missing directory is not an error for open() - it starts out empty, same as a missing + // file (see the "starts out empty" test above). + PropertiesFile* file = properties_file_open(path); + REQUIRE_NE(file, nullptr); + properties_file_set(file, "key", "value"); + + // close()'s save can't create its temp file in a directory that doesn't exist. + CHECK_EQ(properties_file_close(file), ERROR_RESOURCE); +} + +TEST_CASE("a failed close leaves previously-saved content on disk untouched") { + const char* dir = "/tmp/tactility_kernel_properties_file_readonly_test"; + const char* path = "/tmp/tactility_kernel_properties_file_readonly_test/settings.properties"; + + mkdir(dir, 0777); + chmod(dir, 0777); + std::remove(path); + + { + PropertiesFile* file = properties_file_open(path); + properties_file_set(file, "key", "original"); + REQUIRE_EQ(properties_file_close(file), ERROR_NONE); + } + + // Read-only directory - save_to_file()'s temp file can't be created there, so the close + // below must fail without disturbing the "original" content already on disk. + REQUIRE_EQ(chmod(dir, 0555), 0); + + PropertiesFile* file = properties_file_open(path); + properties_file_set(file, "key", "corrupted"); + CHECK_EQ(properties_file_close(file), ERROR_RESOURCE); + + chmod(dir, 0777); // restore write access for the check below and for cleanup + + PropertiesFile* reloaded = properties_file_open(path); + char buffer[32]; + CHECK_EQ(properties_file_get(reloaded, "key", buffer, sizeof(buffer)), ERROR_NONE); + CHECK_EQ(std::strcmp(buffer, "original"), 0); + properties_file_close(reloaded); + + std::remove(path); + rmdir(dir); +} + TEST_CASE("properties_file_for_each visits every key exactly once") { ScratchFile scratch; diff --git a/Tests/TactilityKernel/Source/SystemEventTest.cpp b/Tests/TactilityKernel/Source/SystemEventTest.cpp index fa7c8e09d..750c66cbc 100644 --- a/Tests/TactilityKernel/Source/SystemEventTest.cpp +++ b/Tests/TactilityKernel/Source/SystemEventTest.cpp @@ -257,6 +257,31 @@ TEST_CASE("system_event_subscribe/_await deliver the event payload by value") { CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NOT_FOUND); } +TEST_CASE("system_event_await returns a matching event that arrived before it started waiting") { + SystemEventSubscription sub {}; + sub.event.type = KERNEL_EVENT_NETWORK_CONNECTED; + CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE); + + // Same-thread emit, no background thread needed: unlike the "detects a change after it + // starts waiting" tests above, this is exactly the case system_event_await() must handle - + // sequence already moved ahead of consumed_sequence before await() is even called. + NetworkConnectedEvent connected { .device = nullptr, .ipv4_addr = 0x0A000001, .gateway = 0x0A0000FE }; + CHECK_EQ(system_event_emit(KERNEL_EVENT_NETWORK_CONNECTED, &connected, sizeof(connected)), ERROR_NONE); + + CHECK_EQ(system_event_await(&sub, 0), ERROR_NONE); + + NetworkConnectedEvent received {}; + CHECK_EQ(system_event_get_data(&sub, reinterpret_cast(&received), sizeof(received)), ERROR_NONE); + CHECK_EQ(received.ipv4_addr, connected.ipv4_addr); + CHECK_EQ(received.gateway, connected.gateway); + + // The pending event was consumed by the call above - a second await() with no further + // emit must time out rather than returning the same event again. + CHECK_EQ(system_event_await(&sub, 0), ERROR_TIMEOUT); + + system_event_unsubscribe(&sub); +} + TEST_CASE("system_event_await times out when no matching event has arrived") { SystemEventSubscription sub {}; sub.event.type = KERNEL_EVENT_TIME_CHANGED; @@ -341,3 +366,74 @@ TEST_CASE("system_event_get_data on a subscription with no payload copies nothin system_event_unsubscribe(&sub); } + +// Regression coverage for system_event_unsubscribe() racing a task blocked in +// system_event_await() on the same subscription, and for reusing a subscription node after +// unsubscribing it - see the @warning on system_event_unsubscribe() in system_event.h. + +TEST_CASE("system_event_unsubscribe wakes a task blocked in system_event_await with ERROR_INVALID_STATE") { + SystemEventSubscription sub {}; + sub.event.type = KERNEL_EVENT_SERVICE_STARTED; + CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE); + + auto* thread = thread_alloc_full( + "system-event-awaiter", + 4096, + [](void* context) { + auto* awaited_sub = static_cast(context); + // Long timeout - the point is that unsubscribe() wakes this early, not that it + // eventually times out on its own. + return static_cast(system_event_await(awaited_sub, pdMS_TO_TICKS(5000))); + }, + &sub, + -1 + ); + CHECK_EQ(thread_start(thread), ERROR_NONE); + + // Give the awaiter task a moment to actually reach xSemaphoreTake() before unsubscribing - + // otherwise this test wouldn't exercise the "already blocked" race at all. + delay_millis(20); + + // Must return promptly (nudging the blocked awaiter awake), not by waiting out its timeout. + TickType_t before = get_ticks(); + CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE); + CHECK_LT(get_ticks() - before, pdMS_TO_TICKS(1000)); + + CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE); + CHECK_EQ(thread_get_return_code(thread), ERROR_INVALID_STATE); + thread_free(thread); + + // A second unsubscribe() has nothing left to do. + CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NOT_FOUND); +} + +TEST_CASE("a subscription node can be re-subscribed after system_event_unsubscribe") { + SystemEventSubscription sub {}; + sub.event.type = KERNEL_EVENT_SERVICE_STOPPED; + + CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE); + CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE); + + // Re-registering the same node (same storage, not a fresh SystemEventSubscription) must + // work as if it were new - a fresh semaphore, and no leftover `cancelled` state from the + // unsubscribe() above causing an immediate spurious ERROR_INVALID_STATE below. + CHECK_EQ(system_event_subscribe(&sub), ERROR_NONE); + + auto* thread = thread_alloc_full( + "system-event-emitter", + 4096, + [](void*) { + delay_millis(20); + system_event_emit(KERNEL_EVENT_SERVICE_STOPPED, nullptr, 0); + return 0; + }, + nullptr, + -1 + ); + CHECK_EQ(thread_start(thread), ERROR_NONE); + CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); + CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE); + thread_free(thread); + + CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE); +} From a2056e8e96dba2292f2b3a563931453efca6d730 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 18:51:59 +0200 Subject: [PATCH 06/31] Fixes --- Modules/app-module/source/app_scheduler.cpp | 7 +++-- .../lvgl-window-manager-module/CMakeLists.txt | 2 +- .../lvgl_window_manager/window_manager.h | 8 ++++++ .../source/window_manager.cpp | 2 +- Tactility/CMakeLists.txt | 2 +- .../include/tactility/properties_file.h | 4 +-- .../include/tactility/system_event.h | 19 ++++++++++++- TactilityKernel/source/bundle.cpp | 12 +------- TactilityKernel/source/properties_file.cpp | 14 +++++++--- TactilityKernel/source/system_event.cpp | 28 +++++++++++++++++-- .../Source/PreferencesTest.cpp | 1 + .../Source/PropertiesFileTest.cpp | 12 ++++++-- .../Source/SystemEventTest.cpp | 4 +-- 13 files changed, 85 insertions(+), 30 deletions(-) diff --git a/Modules/app-module/source/app_scheduler.cpp b/Modules/app-module/source/app_scheduler.cpp index 948222e04..fa7f76238 100644 --- a/Modules/app-module/source/app_scheduler.cpp +++ b/Modules/app-module/source/app_scheduler.cpp @@ -71,13 +71,16 @@ void set_completion(AppInstanceId app_instance_id, AppCompletionSignal* completi // Takes a reference on app_instance_id's completion signal (see AppCompletionSignal), for the // caller to wait on. @return the signal to wait on, or NULL if the instance has already fully // finished (its ledger entry - and so its reference to the signal - is already gone) and so -// there's nothing left to wait for. +// there's nothing left to wait for, or if the instance is still starting up (start_internal() +// in manager.cpp inserts the ledger entry before app_scheduler_start() has gotten as far as +// set_completion() - `completion` is NULL for that whole window) and so there's nothing to +// take a reference on yet. AppCompletionSignal* acquire_completion_signal(AppInstanceId app_instance_id) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); auto iterator = ledger.instances.find(app_instance_id); AppCompletionSignal* completion = nullptr; - if (iterator != ledger.instances.end()) { + if (iterator != ledger.instances.end() && iterator->second.completion != nullptr) { completion = iterator->second.completion; completion->refcount++; } diff --git a/Modules/lvgl-window-manager-module/CMakeLists.txt b/Modules/lvgl-window-manager-module/CMakeLists.txt index d8e518d56..79c6d56ec 100644 --- a/Modules/lvgl-window-manager-module/CMakeLists.txt +++ b/Modules/lvgl-window-manager-module/CMakeLists.txt @@ -7,5 +7,5 @@ file(GLOB_RECURSE SOURCE_FILES "source/*.c*") tactility_add_module(lvgl-window-manager-module SRCS ${SOURCE_FILES} INCLUDE_DIRS include/ - REQUIRES TactilityKernel lvgl-module + REQUIRES TactilityKernel lvgl-module app-module ) diff --git a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h index 7a5c243e0..957be5a5c 100644 --- a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h +++ b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h @@ -36,6 +36,10 @@ enum WindowState { * @return the widget windows should actually be placed into - @a root_widget itself, or a * child of it. Returning NULL falls back to @a root_widget. * @warning Called on the LVGL task with the LVGL lock already held. + * @warning Also called with window-manager's internal lifecycle_mutex held (non-recursive) - + * do NOT call window_manager_start()/window_manager_stop()/window_manager_create()/ + * window_manager_remove() or any other window-manager API from this callback, that would + * deadlock. */ typedef lv_obj_t* (*WindowManagerScreenInitFn)(lv_obj_t* root_widget); @@ -76,6 +80,10 @@ error_t window_manager_stop(void); * window_manager_remove() for the window that used to be on top (e.g. a dialog's own thread as * it closes). Do NOT rely on thread_local state set by this window's own app thread; use * @a user_data instead. + * @warning Also called with window-manager's internal lifecycle_mutex held (non-recursive) - + * do NOT call window_manager_start()/window_manager_stop()/window_manager_create()/ + * window_manager_remove() or any other window-manager API from this callback, that would + * deadlock. */ typedef void (*WindowCreateWidgetsFn)(lv_obj_t* root, void* user_data); diff --git a/Modules/lvgl-window-manager-module/source/window_manager.cpp b/Modules/lvgl-window-manager-module/source/window_manager.cpp index f182f2149..94da9f451 100644 --- a/Modules/lvgl-window-manager-module/source/window_manager.cpp +++ b/Modules/lvgl-window-manager-module/source/window_manager.cpp @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #include -#include "../../app-module/include/app/instance.h" +#include #include diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index 5a6897538..3de858ac9 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -10,7 +10,6 @@ list(APPEND REQUIRES_LIST lvgl-module lvgl-window-manager-module app-module - app-esp32-module crypt-module gps-module gps-generic-module @@ -23,6 +22,7 @@ list(APPEND REQUIRES_LIST if (DEFINED ENV{ESP_IDF_VERSION}) list(APPEND REQUIRES_LIST + app-esp32-module platform-esp32 driver elf_loader diff --git a/TactilityKernel/include/tactility/properties_file.h b/TactilityKernel/include/tactility/properties_file.h index 2df582f3a..3c65ebc98 100644 --- a/TactilityKernel/include/tactility/properties_file.h +++ b/TactilityKernel/include/tactility/properties_file.h @@ -27,8 +27,8 @@ typedef struct PropertiesFile PropertiesFile; * @param[in] path absolute or relative file path (e.g. "/data/settings.properties") - the * parent directory must already exist * @return the new instance, or NULL on allocation failure, or NULL if @a path exists but a - * genuine I/O error interrupted reading it (a missing file is not an error - the instance - * starts out empty in that case) + * genuine error prevented opening or reading it, e.g. a permissions error (a missing file is + * not an error - the instance starts out empty in that case) */ PropertiesFile* properties_file_open(const char* path); diff --git a/TactilityKernel/include/tactility/system_event.h b/TactilityKernel/include/tactility/system_event.h index 2d5356f1c..8ff46cd2f 100644 --- a/TactilityKernel/include/tactility/system_event.h +++ b/TactilityKernel/include/tactility/system_event.h @@ -168,8 +168,19 @@ struct SystemEventSubscription { int waiter_count; /** Set by system_event_unsubscribe() before it gives `semaphore` and waits, so a task * already blocked in system_event_await() bails out (ERROR_INVALID_STATE) instead of - * waiting out its full timeout. Reset on the next system_event_subscribe(). */ + * waiting out its full timeout. Reset once system_event_unsubscribe() finishes + * draining old awaiters (see unsubscribe_in_progress) - not simply "on the next + * system_event_subscribe()", so a fresh registration can never observe a stale `true` + * left over from an unsubscribe that hasn't returned yet. */ bool cancelled; + /** True from the moment system_event_unsubscribe() unlinks `sub` until it has finished + * draining old awaiters and deleted the old semaphore. system_event_subscribe() spins + * until this clears before reusing `sub` - otherwise a new registration could reset + * waiter_count/cancelled (both shared with the old registration, there being only one + * `sub`) out from under the old system_event_unsubscribe() call still relying on them, + * or hand out a new semaphore for that same call to then promptly delete instead of the + * old one, while an old awaiter is still blocked on the real old semaphore. */ + bool unsubscribe_in_progress; struct SystemEventSubscription* next; } internal; @@ -178,6 +189,10 @@ struct SystemEventSubscription { /** * Register a poll subscription for events of @a sub->type. * @warning Does not work in ISR context. + * @warning If @a sub was just passed to system_event_unsubscribe() (e.g. reusing a node for a + * new registration) and that call hasn't returned yet on another task, this call blocks + * (briefly - not for the full duration of anyone's timeout) until it does, before registering - + * see SystemEventSubscription::internal.unsubscribe_in_progress. * @param[in,out] sub subscription to register; caller sets @a sub->type beforehand, owns the * storage, and must keep it alive (and stationary) until unsubscribed * @retval ERROR_NONE on success @@ -194,6 +209,8 @@ error_t system_event_subscribe(struct SystemEventSubscription* sub); * currently blocked in system_event_await() on @a sub has woken up and left, so it's safe to * delete the subscription's semaphore before this call returns. A blocked awaiter is woken * (with ERROR_INVALID_STATE) as part of this call rather than left to time out on its own. + * A concurrent system_event_subscribe() reusing the same @a sub waits out this same window + * (see system_event_subscribe()'s @warning) rather than racing it. * @param[in] sub subscription to remove, as passed to system_event_subscribe() * @return ERROR_NONE on success, ERROR_NOT_FOUND if no matching subscription exists */ diff --git a/TactilityKernel/source/bundle.cpp b/TactilityKernel/source/bundle.cpp index 20f0eff82..9ca06175c 100644 --- a/TactilityKernel/source/bundle.cpp +++ b/TactilityKernel/source/bundle.cpp @@ -44,17 +44,7 @@ Bundle* bundle_clone(const Bundle* bundle) { if (clone == nullptr) { return nullptr; } - // The Bundle allocation above is nothrow, but copy-assigning `entries` (allocating a node - // and copying the key/value_string for every entry) is not - std::bad_alloc could still - // escape mid-copy. Callers only ever check for a NULL return, so convert that into the - // documented nullptr-on-failure contract instead of letting it propagate out of this - // extern "C" function (which would be undefined behavior). - try { - clone->entries = bundle->entries; - } catch (...) { - delete clone; - return nullptr; - } + clone->entries = bundle->entries; return clone; } diff --git a/TactilityKernel/source/properties_file.cpp b/TactilityKernel/source/properties_file.cpp index c2203bab5..5194b7d22 100644 --- a/TactilityKernel/source/properties_file.cpp +++ b/TactilityKernel/source/properties_file.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -48,9 +49,9 @@ namespace { // close(). Mirrors Tactility's loadPropertiesFile(): "#"-prefixed and blank lines are skipped; // a "[section]" line becomes a literal prefix (verbatim, brackets included) prepended to every // subsequent key, until the next "[section]" line replaces it. -// @return false if the file exists but a genuine I/O error interrupted reading it (fgetc()'s -// EOF return doesn't by itself distinguish clean end-of-file from a read error - ferror() after -// the loop does); true otherwise, including for a missing file. +// @return false if the file exists but a genuine I/O error interrupted opening or reading it +// (fgetc()'s EOF return doesn't by itself distinguish clean end-of-file from a read error - +// ferror() after the loop does); true otherwise, including for a missing file (ENOENT). bool load_from_file(PropertiesFile* file) { FileMutex mutex {}; file_mutex_get(&mutex, file->path.c_str()); @@ -58,8 +59,13 @@ bool load_from_file(PropertiesFile* file) { FILE* handle = std::fopen(file->path.c_str(), "r"); if (handle == nullptr) { + const int open_error = errno; file_mutex_unlock(&mutex); - return true; + if (open_error == ENOENT) { + return true; + } + LOG_E(TAG, "Failed to open %s", file->path.c_str()); + return false; } std::string key_prefix; diff --git a/TactilityKernel/source/system_event.cpp b/TactilityKernel/source/system_event.cpp index 4207482ac..73c07ffac 100644 --- a/TactilityKernel/source/system_event.cpp +++ b/TactilityKernel/source/system_event.cpp @@ -166,6 +166,23 @@ error_t system_event_emit( } error_t system_event_subscribe(SystemEventSubscription* sub) { + // Wait out any system_event_unsubscribe() call still draining old awaiters for this same + // `sub` on another task (see internal.unsubscribe_in_progress). waiter_count/cancelled + // belong to `sub` itself, not to a given registration - reusing `sub` before that call + // finishes would reset them out from under it, and could hand out a fresh semaphore for it + // to then promptly delete instead of the old one, while an old awaiter is still blocked on + // the real old semaphore. + while (true) { + mutex_lock(&poll_subscriptions_mutex.handle); + bool busy = sub->internal.unsubscribe_in_progress; + mutex_unlock(&poll_subscriptions_mutex.handle); + + if (!busy) { + break; + } + delay_ticks(pdMS_TO_TICKS(10)); + } + SemaphoreHandle_t semaphore = xSemaphoreCreateBinary(); if (semaphore == nullptr) { return ERROR_OUT_OF_MEMORY; @@ -218,6 +235,9 @@ error_t system_event_unsubscribe(SystemEventSubscription* sub) { // this point would overwrite that field with a freshly created semaphore, and we must // not delete the wrong (newly active) one. sub->internal.cancelled = true; + // Blocks a concurrent system_event_subscribe() from reusing `sub` until this whole + // call returns - see internal.unsubscribe_in_progress and system_event_subscribe(). + sub->internal.unsubscribe_in_progress = true; semaphore_to_delete = sub->internal.semaphore; sub->internal.semaphore = nullptr; } @@ -246,9 +266,13 @@ error_t system_event_unsubscribe(SystemEventSubscription* sub) { vSemaphoreDelete(semaphore_to_delete); - // Reset so a future system_event_subscribe() re-registering this same `sub` isn't left - // pre-cancelled (subscribe() also resets this itself, defensively). + // Reset under the lock, together, as the last step - only past this point is `sub` safe + // for system_event_subscribe() to reuse (see internal.unsubscribe_in_progress and the + // busy-wait at the top of system_event_subscribe()). + mutex_lock(&poll_subscriptions_mutex.handle); sub->internal.cancelled = false; + sub->internal.unsubscribe_in_progress = false; + mutex_unlock(&poll_subscriptions_mutex.handle); return ERROR_NONE; } diff --git a/Tests/TactilityKernel/Source/PreferencesTest.cpp b/Tests/TactilityKernel/Source/PreferencesTest.cpp index 635c7afd4..0478f440a 100644 --- a/Tests/TactilityKernel/Source/PreferencesTest.cpp +++ b/Tests/TactilityKernel/Source/PreferencesTest.cpp @@ -231,6 +231,7 @@ TEST_CASE("preferences_open creates missing parent directories (recursively) and CHECK(file_exists(nested_path)); Preferences* reopened = preferences_open(nested_path); + REQUIRE_NE(reopened, nullptr); int32_t out = 0; CHECK(preferences_opt_int32(reopened, "count", &out)); CHECK_EQ(out, 7); diff --git a/Tests/TactilityKernel/Source/PropertiesFileTest.cpp b/Tests/TactilityKernel/Source/PropertiesFileTest.cpp index 520b9b47d..e2b4fde40 100644 --- a/Tests/TactilityKernel/Source/PropertiesFileTest.cpp +++ b/Tests/TactilityKernel/Source/PropertiesFileTest.cpp @@ -193,11 +193,17 @@ TEST_CASE("properties_file_close reports ERROR_RESOURCE when the parent director } TEST_CASE("a failed close leaves previously-saved content on disk untouched") { + if (geteuid() == 0) { + // Root bypasses directory write permissions, so the read-only directory below would + // not make save_to_file() fail. + return; + } + const char* dir = "/tmp/tactility_kernel_properties_file_readonly_test"; const char* path = "/tmp/tactility_kernel_properties_file_readonly_test/settings.properties"; - mkdir(dir, 0777); - chmod(dir, 0777); + mkdir(dir, 0700); + chmod(dir, 0700); std::remove(path); { @@ -214,7 +220,7 @@ TEST_CASE("a failed close leaves previously-saved content on disk untouched") { properties_file_set(file, "key", "corrupted"); CHECK_EQ(properties_file_close(file), ERROR_RESOURCE); - chmod(dir, 0777); // restore write access for the check below and for cleanup + chmod(dir, 0700); // restore write access for the check below and for cleanup PropertiesFile* reloaded = properties_file_open(path); char buffer[32]; diff --git a/Tests/TactilityKernel/Source/SystemEventTest.cpp b/Tests/TactilityKernel/Source/SystemEventTest.cpp index 750c66cbc..e5a267fe1 100644 --- a/Tests/TactilityKernel/Source/SystemEventTest.cpp +++ b/Tests/TactilityKernel/Source/SystemEventTest.cpp @@ -399,7 +399,7 @@ TEST_CASE("system_event_unsubscribe wakes a task blocked in system_event_await w CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE); CHECK_LT(get_ticks() - before, pdMS_TO_TICKS(1000)); - CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE); + CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE); CHECK_EQ(thread_get_return_code(thread), ERROR_INVALID_STATE); thread_free(thread); @@ -432,7 +432,7 @@ TEST_CASE("a subscription node can be re-subscribed after system_event_unsubscri ); CHECK_EQ(thread_start(thread), ERROR_NONE); CHECK_EQ(system_event_await(&sub, pdMS_TO_TICKS(2000)), ERROR_NONE); - CHECK_EQ(thread_join(thread, 2, 1), ERROR_NONE); + CHECK_EQ(thread_join(thread, pdMS_TO_TICKS(2000), pdMS_TO_TICKS(1)), ERROR_NONE); thread_free(thread); CHECK_EQ(system_event_unsubscribe(&sub), ERROR_NONE); From e5d05a3a6a8a3d8d00bb1f27dcd4bafa700d252c Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 19:03:41 +0200 Subject: [PATCH 07/31] Fixes --- TactilityKernel/include/tactility/system_event.h | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/TactilityKernel/include/tactility/system_event.h b/TactilityKernel/include/tactility/system_event.h index 8ff46cd2f..71a97dc68 100644 --- a/TactilityKernel/include/tactility/system_event.h +++ b/TactilityKernel/include/tactility/system_event.h @@ -144,6 +144,14 @@ error_t system_event_emit( * and polled with system_event_await(). Unlike system_event_callback_t, `event` is a by-value * copy that remains valid for the subscription's lifetime (until the next matching event * overwrites it), not just for the duration of a callback. + * @warning Must be zero-initialized before the first system_event_subscribe() call (e.g. + * `SystemEventSubscription sub = {};` in C++, `SystemEventSubscription sub = {0};` in C, or + * static/global storage) - system_event_subscribe() reads `internal.unsubscribe_in_progress` + * before it writes it, to detect reuse of a node still being torn down by a concurrent + * system_event_unsubscribe() call; on indeterminate (non-zeroed) storage that read is undefined + * behavior. Not required again for a later system_event_subscribe() reusing the same node after + * system_event_unsubscribe() - the fields it depends on are fully owned/maintained by this API + * from the first successful registration onward. */ struct SystemEventSubscription { /** `event.type` is the event type to subscribe to; set by the caller before @@ -189,6 +197,8 @@ struct SystemEventSubscription { /** * Register a poll subscription for events of @a sub->type. * @warning Does not work in ISR context. + * @warning On its very first call for a given @a sub, @a sub must have been zero-initialized - + * see SystemEventSubscription's @warning. * @warning If @a sub was just passed to system_event_unsubscribe() (e.g. reusing a node for a * new registration) and that call hasn't returned yet on another task, this call blocks * (briefly - not for the full duration of anyone's timeout) until it does, before registering - @@ -224,6 +234,7 @@ error_t system_event_unsubscribe(struct SystemEventSubscription* sub); * system_event_get_data()/system_event_get_timestamp() afterward - intermediate events are * silently overwritten, never delivered. Use system_event_callback_add() instead if every * individual event matters. + * @warning Cannot be called concurrently from different tasks. Each tasks must have its own subscription. * @param[in,out] sub subscription to wait on, as passed to system_event_subscribe() * @param[in] timeout max ticks to wait * @retval ERROR_NONE an event arrived From ca7cb9fd0213f9dc925e72a90d6597176b464d7d Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 19:03:43 +0200 Subject: [PATCH 08/31] Updated apps --- Tactility/Include/Tactility/Bundle.h | 37 +- Tactility/Include/Tactility/Tactility.h | 1 - Tactility/Include/Tactility/app/App.h | 116 --- Tactility/Include/Tactility/app/AppContext.h | 41 -- Tactility/Include/Tactility/app/AppManifest.h | 104 --- Tactility/Include/Tactility/app/AppPaths.h | 47 -- .../Include/Tactility/app/AppRegistration.h | 26 - Tactility/Include/Tactility/app/ElfApp.h | 36 - .../Tactility/app/alertdialog/AlertDialog.h | 48 +- .../Include/Tactility/app/btmanage/BtManage.h | 4 +- .../app/fileselection/FileSelection.h | 29 +- .../Tactility/app/imageviewer/ImageViewer.h | 9 +- .../Tactility/app/inputdialog/InputDialog.h | 25 +- Tactility/Include/Tactility/app/notes/Notes.h | 5 +- .../app/selectiondialog/SelectionDialog.h | 28 +- .../app/touchcalibration/TouchCalibration.h | 9 +- .../Tactility/app/wifimanage/WifiManage.h | 11 +- Tactility/Include/Tactility/lvgl/Statusbar.h | 4 +- Tactility/Include/Tactility/lvgl/Toolbar.h | 5 - .../Include/Tactility/service/loader/Loader.h | 101 --- Tactility/Private/Tactility/app/AppInstance.h | 98 --- .../Tactility/app/AppManifestParsing.h | 14 - .../app/AppManifestParsingInternal.h | 23 - .../Private/Tactility/app/btmanage/Bindings.h | 8 +- .../Tactility/app/btmanage/BtManagePrivate.h | 60 +- .../Private/Tactility/app/btmanage/View.h | 10 +- .../Tactility/app/chat/ChatAppPrivate.h | 33 +- .../Private/Tactility/app/chat/ChatView.h | 11 +- .../Tactility/app/development/Development.h | 11 - Tactility/Private/Tactility/app/files/View.h | 15 +- .../app/fileselection/FileSelectionPrivate.h | 4 - .../Tactility/app/fileselection/View.h | 9 +- .../Tactility/app/i2cscanner/I2cScanner.h | 4 +- .../Private/Tactility/app/launcher/Launcher.h | 4 +- .../app/localesettings/LocaleSettings.h | 10 +- Tactility/Private/Tactility/app/setup/Setup.h | 4 +- .../app/timedatesettings/TimeDateSettings.h | 4 +- .../Private/Tactility/app/timezone/TimeZone.h | 17 +- .../Tactility/app/wificonnect/Bindings.h | 14 - .../Private/Tactility/app/wificonnect/State.h | 24 - .../Private/Tactility/app/wificonnect/View.h | 45 -- .../Tactility/app/wificonnect/WifiConnect.h | 46 +- .../Private/Tactility/app/wifimanage/View.h | 8 +- .../app/wifimanage/WifiManagePrivate.h | 36 +- .../Tactility/service/gui/GuiService.h | 107 --- Tactility/Source/Bundle.cpp | 118 ++-- Tactility/Source/Paths.cpp | 7 +- Tactility/Source/Tactility.cpp | 302 ++++---- Tactility/Source/app/App.cpp | 49 -- Tactility/Source/app/AppInstall.cpp | 206 ------ Tactility/Source/app/AppInstance.cpp | 55 -- Tactility/Source/app/AppManifestParsing.cpp | 97 --- Tactility/Source/app/AppManifestParsingV1.cpp | 77 -- Tactility/Source/app/AppManifestParsingV2.cpp | 77 -- Tactility/Source/app/AppPaths.cpp | 44 -- Tactility/Source/app/AppRegistration.cpp | 63 -- Tactility/Source/app/ElfApp.cpp | 235 ------ Tactility/Source/app/addgps/AddGps.cpp | 306 ++++---- .../Source/app/alertdialog/AlertDialog.cpp | 228 +++--- .../Source/app/appdetails/AppDetails.cpp | 228 +++--- Tactility/Source/app/apphub/AppHubApp.cpp | 288 ++++---- .../app/apphubdetails/AppHubDetailsApp.cpp | 434 +++++++----- Tactility/Source/app/applist/AppList.cpp | 133 ++-- .../Source/app/appsettings/AppSettings.cpp | 148 ++-- .../Source/app/apwebserver/ApWebServer.cpp | 250 ++++--- .../app/audiosettings/AudioSettings.cpp | 367 +++++----- Tactility/Source/app/boot/Boot.cpp | 450 ++++++------ Tactility/Source/app/btmanage/BtManage.cpp | 257 ++++--- Tactility/Source/app/btmanage/View.cpp | 46 +- .../app/btpeersettings/BtPeerSettings.cpp | 388 +++++----- Tactility/Source/app/chat/ChatApp.cpp | 173 +++-- Tactility/Source/app/chat/ChatView.cpp | 29 +- .../app/crashdiagnostics/CrashDiagnostics.cpp | 267 ++++--- .../Source/app/development/Development.cpp | 293 ++++---- Tactility/Source/app/files/FilesApp.cpp | 84 ++- Tactility/Source/app/files/View.cpp | 95 ++- .../app/fileselection/FileSelection.cpp | 142 ++-- Tactility/Source/app/fileselection/View.cpp | 15 +- .../Source/app/gpssettings/GpsSettings.cpp | 491 +++++++------ .../app/grovesettings/GroveSettings.cpp | 154 ++-- .../Source/app/i2cscanner/I2cScanner.cpp | 508 ++++++------- .../Source/app/imageviewer/ImageViewer.cpp | 176 +++-- .../Source/app/inputdialog/InputDialog.cpp | 212 +++--- .../app/kerneldisplay/KernelDisplay.cpp | 494 +++++++------ .../Source/app/keyboard/KeyboardSettings.cpp | 321 +++++---- Tactility/Source/app/launcher/Launcher.cpp | 378 +++++----- .../app/localesettings/LocaleSettings.cpp | 221 +++--- Tactility/Source/app/notes/Notes.cpp | 410 ++++++----- Tactility/Source/app/power/Power.cpp | 391 +++++----- Tactility/Source/app/poweroff/PowerOff.cpp | 233 +++--- .../Source/app/screenshot/Screenshot.cpp | 262 +++---- .../app/selectiondialog/SelectionDialog.cpp | 217 +++--- Tactility/Source/app/settings/Settings.cpp | 121 +++- Tactility/Source/app/setup/Setup.cpp | 333 +++++---- .../Source/app/systeminfo/SystemInfo.cpp | 345 +++++---- .../app/timedatesettings/TimeDateSettings.cpp | 301 ++++---- Tactility/Source/app/timezone/TimeZone.cpp | 401 ++++++----- .../app/touchcalibration/TouchCalibration.cpp | 416 ++++++----- .../app/trackball/TrackballSettings.cpp | 429 ++++++----- .../Source/app/usbsettings/UsbSettings.cpp | 141 ++-- .../webserversettings/WebServerSettings.cpp | 667 ++++++++++-------- .../app/wifiapsettings/WifiApSettings.cpp | 408 ++++++----- Tactility/Source/app/wificonnect/State.cpp | 37 - Tactility/Source/app/wificonnect/View.cpp | 219 ------ .../Source/app/wificonnect/WifiConnect.cpp | 377 ++++++++-- Tactility/Source/app/wifimanage/View.cpp | 28 +- .../Source/app/wifimanage/WifiManage.cpp | 170 +++-- Tactility/Source/file/PropertiesFile.cpp | 68 +- Tactility/Source/lvgl/Toolbar.cpp | 9 - Tactility/Source/network/Http.cpp | 3 - .../development/DevelopmentService.cpp | 37 +- Tactility/Source/service/gui/GuiService.cpp | 375 ---------- Tactility/Source/service/loader/Loader.cpp | 328 --------- .../service/screenshot/ScreenshotTask.cpp | 21 +- .../service/webserver/WebServerService.cpp | 63 +- 115 files changed, 8146 insertions(+), 8875 deletions(-) delete mode 100644 Tactility/Include/Tactility/app/App.h delete mode 100644 Tactility/Include/Tactility/app/AppContext.h delete mode 100644 Tactility/Include/Tactility/app/AppManifest.h delete mode 100644 Tactility/Include/Tactility/app/AppPaths.h delete mode 100644 Tactility/Include/Tactility/app/AppRegistration.h delete mode 100644 Tactility/Include/Tactility/app/ElfApp.h delete mode 100644 Tactility/Include/Tactility/service/loader/Loader.h delete mode 100644 Tactility/Private/Tactility/app/AppInstance.h delete mode 100644 Tactility/Private/Tactility/app/AppManifestParsing.h delete mode 100644 Tactility/Private/Tactility/app/AppManifestParsingInternal.h delete mode 100644 Tactility/Private/Tactility/app/development/Development.h delete mode 100644 Tactility/Private/Tactility/app/wificonnect/Bindings.h delete mode 100644 Tactility/Private/Tactility/app/wificonnect/State.h delete mode 100644 Tactility/Private/Tactility/app/wificonnect/View.h delete mode 100644 Tactility/Private/Tactility/service/gui/GuiService.h delete mode 100644 Tactility/Source/app/App.cpp delete mode 100644 Tactility/Source/app/AppInstall.cpp delete mode 100644 Tactility/Source/app/AppInstance.cpp delete mode 100644 Tactility/Source/app/AppManifestParsing.cpp delete mode 100644 Tactility/Source/app/AppManifestParsingV1.cpp delete mode 100644 Tactility/Source/app/AppManifestParsingV2.cpp delete mode 100644 Tactility/Source/app/AppPaths.cpp delete mode 100644 Tactility/Source/app/AppRegistration.cpp delete mode 100644 Tactility/Source/app/ElfApp.cpp delete mode 100644 Tactility/Source/app/wificonnect/State.cpp delete mode 100644 Tactility/Source/app/wificonnect/View.cpp delete mode 100644 Tactility/Source/service/gui/GuiService.cpp delete mode 100644 Tactility/Source/service/loader/Loader.cpp diff --git a/Tactility/Include/Tactility/Bundle.h b/Tactility/Include/Tactility/Bundle.h index 030e65ae9..e935fc3fd 100644 --- a/Tactility/Include/Tactility/Bundle.h +++ b/Tactility/Include/Tactility/Bundle.h @@ -6,43 +6,30 @@ #include #include -#include namespace tt { /** * A dictionary that maps keys (strings) onto several atomary types. + * Thin C++ wrapper around TactilityKernel's C Bundle (tactility/bundle.h). */ class Bundle final { - typedef uint32_t Hash; - - enum class Type { - Bool, - Int32, - Int64, - String, - }; - - typedef struct { - Type type; - union { - bool value_bool; - int32_t value_int32; - int64_t value_int64; - }; - std::string value_string; - } Value; - - std::unordered_map entries; + // Actually a TactilityKernel ::Bundle* (tactility/bundle.h), cast in Bundle.cpp - kept as + // void* here rather than a forward-declared `struct Bundle*` so this header doesn't put a + // second, unqualified `Bundle` name in scope: any TU with `using namespace tt;` in effect + // (e.g. tests) would then find both `::Bundle` and `tt::Bundle` for a bare `Bundle` lookup + // and fail with "reference to 'Bundle' is ambiguous". + void* handle; public: - Bundle() = default; + Bundle(); + + Bundle(const Bundle& bundle); + Bundle& operator=(const Bundle& bundle); - Bundle(const Bundle& bundle) { - this->entries = bundle.entries; - } + ~Bundle(); bool getBool(const std::string& key) const; int32_t getInt32(const std::string& key) const; diff --git a/Tactility/Include/Tactility/Tactility.h b/Tactility/Include/Tactility/Tactility.h index 5f3b2d9b6..1796c7b1c 100644 --- a/Tactility/Include/Tactility/Tactility.h +++ b/Tactility/Include/Tactility/Tactility.h @@ -3,7 +3,6 @@ #include #include #include -#include #include diff --git a/Tactility/Include/Tactility/app/App.h b/Tactility/Include/Tactility/app/App.h deleted file mode 100644 index e60bd5919..000000000 --- a/Tactility/Include/Tactility/app/App.h +++ /dev/null @@ -1,116 +0,0 @@ -#pragma once - -#include "Tactility/app/AppContext.h" - -#include -#include - -#include - -// Forward declarations -typedef struct _lv_obj_t lv_obj_t; - -namespace tt::app { - -// Forward declarations -class AppContext; -enum class Result; - -typedef unsigned int LaunchId; - -class App { - - Mutex mutex; - - struct ResultHolder { - Result result; - std::unique_ptr resultData; - - explicit ResultHolder(Result result) : result(result), resultData(nullptr) {} - - ResultHolder(Result result, std::unique_ptr resultData) : - result(result), - resultData(std::move(resultData)) {} - }; - - std::unique_ptr resultHolder; - -public: - - App() = default; - virtual ~App() = default; - - virtual void onCreate(AppContext& appContext) {} - virtual void onDestroy(AppContext& appContext) {} - virtual void onShow(AppContext& appContext, lv_obj_t* parent) {} - virtual void onHide(AppContext& appContext) {} - /** resultData could be null */ - virtual void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr resultData) {} - - Mutex& getMutex() { return mutex; } - - bool hasResult() const { return resultHolder != nullptr; } - - void setResult(Result result, std::unique_ptr resultData = nullptr) { - auto lock = getMutex().asScopedLock(); - lock.lock(); - resultHolder = std::make_unique(result, std::move(resultData)); - } - - /** - * Used by system to extract the result data when this application is finished. - * Note that this removes the data from the class! - */ - bool moveResult(Result& outResult, std::unique_ptr& outBundle) { - auto lock = getMutex().asScopedLock(); - lock.lock(); - - if (resultHolder == nullptr) { - return false; - } - - outResult = resultHolder->result; - outBundle = std::move(resultHolder->resultData); - resultHolder = nullptr; - return true; - } -}; - -template -std::shared_ptr create() { return std::shared_ptr(new T); } - -/** - * @brief Start an app - * @param[in] id application name or id - * @param[in] parameters optional parameters to pass onto the application. can be nullptr. - */ -LaunchId start(const std::string& id, std::shared_ptr parameters = nullptr); - -/** @brief Stop the currently showing app. Show the previous app if any app was still running. */ -void stop(); - -/** @brief Stop a specific app and any apps it might have launched on the stack. - * @param[in] id the app id - */ -void stop(const std::string& id); - -/** @brief Stop all app instances that match with this identifier and also stop the apps they started. - * @warning onResult() will only be called for the resulting app that gets shown (if any) - * @param[in] id the id of the app to stop - */ -void stopAll(const std::string& id); - -/** @return true if the app is running somewhere in the app stack (doesn't have to be the top-most app) */ -bool isRunning(const std::string& id); - -/** @return the currently running app context (it is only ever null before the splash screen is shown) */ -std::shared_ptr getCurrentAppContext(); - -/** @return the currently running app (it is only ever null before the splash screen is shown) */ -std::shared_ptr getCurrentApp(); - -bool install(const std::string& path); - -bool uninstall(const std::string& appId); - -} diff --git a/Tactility/Include/Tactility/app/AppContext.h b/Tactility/Include/Tactility/app/AppContext.h deleted file mode 100644 index d425c744f..000000000 --- a/Tactility/Include/Tactility/app/AppContext.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include -#include - -namespace tt::app { - -// Forward declarations -class App; -class AppPaths; -struct AppManifest; -enum class Result; - -typedef union { - struct { - bool hideStatusbar : 1; - }; - unsigned char flags; -} Flags; - -/** - * The public representation of an application instance. - * @warning Do not store references or pointers to these! You can retrieve them via the service registry. - */ -class AppContext { - -protected: - - virtual ~AppContext() = default; - -public: - - virtual const AppManifest& getManifest() const = 0; - virtual std::shared_ptr getParameters() const = 0; - virtual std::unique_ptr getPaths() const = 0; - - virtual std::shared_ptr getApp() const = 0; -}; - - -} diff --git a/Tactility/Include/Tactility/app/AppManifest.h b/Tactility/Include/Tactility/app/AppManifest.h deleted file mode 100644 index 0b363b7fb..000000000 --- a/Tactility/Include/Tactility/app/AppManifest.h +++ /dev/null @@ -1,104 +0,0 @@ -#pragma once - -#include - -#include - -namespace tt::app { - -class App; -class AppContext; - -/** Application types */ -enum class Category { - /** Standard apps, provided by the system. */ - System, - /** The apps that are launched/shown by the Settings app. The Settings app itself is of type AppTypeSystem. */ - Settings, - /** User-provided apps. */ - User -}; - -/** Result status code for application result callback. */ -enum class Result { - Ok = 0U, - Cancelled = 1U, - Error = 2U -}; - -class Location { - - std::string path; - Location() = default; - explicit Location(const std::string& path) : path(path) {} - -public: - - static Location internal() { return {}; } - - static Location external(const std::string& path) { - return Location(path); - } - - /** Internal apps are all apps that are part of the firmware release. */ - bool isInternal() const { return path.empty(); } - - /** - * External apps are all apps that are not part of the firmware release. - * e.g. an application on the sd card or one that is installed in /data - */ - bool isExternal() const { return !path.empty(); } - const std::string& getPath() const { return path; } -}; - -typedef std::shared_ptr(*CreateApp)(); - -struct AppManifest { - - struct Flags { - constexpr static uint32_t None = 0; - /** Don't show the statusbar */ - constexpr static uint32_t HideStatusBar = 1 << 0; - /** Hint to other systems to not show this app (e.g. in launcher or settings) */ - constexpr static uint32_t Hidden = 1 << 1; - }; - - /** The SDK version that was used to compile this app. (e.g. "0.6.0") */ - std::string targetSdk = {}; - - /** Comma-separated list of platforms, e.g. "esp32,esp32s3" */ - std::string targetPlatforms = {}; - - /** The identifier by which the app is launched by the system and other apps. */ - std::string appId = {}; - - /** The user-readable name of the app. Used in UI. */ - std::string appName = {}; - - /** Optional icon. */ - std::string appIcon = {}; - - /** The version as it is displayed to the user (e.g. "1.2.0") */ - std::string appVersionName = {}; - - /** The technical version (must be incremented with new releases of the app */ - uint64_t appVersionCode = 0; - - /** App category helps with listing apps in Launcher, app list or settings apps. */ - Category appCategory = Category::User; - - /** Where the app is located */ - Location appLocation = Location::internal(); - - /** Controls various settings */ - uint16_t appFlags = Flags::None; - - /** Create the instance of the app */ - CreateApp createApp = nullptr; -}; - -struct { - bool operator()(const std::shared_ptr& left, const std::shared_ptr& right) const { return left->appName < right->appName; } -} SortAppManifestByName; - -} // namespace diff --git a/Tactility/Include/Tactility/app/AppPaths.h b/Tactility/Include/Tactility/app/AppPaths.h deleted file mode 100644 index 901500887..000000000 --- a/Tactility/Include/Tactility/app/AppPaths.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include -#include - -namespace tt::app { - -// Forward declarations -class AppManifest; - -class AppPaths { - - const AppManifest& manifest; - -public: - - explicit AppPaths(const AppManifest& manifest) : manifest(manifest) {} - - /** - * The user data directory is intended to survive OS upgrades. - * The path will not end with a "/". - */ - std::string getUserDataPath() const; - - /** - * The user data directory is intended to survive OS upgrades. - * Configuration data should be stored here. - * @param[in] childPath the path without a "/" prefix - */ - std::string getUserDataPath(const std::string& childPath) const; - - /** - * You should not store configuration data here. - * The path will not end with a "/". - * This is mainly used for core apps (system/boot/settings type). - */ - std::string getAssetsPath() const; - - /** - * You should not store configuration data here. - * This is mainly used for core apps (system/boot/settings type). - * @param[in] childPath the path without a "/" prefix - */ - std::string getAssetsPath(const std::string& childPath) const; -}; - -} \ No newline at end of file diff --git a/Tactility/Include/Tactility/app/AppRegistration.h b/Tactility/Include/Tactility/app/AppRegistration.h deleted file mode 100644 index dc6595d63..000000000 --- a/Tactility/Include/Tactility/app/AppRegistration.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "App.h" -#include -#include - -namespace tt::app { - -struct AppManifest; - -/** Register an application with its manifest */ -void addAppManifest(const AppManifest& manifest); - -/** Remove an app from the registry */ -bool removeAppManifest(const std::string& id); - -/** Find an application manifest by its id - * @param[in] id the manifest id - * @return the application manifest if it was found - */ -std::shared_ptr findAppManifestById(const std::string& id); - -/** @return a list of all registered apps. This includes user and system apps. */ -std::vector> getAppManifests(); - -} // namespace diff --git a/Tactility/Include/Tactility/app/ElfApp.h b/Tactility/Include/Tactility/app/ElfApp.h deleted file mode 100644 index 6a3d9f35c..000000000 --- a/Tactility/Include/Tactility/app/ElfApp.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include "AppManifest.h" - -#ifdef ESP_PLATFORM - -namespace tt::app { - -typedef void* (*CreateData)(); -typedef void (*DestroyData)(void* data); -/** data is nullable */ -typedef void (*OnCreate)(void* appContext, void* data); -/** data is nullable */ -typedef void (*OnDestroy)(void* appContext, void* data); -/** data is nullable */ -typedef void (*OnShow)(void* appContext, void* data, lv_obj_t* parent); -/** data is nullable */ -typedef void (*OnHide)(void* appContext, void* data); -/** data is nullable, resultData is nullable. */ -typedef void (*OnResult)(void* appContext, void* data, LaunchId launchId, Result result, Bundle* resultData); - -/** All fields are nullable */ -void setElfAppParameters( - CreateData createData, - DestroyData destroyData, - OnCreate onCreate, - OnDestroy onDestroy, - OnShow onShow, - OnHide onHide, - OnResult onResult -); - -std::shared_ptr createElfApp(const std::shared_ptr& manifest); - -} -#endif // ESP_PLATFORM diff --git a/Tactility/Include/Tactility/app/alertdialog/AlertDialog.h b/Tactility/Include/Tactility/app/alertdialog/AlertDialog.h index baac6d43b..aa1a41db5 100644 --- a/Tactility/Include/Tactility/app/alertdialog/AlertDialog.h +++ b/Tactility/Include/Tactility/app/alertdialog/AlertDialog.h @@ -1,49 +1,27 @@ #pragma once -#include - +#include #include #include -#include /** - * Start the app by its ID and provide: - * - a title - * - a text - * - 0, 1 or more buttons + * Show a dialog with a title, a message and 0, 1 or more buttons. */ namespace tt::app::alertdialog { /** - * Show a dialog with the provided title, message and 0, 1 or more buttons. - * @param[in] title the title to show in the toolbar - * @param[in] message the message to display - * @param[in] buttonLabels the buttons to show - * @return the launch id - */ -LaunchId start(const std::string& title, const std::string& message, const std::vector& buttonLabels); -/** - * Show a dialog with the provided title, message and 0, 1 or more buttons. - * @param[in] title the title to show in the toolbar - * @param[in] message the message to display - * @param[in] buttonLabels the buttons to show - * @return the launch id - */ -LaunchId start(const std::string& title, const std::string& message, const std::vector& buttonLabels); - -/** - * Show a dialog with the provided title, message and an OK button - * @param[in] title the title to show in the toolbar - * @param[in] message the message to display - * @return the launch id + * Show a dialog with the provided title, message and buttons, as a modal child of + * @a callerAppInstanceId (a new-model app - see app/manager.h). The caller receives the + * result as an APP_EVENT_RESULT in its own event loop: result is the pressed button's index + * (>= 0), or a value not matching any button (currently always 1) if the dialog was dismissed + * without a button press. No result_bundle. The caller is responsible for calling + * app_manager_stop() on the returned instance id once it has handled the result. + * @return the new dialog's app instance id */ -LaunchId start(const std::string& title, const std::string& message); +uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::vector& buttonLabels); -/** - * Get the index of the button that the user selected. - * - * @return a value greater than 0 when a selection was done, or -1 when the app was closed clicking one of the selection buttons. - */ -int32_t getResultIndex(const Bundle& bundle); +/** @copydoc start(uint32_t, const std::string&, const std::string&, const std::vector&) + * Shows a single "OK" button. */ +uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message); } diff --git a/Tactility/Include/Tactility/app/btmanage/BtManage.h b/Tactility/Include/Tactility/app/btmanage/BtManage.h index 9ce9928f6..cb0d0e859 100644 --- a/Tactility/Include/Tactility/app/btmanage/BtManage.h +++ b/Tactility/Include/Tactility/app/btmanage/BtManage.h @@ -1,9 +1,9 @@ #pragma once -#include +#include namespace tt::app::btmanage { -LaunchId start(); +uint32_t start(); } // namespace tt::app::btmanage diff --git a/Tactility/Include/Tactility/app/fileselection/FileSelection.h b/Tactility/Include/Tactility/app/fileselection/FileSelection.h index e80ae1e3a..af9af170d 100644 --- a/Tactility/Include/Tactility/app/fileselection/FileSelection.h +++ b/Tactility/Include/Tactility/app/fileselection/FileSelection.h @@ -1,28 +1,29 @@ #pragma once -#include -#include - +#include #include namespace tt::app::fileselection { /** - * Show a file selection dialog that allows the user to select an existing file. - * This app returns the absolute file path as a result. + * Show a file selection dialog that allows the user to select an existing file, as a modal + * child of @a callerAppInstanceId (see app_manager_start_for_result()). Result (0 = Ok, + * 1 = Cancelled) is delivered back via APP_EVENT_RESULT once this app's thread exits - call + * getLastPath() right after receiving it, on result == 0. The caller must call + * app_manager_stop() on the returned instance id once that event arrives, to fully reap this + * instance. + * @return the new app instance id */ -LaunchId startForExistingFile(); +uint32_t startForExistingFile(uint32_t callerAppInstanceId); /** - * Show a file selection dialog that allows the user to select a new or existing file. - * This app returns the absolute file path as a result. + * Same as startForExistingFile(), but also allows picking a path that doesn't exist yet (for + * "save as"-style flows). */ -LaunchId startForExistingOrNewFile(); +uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId); -/** - * @param bundle the result bundle of an app - * @return the path from the bundle, or empty string if none is present - */ -std::string getResultPath(const Bundle& bundle); +/** @return the path picked by the last FileSelection dialog that closed with result == Ok. Only + * one dialog is expected to be open at a time. */ +std::string getLastPath(); } // namespace diff --git a/Tactility/Include/Tactility/app/imageviewer/ImageViewer.h b/Tactility/Include/Tactility/app/imageviewer/ImageViewer.h index 23486cf1e..5049bbc19 100644 --- a/Tactility/Include/Tactility/app/imageviewer/ImageViewer.h +++ b/Tactility/Include/Tactility/app/imageviewer/ImageViewer.h @@ -1,9 +1,14 @@ #pragma once -#include +#include namespace tt::app::imageviewer { -LaunchId start(const std::string& file); +/** + * Show a full-screen viewer for a single image file. Fire-and-forget: doesn't report any result + * back to the caller. + * @param file the path to the image file to display + */ +void start(const std::string& file); } \ No newline at end of file diff --git a/Tactility/Include/Tactility/app/inputdialog/InputDialog.h b/Tactility/Include/Tactility/app/inputdialog/InputDialog.h index 70624b911..28fc089a4 100644 --- a/Tactility/Include/Tactility/app/inputdialog/InputDialog.h +++ b/Tactility/Include/Tactility/app/inputdialog/InputDialog.h @@ -1,21 +1,28 @@ #pragma once -#include -#include - +#include #include /** - * Start the app by its ID and provide: - * - a title - * - a text + * Show a dialog with a title, a message and a text field. */ namespace tt::app::inputdialog { -LaunchId start(const std::string& title, const std::string& message, const std::string& prefilled = ""); +/** + * Show a dialog with the provided title, message and prefilled text, as a modal child of + * @a callerAppInstanceId (a new-model app - see app/manager.h). The caller receives the result + * as an APP_EVENT_RESULT in its own event loop: 0 = OK (call getLastText() for the entered + * text), 1 = Cancelled or dismissed without a press. The caller is responsible for calling + * app_manager_stop() on the returned instance id once it has handled the result. + * @return the new dialog's app instance id + */ +uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::string& prefilled = ""); /** - * @return the text that was in the field when OK was pressed, or otherwise empty string + * @return the text entered the last time any InputDialog instance was closed with OK. Only one + * dialog is expected to be open at a time - call this right after receiving its + * APP_EVENT_RESULT with result == 0. */ -std::string getResult(const Bundle& bundle); +std::string getLastText(); + } diff --git a/Tactility/Include/Tactility/app/notes/Notes.h b/Tactility/Include/Tactility/app/notes/Notes.h index 5bf3096c3..cf85fdf0b 100644 --- a/Tactility/Include/Tactility/app/notes/Notes.h +++ b/Tactility/Include/Tactility/app/notes/Notes.h @@ -1,14 +1,13 @@ #pragma once -#include +#include namespace tt::app::notes { /** * Start the notes app with the specified text file. * @param[in] filePath the path to the text file to open - * @return the launch id */ -LaunchId start(const std::string& filePath); +void start(const std::string& filePath); } diff --git a/Tactility/Include/Tactility/app/selectiondialog/SelectionDialog.h b/Tactility/Include/Tactility/app/selectiondialog/SelectionDialog.h index 8fabf842b..095220b0c 100644 --- a/Tactility/Include/Tactility/app/selectiondialog/SelectionDialog.h +++ b/Tactility/Include/Tactility/app/selectiondialog/SelectionDialog.h @@ -1,28 +1,28 @@ #pragma once -#include -#include +#include "app/instance.h" + +#include #include #include /** - * Start the app by its ID and provide: - * - an optional title - * - 2 or more items - * - * If you provide 0 items, the app will auto-close. - * If you provide 1 item, the app will auto-close with result index 0 + * Show a dialog with a title and a list of selectable items. */ namespace tt::app::selectiondialog { -LaunchId start(const std::string& title, const std::vector& items); - /** - * Get the index of the item that the user selected. - * - * @return a value greater than 0 when a selection was done, or -1 when the app was closed without selecting an item. + * Show a selection dialog with the provided title and items, as a modal child of + * @a callerAppInstanceId (a new-model app - see app/manager.h). The caller receives the + * result as an APP_EVENT_RESULT in its own event loop: result is the selected item's index + * (>= 0), -1 if 0 items were provided (an error - the dialog auto-closes without showing + * anything), or a value not matching any item (currently always 1) if the dialog was + * dismissed without a selection. No result_bundle. If exactly 1 item is provided, the dialog + * auto-closes with result index 0 without showing anything. The caller is responsible for + * calling app_manager_stop() on the returned instance id once it has handled the result. + * @return the new dialog's app instance id */ -int32_t getResultIndex(const Bundle& bundle); +AppInstanceId start(AppInstanceId callerAppInstanceId, const std::string& title, const std::vector& items); } diff --git a/Tactility/Include/Tactility/app/touchcalibration/TouchCalibration.h b/Tactility/Include/Tactility/app/touchcalibration/TouchCalibration.h index 5c8dce48f..54d6b6ab5 100644 --- a/Tactility/Include/Tactility/app/touchcalibration/TouchCalibration.h +++ b/Tactility/Include/Tactility/app/touchcalibration/TouchCalibration.h @@ -6,11 +6,16 @@ #if defined(CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED) -#include +#include namespace tt::app::touchcalibration { -LaunchId start(); +/** + * Starts calibration as a modal child of @a callerAppInstanceId. Result (Ok=0/Error=2, no + * bundle) is delivered as APP_EVENT_RESULT once the user dismisses the outcome screen. + * @return the new app instance id + */ +uint32_t start(uint32_t callerAppInstanceId); } // namespace tt::app::touchcalibration diff --git a/Tactility/Include/Tactility/app/wifimanage/WifiManage.h b/Tactility/Include/Tactility/app/wifimanage/WifiManage.h index 8d165ffb6..2c187bb1d 100644 --- a/Tactility/Include/Tactility/app/wifimanage/WifiManage.h +++ b/Tactility/Include/Tactility/app/wifimanage/WifiManage.h @@ -1,9 +1,16 @@ #pragma once -#include +#include namespace tt::app::wifimanage { -LaunchId start(); +/** + * Starts as a modal child of @a callerAppInstanceId (see app_manager_start_for_result()) - an + * APP_EVENT_RESULT is delivered back once the user closes this screen (default Cancelled/no + * bundle if never explicitly set - callers that just want a "the wifi step is done" signal, like + * Setup, can ignore the actual result value). + * @return the new app instance id + */ +uint32_t start(uint32_t callerAppInstanceId); } // namespace diff --git a/Tactility/Include/Tactility/lvgl/Statusbar.h b/Tactility/Include/Tactility/lvgl/Statusbar.h index 3a056a9bf..302f28646 100644 --- a/Tactility/Include/Tactility/lvgl/Statusbar.h +++ b/Tactility/Include/Tactility/lvgl/Statusbar.h @@ -1,9 +1,9 @@ #pragma once -#include - #include +#include + namespace tt::lvgl { constexpr auto STATUSBAR_ICON_LIMIT = 8; diff --git a/Tactility/Include/Tactility/lvgl/Toolbar.h b/Tactility/Include/Tactility/lvgl/Toolbar.h index 486cfe78f..cef66a444 100644 --- a/Tactility/Include/Tactility/lvgl/Toolbar.h +++ b/Tactility/Include/Tactility/lvgl/Toolbar.h @@ -1,12 +1,7 @@ #pragma once -#include "../app/AppContext.h" - #include namespace tt::lvgl { -/** Create a toolbar widget that shows the app name as title */ -lv_obj_t* toolbar_create(lv_obj_t* parent, const app::AppContext& app); - } // namespace diff --git a/Tactility/Include/Tactility/service/loader/Loader.h b/Tactility/Include/Tactility/service/loader/Loader.h deleted file mode 100644 index de78ed41f..000000000 --- a/Tactility/Include/Tactility/service/loader/Loader.h +++ /dev/null @@ -1,101 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace tt::service::loader { - - -class LoaderService final : public Service { - -public: - - enum class Event { - ApplicationStarted, - ApplicationShowing, - ApplicationHiding, - ApplicationStopped - }; - -private: - - std::shared_ptr> pubsubExternal = std::make_shared>(); - RecursiveMutex mutex; - std::vector> appStack; - app::LaunchId nextLaunchId = 0; - - /** The dispatcher thread needs a callstack large enough to accommodate all the dispatched methods. - * This includes full LVGL redraw via Gui::redraw() - */ - std::unique_ptr dispatcherThread = std::make_unique("loader_dispatcher", 6144); // Files app requires ~5k - - void onStartAppMessage(const std::string& id, app::LaunchId launchId, std::shared_ptr parameters); - - void onStopTopAppMessage(const std::string& id); - - void onStopAllAppMessage(const std::string& id); - - void transitionAppToState(const std::shared_ptr& app, app::State state); - - int findAppInStack(const std::string& id) const; - - bool onStart(ServiceContext& service) override { - dispatcherThread->start(); - return true; - } - - void onStop(ServiceContext& service) override { - // Send stop signal to thread and wait for thread to finish - mutex.withLock([this] { - dispatcherThread->stop(); - }); - } - -public: - /** - * @brief Start an app given an app id and an optional bundle with parameters - * @param id the app identifier - * @param parameters optional parameter bundle (nullable) - * @return the launch id - */ - app::LaunchId start(const std::string& id, std::shared_ptr parameters); - - /** - * @brief Stops the top-most app (the one that is currently active shown to the user - * @warning Avoid calling this directly and use stopTop(id) instead - */ - void stopTop(); - - /** - * @brief Stops the top-most app if the id is still matching by the time the stop event arrives. - * @param id the id of the app to stop - */ - void stopTop(const std::string& id); - - /** - * @brief Stops all apps with the provided id and any apps that were pushed on top of the stack after the original app was started. - * @param id the id of the app to stop - */ - void stopAll(const std::string& id); - - /** @return the AppContext of the top-most application, or nullptr if no app is running. */ - std::shared_ptr getCurrentAppContext(); - - /** @return true if the app is running anywhere in the app stack (the app does not have to be the top-most one for this to return true) */ - bool isRunning(const std::string& id) const; - - /** @return the PubSub object that is responsible for event publishing */ - std::shared_ptr> getPubsub() const { return pubsubExternal; } -}; - -/** return the service or nullptr if it's not running */ -std::shared_ptr findLoaderService(); - -} // namespace diff --git a/Tactility/Private/Tactility/app/AppInstance.h b/Tactility/Private/Tactility/app/AppInstance.h deleted file mode 100644 index 14474f950..000000000 --- a/Tactility/Private/Tactility/app/AppInstance.h +++ /dev/null @@ -1,98 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include -#include - -#include -#include - -namespace tt::app { - -enum class State { - Initial, // AppInstance was created, but the state hasn't advanced yet - Created, // App was placed into memory - Showing, // App view was created - Hiding, // App view was destroyed - Destroyed // App was removed from memory -}; - -/** - * Thread-safe app instance. - */ -class AppInstance : public AppContext { - - Mutex mutex; - const std::shared_ptr manifest; - State state = State::Initial; - LaunchId launchId; - Flags flags = { .hideStatusbar = true }; - /** @brief Optional parameters to start the app with - * When these are stored in the app struct, the struct takes ownership. - * Do not mutate after app creation. - */ - std::shared_ptr parameters; - - std::shared_ptr app; - - static std::shared_ptr createApp( - const std::shared_ptr& manifest - ) { - if (manifest->appLocation.isInternal()) { - assert(manifest->createApp != nullptr); - return manifest->createApp(); - } else if (manifest->appLocation.isExternal()) { - if (manifest->createApp != nullptr) { - LOG_W("AppInstance", "Manifest specifies createApp, but this is not used with external apps"); - } -#ifdef ESP_PLATFORM - return createElfApp(manifest); -#else - check(false, "not supported"); -#endif - } else { - check(false, "not implemented"); - } - } - -public: - - explicit AppInstance(const std::shared_ptr& manifest, LaunchId launchId) : - manifest(manifest), - launchId(launchId), - app(createApp(manifest)) - {} - - AppInstance(const std::shared_ptr& manifest, LaunchId launchId, std::shared_ptr parameters) : - manifest(manifest), - launchId(launchId), - parameters(std::move(parameters)), - app(createApp(manifest)) - {} - - ~AppInstance() override = default; - - LaunchId getLaunchId() const { return launchId; } - - void setState(State state); - State getState() const; - - const AppManifest& getManifest() const override; - - Flags getFlags() const; - void setFlags(Flags flags); - Flags& mutableFlags() { return flags; } // TODO: locking mechanism - - std::shared_ptr getParameters() const override; - - std::unique_ptr getPaths() const override; - - std::shared_ptr getApp() const override { return app; } -}; - -} // namespace diff --git a/Tactility/Private/Tactility/app/AppManifestParsing.h b/Tactility/Private/Tactility/app/AppManifestParsing.h deleted file mode 100644 index 3be62387c..000000000 --- a/Tactility/Private/Tactility/app/AppManifestParsing.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include - -#include - -namespace tt::app { - -bool isValidId(const std::string& id); - -/** Parses a manifest.properties file, auto-detecting the V1 (sectioned) or V2 (flat) format from its first line. */ -bool parseManifest(const std::string& filePath, AppManifest& manifest); - -} diff --git a/Tactility/Private/Tactility/app/AppManifestParsingInternal.h b/Tactility/Private/Tactility/app/AppManifestParsingInternal.h deleted file mode 100644 index 3083766fc..000000000 --- a/Tactility/Private/Tactility/app/AppManifestParsingInternal.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include - -#include -#include - -namespace tt::app { - -bool getValueFromManifest(const std::map& map, const std::string& key, std::string& output); - -bool isValidManifestVersion(const std::string& version); -bool isValidAppVersionName(const std::string& version); -bool isValidAppVersionCode(const std::string& version); -bool isValidName(const std::string& name); - -/** Parses a V1 (sectioned INI, e.g. "[app]versionName=...") manifest map. */ -bool parseManifestV1(const std::map& map, AppManifest& manifest); - -/** Parses a V2 (flat dot-notation, e.g. "app.version.name=...") manifest map. */ -bool parseManifestV2(const std::map& map, AppManifest& manifest); - -} diff --git a/Tactility/Private/Tactility/app/btmanage/Bindings.h b/Tactility/Private/Tactility/app/btmanage/Bindings.h index 4ca3668c9..7136e3010 100644 --- a/Tactility/Private/Tactility/app/btmanage/Bindings.h +++ b/Tactility/Private/Tactility/app/btmanage/Bindings.h @@ -5,11 +5,13 @@ namespace tt::app::btmanage { -typedef void (*OnBtToggled)(bool enable); -typedef void (*OnScanToggled)(bool enable); +// `context` is this app instance's Context* (see BtManagePrivate.h) - the new app-module has no +// global "current app" accessor, so callbacks need it threaded through explicitly. +typedef void (*OnBtToggled)(void* context, bool enable); +typedef void (*OnScanToggled)(void* context, bool enable); typedef void (*OnConnectPeer)(const std::array& addr, int profileId); typedef void (*OnDisconnectPeer)(const std::array& addr, int profileId); -typedef void (*OnPairPeer)(const std::array& addr); +typedef void (*OnPairPeer)(void* context, const std::array& addr); typedef void (*OnForgetPeer)(const std::array& addr); struct Bindings { diff --git a/Tactility/Private/Tactility/app/btmanage/BtManagePrivate.h b/Tactility/Private/Tactility/app/btmanage/BtManagePrivate.h index 6b9d70a92..8bf01be2f 100644 --- a/Tactility/Private/Tactility/app/btmanage/BtManagePrivate.h +++ b/Tactility/Private/Tactility/app/btmanage/BtManagePrivate.h @@ -3,7 +3,6 @@ #include "./View.h" #include "./State.h" -#include #include #include #include @@ -13,54 +12,37 @@ namespace tt::app::btmanage { -class BtManage final : public App { - +struct Context { + uint32_t appInstanceId; Mutex mutex; - Bindings bindings = { }; + Bindings bindings {}; State state; View view = View(&bindings, &state); - bool isViewEnabled = false; Device* btDevice = nullptr; bool callbackRegistered = false; - // Bumped by onHide() to invalidate any BT event already dispatched to the main - // task for this show/hide session (BtManage is reused across hide/show cycles - - // e.g. launching BtPeerSettings pushes it on top and hides this instance without - // destroying it). Kept in its own heap allocation, independent of BtManage's - // lifetime, so a dispatched callback can check it without touching a possibly - // already-destroyed `this`. + // Bumped right before the BT event callback is unregistered at the end of appMain(), to + // invalidate any BT event already dispatched to the main task for this instance. Kept in + // its own heap allocation, independent of Context's (stack-local) lifetime, so a dispatched + // callback can check it without touching a possibly already-destroyed Context. std::shared_ptr> generation = std::make_shared>(0); -public: - - void onBtEvent(const struct BtEvent& event); - - BtManage(); - - void lock(); - void unlock(); - - void onShow(AppContext& app, lv_obj_t* parent) override; - void onHide(AppContext& app) override; - - Bindings& getBindings() { return bindings; } - State& getState() { return state; } - - void requestViewUpdate(); + void lock() { mutex.lock(); } + void unlock() { mutex.unlock(); } +}; - std::shared_ptr> getGeneration() const { return generation; } +void onBtEvent(Context* ctx, const struct BtEvent& event); +void requestViewUpdate(Context* ctx); - // Re-attempts registering the device event callback. Needed because the BLE driver - // only allocates its callback list while the device is started/on: a registration - // attempted while the radio is off silently no-ops, so this must be called again - // right after a successful bluetooth::start(). Idempotent: no-ops if already - // registered for this device, so it's safe to call from both onShow() and here. - void registerDeviceCallback(Device* dev); +// Re-attempts registering the device event callback. Needed because the BLE driver only +// allocates its callback list while the device is started/on: a registration attempted while +// the radio is off silently no-ops, so this must be called again right after a successful +// bluetooth::start(). Idempotent: no-ops if already registered for this device. +void registerDeviceCallback(Context* ctx, Device* dev); - // Call after bluetooth::stop(): the driver frees its callback list on stop, so the - // registration state must be cleared here too, without touching the (now-dangling) - // driver-side list. - void forgetCallbackRegistration(); -}; +// Call after bluetooth::stop(): the driver frees its callback list on stop, so the +// registration state must be cleared here too, without touching the (now-dangling) driver-side +// list. +void forgetCallbackRegistration(Context* ctx); } // namespace tt::app::btmanage diff --git a/Tactility/Private/Tactility/app/btmanage/View.h b/Tactility/Private/Tactility/app/btmanage/View.h index 7e2f9e56f..799704b01 100644 --- a/Tactility/Private/Tactility/app/btmanage/View.h +++ b/Tactility/Private/Tactility/app/btmanage/View.h @@ -3,9 +3,7 @@ #include "./Bindings.h" #include "./State.h" -#include -#include - +#include #include namespace tt::app::btmanage { @@ -14,7 +12,9 @@ class View final { Bindings* bindings; State* state; - std::unique_ptr paths; + // Passed through to onBtToggled/onScanToggled/onPairPeer via lv_obj user_data - see + // Bindings.h. Set in init(), before any callback can fire. + void* context = nullptr; lv_obj_t* root = nullptr; lv_obj_t* enable_switch = nullptr; lv_obj_t* enable_on_boot_switch = nullptr; @@ -34,7 +34,7 @@ class View final { View(Bindings* bindings, State* state) : bindings(bindings), state(state) {} - void init(const AppContext& app, lv_obj_t* parent); + void init(void* context, lv_obj_t* parent); void update(); }; diff --git a/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h b/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h index ec98fc331..7abd67cc3 100644 --- a/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h +++ b/Tactility/Private/Tactility/app/chat/ChatAppPrivate.h @@ -10,36 +10,31 @@ #include "ChatView.h" #include "ChatSettings.h" -#include #include -namespace tt::app::chat { +#include +#include -class ChatApp final : public App { +namespace tt::app::chat { +// Replaces the old ChatApp (tt::app::App subclass) under the thread-per-app model. Declared +// here (rather than local to ChatApp.cpp's anonymous namespace, as most converted apps do) +// because ChatView - a separate translation unit - also needs to reference it. +struct Context { + uint32_t appInstanceId; ChatState state; ChatView view = ChatView(this, &state); service::espnow::ReceiverSubscription receiveSubscription = -1; ChatSettingsData settings; bool isFirstLaunch = false; +}; - void onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length); - void enableEspNow(); - void disableEspNow(); - -public: - void onCreate(AppContext& appContext) override; - void onDestroy(AppContext& appContext) override; - void onShow(AppContext& context, lv_obj_t* parent) override; - - void sendMessage(const std::string& text); - void applySettings(const std::string& nickname, const std::string& keyHex); - void switchChannel(const std::string& chatChannel); - - const ChatSettingsData& getSettings() const { return settings; } +void enableEspNow(Context* ctx); +void disableEspNow(Context* ctx); - ~ChatApp() override = default; -}; +void sendMessage(Context* ctx, const std::string& text); +void applySettings(Context* ctx, const std::string& nickname, const std::string& keyHex); +void switchChannel(Context* ctx, const std::string& chatChannel); } // namespace tt::app::chat diff --git a/Tactility/Private/Tactility/app/chat/ChatView.h b/Tactility/Private/Tactility/app/chat/ChatView.h index 794235a85..b53b3b98f 100644 --- a/Tactility/Private/Tactility/app/chat/ChatView.h +++ b/Tactility/Private/Tactility/app/chat/ChatView.h @@ -9,18 +9,16 @@ #include "ChatState.h" #include "ChatSettings.h" -#include - #include #include namespace tt::app::chat { -class ChatApp; +struct Context; class ChatView { - ChatApp* app; + Context* app; ChatState* state; lv_obj_t* toolbar = nullptr; @@ -45,6 +43,7 @@ class ChatView { static void addMessageToList(lv_obj_t* msgList, const StoredMessage& msg); + static void onBackPressed(lv_event_t* e); static void onSendClicked(lv_event_t* e); static void onSettingsClicked(lv_event_t* e); static void onSettingsSave(lv_event_t* e); @@ -54,7 +53,7 @@ class ChatView { static void onChannelCancel(lv_event_t* e); public: - ChatView(ChatApp* app, ChatState* state) : app(app), state(state) {} + ChatView(Context* app, ChatState* state) : app(app), state(state) {} ~ChatView() = default; ChatView(const ChatView&) = delete; @@ -62,7 +61,7 @@ class ChatView { ChatView(ChatView&&) = delete; ChatView& operator=(ChatView&&) = delete; - void init(AppContext& appContext, lv_obj_t* parent); + void init(lv_obj_t* parent); void displayMessage(const StoredMessage& msg); void refreshMessageList(); diff --git a/Tactility/Private/Tactility/app/development/Development.h b/Tactility/Private/Tactility/app/development/Development.h deleted file mode 100644 index ca93c52d9..000000000 --- a/Tactility/Private/Tactility/app/development/Development.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once - -#ifdef ESP_PLATFORM - -namespace tt::app::development { - -void start(); - -} - -#endif // ESP_PLATFORM \ No newline at end of file diff --git a/Tactility/Private/Tactility/app/files/View.h b/Tactility/Private/Tactility/app/files/View.h index c29b9fba2..ab3fa7f81 100644 --- a/Tactility/Private/Tactility/app/files/View.h +++ b/Tactility/Private/Tactility/app/files/View.h @@ -2,8 +2,7 @@ #include "./State.h" -#include - +#include #include #include @@ -11,7 +10,8 @@ namespace tt::app::files { class View final { std::shared_ptr state; - + uint32_t appInstanceId = 0; + size_t current_start_index = 0; size_t last_loaded_index = 0; const size_t MAX_BATCH = 50; @@ -24,7 +24,7 @@ class View final { lv_obj_t* paste_button = nullptr; std::string installAppPath = { 0 }; - LaunchId installAppLaunchId = 0; + uint32_t installDialogId = 0; void showActions(); void showActionsForDirectory(); @@ -39,9 +39,10 @@ class View final { explicit View(const std::shared_ptr& state) : state(state) {} - void init(const AppContext& appContext, lv_obj_t* parent); + void init(uint32_t appInstanceId, lv_obj_t* parent); void update(size_t start_index = 0); + void onBackPressed(); void onNavigateUpPressed(); void onDirEntryPressed(uint32_t index); void onDirEntryLongPressed(int32_t index); @@ -54,8 +55,8 @@ class View final { void onPastePressed(); void onEjectPressed(); void onDirEntryListScrollBegin(); - void onResult(LaunchId launchId, Result result, std::unique_ptr bundle); - void deinit(const AppContext& appContext); + void onResult(uint32_t launchId, int32_t result); + void deinit(); private: diff --git a/Tactility/Private/Tactility/app/fileselection/FileSelectionPrivate.h b/Tactility/Private/Tactility/app/fileselection/FileSelectionPrivate.h index 479edd125..dbe4514a7 100644 --- a/Tactility/Private/Tactility/app/fileselection/FileSelectionPrivate.h +++ b/Tactility/Private/Tactility/app/fileselection/FileSelectionPrivate.h @@ -1,7 +1,5 @@ #pragma once -#include - namespace tt::app::fileselection { enum class Mode { @@ -9,6 +7,4 @@ enum class Mode { ExistingOrNew = 1 }; -Mode getMode(const Bundle& bundle); - } diff --git a/Tactility/Private/Tactility/app/fileselection/View.h b/Tactility/Private/Tactility/app/fileselection/View.h index eb8f2c856..510d18999 100644 --- a/Tactility/Private/Tactility/app/fileselection/View.h +++ b/Tactility/Private/Tactility/app/fileselection/View.h @@ -3,14 +3,13 @@ #include "./State.h" #include "./FileSelectionPrivate.h" -#include - #include #include namespace tt::app::fileselection { class View final { + uint32_t appInstanceId; std::shared_ptr state; lv_obj_t* dir_entry_list = nullptr; @@ -22,11 +21,15 @@ class View final { void onTapFile(const std::string&path, const std::string&filename); static void onSelectButtonPressed(lv_event_t* event); static void onPathTextChanged(lv_event_t* event); + /** Emits an async APP_EVENT_CLOSE for appInstanceId - see FileSelection.cpp's appMain() for + * why this indirection (rather than calling app_manager_stop() here) is required. */ + static void onBackPressedCallback(lv_event_t* event); void createDirEntryWidget(lv_obj_t* parent, dirent& dir_entry); public: - explicit View(const std::shared_ptr& state, std::function onFileSelected) : + explicit View(uint32_t appInstanceId, const std::shared_ptr& state, std::function onFileSelected) : + appInstanceId(appInstanceId), state(state), on_file_selected(std::move(onFileSelected)) {} diff --git a/Tactility/Private/Tactility/app/i2cscanner/I2cScanner.h b/Tactility/Private/Tactility/app/i2cscanner/I2cScanner.h index 66e998e2a..36e0d05f3 100644 --- a/Tactility/Private/Tactility/app/i2cscanner/I2cScanner.h +++ b/Tactility/Private/Tactility/app/i2cscanner/I2cScanner.h @@ -1,9 +1,9 @@ #pragma once -#include +#include namespace tt::app::i2cscanner { -LaunchId start(); +uint32_t start(); } diff --git a/Tactility/Private/Tactility/app/launcher/Launcher.h b/Tactility/Private/Tactility/app/launcher/Launcher.h index b5d15a246..f5e13716a 100644 --- a/Tactility/Private/Tactility/app/launcher/Launcher.h +++ b/Tactility/Private/Tactility/app/launcher/Launcher.h @@ -1,9 +1,7 @@ #pragma once -#include - namespace tt::app::launcher { -LaunchId start(); +uint32_t start(); } diff --git a/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h b/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h index 3de41fd91..68f26d0e4 100644 --- a/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h +++ b/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h @@ -1,9 +1,5 @@ #pragma once -#include - -namespace tt::app::localesettings { - -LaunchId start(); - -} \ No newline at end of file +// Intentionally empty: LocaleSettings.cpp has no external callers (verified via repo-wide +// grep during its thread-per-app conversion), so it no longer exposes a start() wrapper. This +// header is kept as a placeholder in case that changes; nothing currently includes it. \ No newline at end of file diff --git a/Tactility/Private/Tactility/app/setup/Setup.h b/Tactility/Private/Tactility/app/setup/Setup.h index 847518608..f8074812b 100644 --- a/Tactility/Private/Tactility/app/setup/Setup.h +++ b/Tactility/Private/Tactility/app/setup/Setup.h @@ -1,10 +1,8 @@ #pragma once -#include - namespace tt::app::setup { -LaunchId start(); +void start(); /** @return true if the setup wizard has already run to completion */ bool isCompleted(); diff --git a/Tactility/Private/Tactility/app/timedatesettings/TimeDateSettings.h b/Tactility/Private/Tactility/app/timedatesettings/TimeDateSettings.h index be6febdf2..22dc4f6c9 100644 --- a/Tactility/Private/Tactility/app/timedatesettings/TimeDateSettings.h +++ b/Tactility/Private/Tactility/app/timedatesettings/TimeDateSettings.h @@ -1,9 +1,9 @@ #pragma once -#include +#include namespace tt::app::timedatesettings { -LaunchId start(); +uint32_t start(); } \ No newline at end of file diff --git a/Tactility/Private/Tactility/app/timezone/TimeZone.h b/Tactility/Private/Tactility/app/timezone/TimeZone.h index 9e090e8cb..a1bc4bd83 100644 --- a/Tactility/Private/Tactility/app/timezone/TimeZone.h +++ b/Tactility/Private/Tactility/app/timezone/TimeZone.h @@ -1,13 +1,20 @@ #pragma once -#include -#include +#include +#include namespace tt::app::timezone { -LaunchId start(bool saveTimeZone = false); +/** + * @return the started dialog's instance id; result (0 = Ok, 1 = Cancelled) is delivered to + * @a callerAppInstanceId as APP_EVENT_RESULT once the user picks a time zone - call + * getLastName()/getLastCode() right after receiving it, on result == 0. + */ +uint32_t start(uint32_t callerAppInstanceId, bool saveTimeZone = false); -std::string getResultName(const Bundle& bundle); -std::string getResultCode(const Bundle& bundle); +/** @return the name/code from the last time zone picked by any TimeZone dialog instance. Only + * one dialog is expected to be open at a time. */ +std::string getLastName(); +std::string getLastCode(); } diff --git a/Tactility/Private/Tactility/app/wificonnect/Bindings.h b/Tactility/Private/Tactility/app/wificonnect/Bindings.h deleted file mode 100644 index 43e2cbeab..000000000 --- a/Tactility/Private/Tactility/app/wificonnect/Bindings.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include - -namespace tt::app::wificonnect { - -typedef void (*OnConnectSsid)(const service::wifi::settings::WifiApSettings& settings, bool store, void* context); - -typedef struct { - OnConnectSsid onConnectSsid; - void* onConnectSsidContext; -} Bindings; - -} // namespace diff --git a/Tactility/Private/Tactility/app/wificonnect/State.h b/Tactility/Private/Tactility/app/wificonnect/State.h deleted file mode 100644 index 0a591f6a5..000000000 --- a/Tactility/Private/Tactility/app/wificonnect/State.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include - -namespace tt::app::wificonnect { - -class State final { - Mutex lock; - service::wifi::settings::WifiApSettings apSettings; - bool connectionError = false; - bool connecting = false; -public: - - void setConnectionError(bool error); - bool hasConnectionError() const; - - void setApSettings(const service::wifi::settings::WifiApSettings& newSettings); - - void setConnecting(bool isConnecting); - bool isConnecting() const; -}; - -} // namespace diff --git a/Tactility/Private/Tactility/app/wificonnect/View.h b/Tactility/Private/Tactility/app/wificonnect/View.h deleted file mode 100644 index 6f0007504..000000000 --- a/Tactility/Private/Tactility/app/wificonnect/View.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -#include "./Bindings.h" -#include "./State.h" - -#include - -#include - -namespace tt::app::wificonnect { - -class WifiConnect; - -class View final { - - Bindings* bindings; - State* state; - -public: - - lv_obj_t* ssid_textarea = nullptr; - lv_obj_t* ssid_error = nullptr; - lv_obj_t* password_textarea = nullptr; - lv_obj_t* password_error = nullptr; - lv_obj_t* connect_button = nullptr; - lv_obj_t* remember_switch = nullptr; - lv_obj_t* connecting_spinner = nullptr; - lv_obj_t* connection_error = nullptr; - lv_group_t* group = nullptr; - - View(Bindings* bindings, State* state) : - bindings(bindings), - state(state) - {} - - void init(AppContext& app, lv_obj_t* parent); - void update(); - - void createBottomButtons(lv_obj_t* parent); - void setLoading(bool loading); - void resetErrors(); -}; - - -} // namespace diff --git a/Tactility/Private/Tactility/app/wificonnect/WifiConnect.h b/Tactility/Private/Tactility/app/wificonnect/WifiConnect.h index 674650d33..9891b4bfa 100644 --- a/Tactility/Private/Tactility/app/wificonnect/WifiConnect.h +++ b/Tactility/Private/Tactility/app/wificonnect/WifiConnect.h @@ -1,54 +1,12 @@ #pragma once -#include -#include -#include -#include - -#include -#include +#include namespace tt::app::wificonnect { -class WifiConnect final : public App { - - Mutex mutex; - State state; - Bindings bindings = { - .onConnectSsid = nullptr, - .onConnectSsidContext = nullptr - }; - View view = View(&bindings, &state); - PubSub::SubscriptionHandle wifiSubscription; - bool viewEnabled = false; - - void onWifiEvent(service::wifi::WifiEvent event); - -public: - - WifiConnect(); - ~WifiConnect() override; - - void lock(); - void unlock(); - - void onShow(AppContext& app, lv_obj_t* parent) override; - void onHide(AppContext& app) override; - - State& getState() { return state; } - Bindings& getBindings() { return bindings; } - View& getView() { return view; } - - void requestViewUpdate(); -}; - /** * Start the app with optional pre-filled fields. */ -LaunchId start(const std::string& ssid = "", const std::string& password = ""); - -bool optSsidParameter(const std::shared_ptr& bundle, std::string& ssid); - -bool optPasswordParameter(const std::shared_ptr& bundle, std::string& password); +void start(const std::string& ssid = "", const std::string& password = ""); } // namespace diff --git a/Tactility/Private/Tactility/app/wifimanage/View.h b/Tactility/Private/Tactility/app/wifimanage/View.h index 00a0a5055..fdf989547 100644 --- a/Tactility/Private/Tactility/app/wifimanage/View.h +++ b/Tactility/Private/Tactility/app/wifimanage/View.h @@ -3,9 +3,7 @@ #include "./Bindings.h" #include "./State.h" -#include -#include - +#include #include namespace tt::app::wifimanage { @@ -14,7 +12,7 @@ class View final { Bindings* bindings; State* state; - std::unique_ptr paths; + uint32_t appInstanceId = 0; lv_obj_t* root = nullptr; lv_obj_t* enable_switch = nullptr; lv_obj_t* enable_on_boot_switch = nullptr; @@ -36,7 +34,7 @@ class View final { View(Bindings* bindings, State* state) : bindings(bindings), state(state) {} - void init(const AppContext& app, lv_obj_t* parent); + void init(uint32_t appInstanceId, lv_obj_t* parent); void update(); }; diff --git a/Tactility/Private/Tactility/app/wifimanage/WifiManagePrivate.h b/Tactility/Private/Tactility/app/wifimanage/WifiManagePrivate.h index 3c42b1d36..ce495e107 100644 --- a/Tactility/Private/Tactility/app/wifimanage/WifiManagePrivate.h +++ b/Tactility/Private/Tactility/app/wifimanage/WifiManagePrivate.h @@ -3,39 +3,11 @@ #include "./View.h" #include "./State.h" -#include - #include #include #include -namespace tt::app::wifimanage { - -class WifiManage final : public App { - - PubSub::SubscriptionHandle wifiSubscription = nullptr; - Mutex mutex; - Bindings bindings = { }; - State state; - View view = View(&bindings, &state); - bool isViewEnabled = false; - - void onWifiEvent(service::wifi::WifiEvent event); - -public: - - WifiManage(); - - void lock(); - void unlock(); - - void onShow(AppContext& app, lv_obj_t* parent) override; - void onHide(AppContext& app) override; - - Bindings& getBindings() { return bindings; } - State& getState() { return state; } - - void requestViewUpdate(); -}; - -} // namespace +// Context (the app's actual runtime state) is defined inside WifiManage.cpp's own anonymous +// namespace - View.cpp doesn't need it (Bindings*/State* pointers and a raw appInstanceId are +// threaded through explicitly instead, see View.h/View.cpp). This header now only bundles +// View.h/State.h for convenience, same as before. diff --git a/Tactility/Private/Tactility/service/gui/GuiService.h b/Tactility/Private/Tactility/service/gui/GuiService.h deleted file mode 100644 index f00b947ca..000000000 --- a/Tactility/Private/Tactility/service/gui/GuiService.h +++ /dev/null @@ -1,107 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include - -#include - -#include - -namespace tt::service::gui { - -/** - * Output a log warning if the current task is the GUI task. - * This is meant for code that should either create their own task or use a different task to execute on. - * @param[in] context a descriptive name or label that refers to the caller of this function - */ -void warnIfRunningOnGuiTask(const char* context); - -class GuiService final : public Service { - - // Thread and lock - Thread* thread = nullptr; - DispatcherHandle_t dispatcher = nullptr; - bool exitRequested = false; - RecursiveMutex mutex; - PubSub::SubscriptionHandle loader_pubsub_subscription = nullptr; - - // Signaled by hideApp() once App::onHide() has actually finished running on the GUI - // task. onLoaderEvent() blocks on this (still on the Loader thread, inside the - // synchronous pubsub publish() call) before returning from the ApplicationHiding - // branch, so LoaderService::transitionAppToState(Hiding) can't return - and therefore - // the immediately-following Destroyed transition (which unloads an ELF app's code via - // esp_elf_deinit) can't run - until onHide() has fully completed. Without this, the - // ELF's code/data can be unmapped while onHide() (and anything it spawned, like a - // camera capture task) is still executing it. - Semaphore hideDoneSem { 1, 0 }; - - // Layers and Canvas - lv_obj_t* appRootWidget = nullptr; - lv_obj_t* statusbarWidget = nullptr; - - // App-specific - std::shared_ptr appToRender = nullptr; - - LvglSoftwareKeyboard software_keyboard = {}; - - bool isStarted = false; - - static int32_t guiMain(); - - static void onGuiDispatch(void* context); - - void onLoaderEvent(loader::LoaderService::Event event); - - lv_obj_t* createAppViews(lv_obj_t* parent); - - void redraw(); - - void lock() const { - check(mutex.lock(pdMS_TO_TICKS(1000))); - } - - void unlock() const { - mutex.unlock(); - } - - void showApp(std::shared_ptr app); - - void hideApp(); - -public: - - bool onStart(ServiceContext& service) override; - - void onStop(ServiceContext& service) override; - - /** - * Show the on-screen keyboard. - * @param[in] textarea the textarea to focus the input for - */ - void softwareKeyboardShow(lv_obj_t* textarea); - - /** - * Hide the on-screen keyboard. - * Has no effect when the keyboard is not visible. - */ - void softwareKeyboardHide(); - - void keyboardAddTextArea(lv_obj_t* textarea); - - /** - * The on-screen keyboard is only shown when both of these conditions are true: - * - there is no hardware keyboard - * - TT_CONFIG_FORCE_ONSCREEN_KEYBOARD is set to true in tactility_config.h - * @return if we should show a on-screen keyboard for text input inside our apps - */ - bool softwareKeyboardIsEnabled(); -}; - -std::shared_ptr findService(); - -} // namespace diff --git a/Tactility/Source/Bundle.cpp b/Tactility/Source/Bundle.cpp index 0295c76a8..cd8c8c08a 100644 --- a/Tactility/Source/Bundle.cpp +++ b/Tactility/Source/Bundle.cpp @@ -1,113 +1,113 @@ #include "Tactility/Bundle.h" +#include + +#include + namespace tt { +namespace { +::Bundle* as_kernel(void* handle) { return static_cast<::Bundle*>(handle); } +} // namespace + +Bundle::Bundle() : handle(bundle_alloc()) {} + +Bundle::Bundle(const Bundle& bundle) : handle(bundle_clone(as_kernel(bundle.handle))) {} + +Bundle& Bundle::operator=(const Bundle& bundle) { + if (this != &bundle) { + ::Bundle* cloned = bundle_clone(as_kernel(bundle.handle)); + bundle_free(as_kernel(handle)); + handle = cloned; + } + return *this; +} + +Bundle::~Bundle() { + bundle_free(as_kernel(handle)); +} + bool Bundle::getBool(const std::string& key) const { - return this->entries.find(key)->second.value_bool; + return bundle_get_bool(as_kernel(handle), key.c_str()); } int32_t Bundle::getInt32(const std::string& key) const { - return this->entries.find(key)->second.value_int32; + return bundle_get_int32(as_kernel(handle), key.c_str()); } int64_t Bundle::getInt64(const std::string& key) const { - return this->entries.find(key)->second.value_int64; + return bundle_get_int64(as_kernel(handle), key.c_str()); } std::string Bundle::getString(const std::string& key) const { - return this->entries.find(key)->second.value_string; + // bundle_get_string() needs a bounded buffer; grow and retry until it fits. + std::vector buffer(64); + while (true) { + error_t error = bundle_get_string(as_kernel(handle), key.c_str(), buffer.data(), buffer.size()); + if (error == ERROR_NONE) { + return std::string(buffer.data()); + } + buffer.resize(buffer.size() * 2); + } } bool Bundle::hasBool(const std::string& key) const { - auto entry = this->entries.find(key); - return entry != std::end(this->entries) && entry->second.type == Type::Bool; + return bundle_has_bool(as_kernel(handle), key.c_str()); } bool Bundle::hasInt32(const std::string& key) const { - auto entry = this->entries.find(key); - return entry != std::end(this->entries) && entry->second.type == Type::Int32; + return bundle_has_int32(as_kernel(handle), key.c_str()); } bool Bundle::hasInt64(const std::string& key) const { - auto entry = this->entries.find(key); - return entry != std::end(this->entries) && entry->second.type == Type::Int64; + return bundle_has_int64(as_kernel(handle), key.c_str()); } bool Bundle::hasString(const std::string& key) const { - auto entry = this->entries.find(key); - return entry != std::end(this->entries) && entry->second.type == Type::String; + return bundle_has_string(as_kernel(handle), key.c_str()); } bool Bundle::optBool(const std::string& key, bool& out) const { - auto entry = this->entries.find(key); - if (entry != std::end(this->entries) && entry->second.type == Type::Bool) { - out = entry->second.value_bool; - return true; - } else { - return false; - } + return bundle_opt_bool(as_kernel(handle), key.c_str(), &out); } bool Bundle::optInt32(const std::string& key, int32_t& out) const { - auto entry = this->entries.find(key); - if (entry != std::end(this->entries) && entry->second.type == Type::Int32) { - out = entry->second.value_int32; - return true; - } else { - return false; - } + return bundle_opt_int32(as_kernel(handle), key.c_str(), &out); } bool Bundle::optInt64(const std::string& key, int64_t& out) const { - auto entry = this->entries.find(key); - if (entry != std::end(this->entries) && entry->second.type == Type::Int64) { - out = entry->second.value_int64; - return true; - } else { - return false; - } + return bundle_opt_int64(as_kernel(handle), key.c_str(), &out); } bool Bundle::optString(const std::string& key, std::string& out) const { - auto entry = this->entries.find(key); - if (entry != std::end(this->entries) && entry->second.type == Type::String) { - out = entry->second.value_string; - return true; - } else { - return false; + std::vector buffer(64); + while (true) { + error_t error = bundle_opt_string(as_kernel(handle), key.c_str(), buffer.data(), buffer.size()); + if (error == ERROR_NONE) { + out = buffer.data(); + return true; + } + if (error == ERROR_NOT_FOUND) { + return false; + } + buffer.resize(buffer.size() * 2); } } void Bundle::putBool(const std::string& key, bool value) { - this->entries[key] = { - .type = Type::Bool, - .value_bool = value, - .value_string = "" - }; + bundle_put_bool(as_kernel(handle), key.c_str(), value); } void Bundle::putInt32(const std::string& key, int32_t value) { - this->entries[key] = { - .type = Type::Int32, - .value_int32 = value, - .value_string = "" - }; + bundle_put_int32(as_kernel(handle), key.c_str(), value); } void Bundle::putInt64(const std::string& key, int64_t value) { - this->entries[key] = { - .type = Type::Int64, - .value_int64 = value, - .value_string = "" - }; + bundle_put_int64(as_kernel(handle), key.c_str(), value); } void Bundle::putString(const std::string& key, const std::string& value) { - this->entries[key] = { - .type = Type::String, - .value_bool = false, - .value_string = value - }; + bundle_put_string(as_kernel(handle), key.c_str(), value.c_str()); } } // namespace diff --git a/Tactility/Source/Paths.cpp b/Tactility/Source/Paths.cpp index 6857f706e..1d9d06635 100644 --- a/Tactility/Source/Paths.cpp +++ b/Tactility/Source/Paths.cpp @@ -1,6 +1,7 @@ #include -#include +#include "../../Modules/app-module/private/app/private/app_metadata_parsing_internal.h" + #include #include @@ -71,12 +72,12 @@ std::string getUserHomePath() { } std::string getAppInstallPath(const std::string& appId) { - assert(app::isValidId(appId)); + assert(app_metadata_is_valid_id(appId.c_str())); return std::format("{}/{}", getAppInstallPath(), appId); } std::string getAppUserPath(const std::string& appId) { - assert(app::isValidId(appId)); + assert(app_metadata_is_valid_id(appId.c_str())); return std::format("{}/app/{}", getUserHomePath(), appId); } diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 805269e52..cf683f9e2 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -1,36 +1,51 @@ #ifdef ESP_PLATFORM #include #include +#include #endif #include +#include +#include +#include + +#include +#include +#include +#include +#include #include -#include -#include + #include +#include #include -#include -#include +#include +#include +#include #include -#include -#include #include +#include +#include +#include #include -#include #include #include #include #include +#include + #include #include #include -#include #include #include +#include +#include + #include #include #include @@ -85,8 +100,6 @@ namespace service { namespace espnow { extern const ServiceManifest manifest; } #endif // Secondary (UI) - namespace gui { extern const ServiceManifest manifest; } - namespace loader { extern const ServiceManifest manifest; } namespace memorychecker { extern const ServiceManifest manifest; } namespace statusbar { extern const ServiceManifest manifest; } #ifdef ESP_PLATFORM @@ -107,64 +120,66 @@ namespace service { // region Default apps +// All apps below are converted to the new app-module + window-manager model, so their manifest +// is the new, global ::AppManifest, not this namespace's old tt::app::AppManifest. namespace app { - namespace addgps { extern const AppManifest manifest; } - namespace alertdialog { extern const AppManifest manifest; } - namespace apphub { extern const AppManifest manifest; } - namespace apphubdetails { extern const AppManifest manifest; } - namespace appdetails { extern const AppManifest manifest; } - namespace applist { extern const AppManifest manifest; } - namespace appsettings { extern const AppManifest manifest; } - namespace audiosettings { extern const AppManifest manifest; } - namespace boot { extern const AppManifest manifest; } - namespace development { extern const AppManifest manifest; } - namespace display { extern const AppManifest manifest; } - namespace kerneldisplay { extern const AppManifest manifest; } - namespace files { extern const AppManifest manifest; } - namespace fileselection { extern const AppManifest manifest; } - namespace gpssettings { extern const AppManifest manifest; } - namespace grovesettings { extern const AppManifest manifest; } - namespace i2cscanner { extern const AppManifest manifest; } - namespace imageviewer { extern const AppManifest manifest; } - namespace inputdialog { extern const AppManifest manifest; } - namespace launcher { extern const AppManifest manifest; } - namespace localesettings { extern const AppManifest manifest; } - namespace notes { extern const AppManifest manifest; } - namespace power { extern const AppManifest manifest; } - namespace poweroff { extern const AppManifest manifest; } - namespace selectiondialog { extern const AppManifest manifest; } - namespace settings { extern const AppManifest manifest; } - namespace setup { extern const AppManifest manifest; } - namespace systeminfo { extern const AppManifest manifest; } - namespace timedatesettings { extern const AppManifest manifest; } + namespace addgps { extern const ::AppManifest manifest; } + namespace alertdialog { extern const ::AppManifest manifest; } + namespace apphub { extern const ::AppManifest manifest; } + namespace apphubdetails { extern const ::AppManifest manifest; } + namespace appdetails { extern const ::AppManifest manifest; } + namespace applist { extern const ::AppManifest manifest; } + namespace appsettings { extern const ::AppManifest manifest; } + namespace audiosettings { extern const ::AppManifest manifest; } + namespace boot { extern const ::AppManifest manifest; } + namespace development { extern const ::AppManifest manifest; } + namespace display { extern const ::AppManifest manifest; } + namespace kerneldisplay { extern const ::AppManifest manifest; } + namespace files { extern const ::AppManifest manifest; } + namespace fileselection { extern const ::AppManifest manifest; } + namespace gpssettings { extern const ::AppManifest manifest; } + namespace grovesettings { extern const ::AppManifest manifest; } + namespace i2cscanner { extern const ::AppManifest manifest; } + namespace imageviewer { extern const ::AppManifest manifest; } + namespace inputdialog { extern const ::AppManifest manifest; } + namespace launcher { extern const ::AppManifest manifest; } + namespace localesettings { extern const ::AppManifest manifest; } + namespace notes { extern const ::AppManifest manifest; } + namespace power { extern const ::AppManifest manifest; } + namespace poweroff { extern const ::AppManifest manifest; } + namespace selectiondialog { extern const ::AppManifest manifest; } + namespace settings { extern const ::AppManifest manifest; } + namespace setup { extern const ::AppManifest manifest; } + namespace systeminfo { extern const ::AppManifest manifest; } + namespace timedatesettings { extern const ::AppManifest manifest; } #ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED - namespace touchcalibration { extern const AppManifest manifest; } + namespace touchcalibration { extern const ::AppManifest manifest; } #endif - namespace timezone { extern const AppManifest manifest; } - namespace usbsettings { extern const AppManifest manifest; } - namespace btmanage { extern const AppManifest manifest; } - namespace btpeersettings { extern const AppManifest manifest; } - namespace wifiapsettings { extern const AppManifest manifest; } - namespace wificonnect { extern const AppManifest manifest; } - namespace wifimanage { extern const AppManifest manifest; } + namespace timezone { extern const ::AppManifest manifest; } + namespace usbsettings { extern const ::AppManifest manifest; } + namespace btmanage { extern const ::AppManifest manifest; } + namespace btpeersettings { extern const ::AppManifest manifest; } + namespace wifiapsettings { extern const ::AppManifest manifest; } + namespace wificonnect { extern const ::AppManifest manifest; } + namespace wifimanage { extern const ::AppManifest manifest; } #ifdef ESP_PLATFORM - namespace apwebserver { extern const AppManifest manifest; } - namespace crashdiagnostics { extern const AppManifest manifest; } - namespace webserversettings { extern const AppManifest manifest; } + namespace apwebserver { extern const ::AppManifest manifest; } + namespace crashdiagnostics { extern const ::AppManifest manifest; } + namespace webserversettings { extern const ::AppManifest manifest; } #if CONFIG_TT_TDECK_WORKAROUND == 1 - namespace keyboardsettings { extern const AppManifest manifest; } // T-Deck only for now + namespace keyboardsettings { extern const ::AppManifest manifest; } // T-Deck only for now #endif #endif - namespace trackballsettings { extern const AppManifest manifest; } // T-Deck only for now + namespace trackballsettings { extern const ::AppManifest manifest; } // T-Deck only for now #if TT_FEATURE_SCREENSHOT_ENABLED - namespace screenshot { extern const AppManifest manifest; } + namespace screenshot { extern const ::AppManifest manifest; } #endif #if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) - namespace chat { extern const AppManifest manifest; } + namespace chat { extern const ::AppManifest manifest; } #endif } @@ -174,118 +189,90 @@ namespace app { static void registerInternalApps() { LOG_I(TAG, "Registering internal apps"); - addAppManifest(app::alertdialog::manifest); - addAppManifest(app::appdetails::manifest); - addAppManifest(app::apphub::manifest); - addAppManifest(app::apphubdetails::manifest); - addAppManifest(app::applist::manifest); - addAppManifest(app::appsettings::manifest); + app_manager_add(&app::alertdialog::manifest); + app_manager_add(&app::appdetails::manifest); + app_manager_add(&app::apphub::manifest); + app_manager_add(&app::apphubdetails::manifest); + app_manager_add(&app::applist::manifest); + app_manager_add(&app::appsettings::manifest); if (service::audio::isAvailable()) { - addAppManifest(app::audiosettings::manifest); + app_manager_add(&app::audiosettings::manifest); } if (device_exists_of_type(&DISPLAY_TYPE)) { - addAppManifest(app::kerneldisplay::manifest); + app_manager_add(&app::kerneldisplay::manifest); } - addAppManifest(app::files::manifest); - addAppManifest(app::fileselection::manifest); - addAppManifest(app::i2cscanner::manifest); - addAppManifest(app::imageviewer::manifest); - addAppManifest(app::inputdialog::manifest); - addAppManifest(app::launcher::manifest); - addAppManifest(app::localesettings::manifest); - addAppManifest(app::notes::manifest); + app_manager_add(&app::files::manifest); + app_manager_add(&app::fileselection::manifest); + app_manager_add(&app::i2cscanner::manifest); + app_manager_add(&app::imageviewer::manifest); + app_manager_add(&app::inputdialog::manifest); + app_manager_add(&app::launcher::manifest); + app_manager_add(&app::localesettings::manifest); + app_manager_add(&app::notes::manifest); if (device_exists_of_type(&POWER_SUPPLY_TYPE)) { - addAppManifest(app::poweroff::manifest); + app_manager_add(&app::poweroff::manifest); } - addAppManifest(app::settings::manifest); - addAppManifest(app::selectiondialog::manifest); - addAppManifest(app::setup::manifest); - addAppManifest(app::systeminfo::manifest); - addAppManifest(app::timedatesettings::manifest); + app_manager_add(&app::settings::manifest); + app_manager_add(&app::selectiondialog::manifest); + app_manager_add(&app::setup::manifest); + app_manager_add(&app::systeminfo::manifest); + app_manager_add(&app::timedatesettings::manifest); #ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED - addAppManifest(app::touchcalibration::manifest); + app_manager_add(&app::touchcalibration::manifest); #endif - addAppManifest(app::timezone::manifest); - addAppManifest(app::wifiapsettings::manifest); - addAppManifest(app::wificonnect::manifest); - addAppManifest(app::wifimanage::manifest); + app_manager_add(&app::timezone::manifest); + app_manager_add(&app::wifiapsettings::manifest); + app_manager_add(&app::wificonnect::manifest); + app_manager_add(&app::wifimanage::manifest); #ifdef ESP_PLATFORM - addAppManifest(app::apwebserver::manifest); - addAppManifest(app::webserversettings::manifest); - addAppManifest(app::crashdiagnostics::manifest); - addAppManifest(app::development::manifest); + app_manager_add(&app::apwebserver::manifest); + app_manager_add(&app::webserversettings::manifest); + app_manager_add(&app::crashdiagnostics::manifest); + app_manager_add(&app::development::manifest); #if defined(CONFIG_TT_TDECK_WORKAROUND) - addAppManifest(app::keyboardsettings::manifest); + app_manager_add(&app::keyboardsettings::manifest); #endif #endif if (device_exists_of_type(&TRACKBALL_TYPE)) { - addAppManifest(app::trackballsettings::manifest); + app_manager_add(&app::trackballsettings::manifest); } #if defined(CONFIG_TINYUSB_MSC_ENABLED) && CONFIG_TINYUSB_MSC_ENABLED - addAppManifest(app::usbsettings::manifest); + app_manager_add(&app::usbsettings::manifest); #endif #if TT_FEATURE_SCREENSHOT_ENABLED - addAppManifest(app::screenshot::manifest); + app_manager_add(&app::screenshot::manifest); #endif #if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) - addAppManifest(app::chat::manifest); + app_manager_add(&app::chat::manifest); #endif if (device_exists_of_type(&GROVE_TYPE)) { - addAppManifest(app::grovesettings::manifest); + app_manager_add(&app::grovesettings::manifest); } if (device_exists_of_type(&UART_CONTROLLER_TYPE) || device_exists_of_type(&GROVE_TYPE)) { - addAppManifest(app::addgps::manifest); - addAppManifest(app::gpssettings::manifest); + app_manager_add(&app::addgps::manifest); + app_manager_add(&app::gpssettings::manifest); } if (device_exists_of_type(&POWER_SUPPLY_TYPE)) { - addAppManifest(app::power::manifest); + app_manager_add(&app::power::manifest); } #if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED - addAppManifest(app::btmanage::manifest); - addAppManifest(app::btpeersettings::manifest); + app_manager_add(&app::btmanage::manifest); + app_manager_add(&app::btpeersettings::manifest); #endif } -static void registerInstalledApp(std::string path) { - LOG_I(TAG, "Registering app at %s", path.c_str()); - std::string manifest_path = path + "/manifest.properties"; - if (!file::isFile(manifest_path)) { - LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str()); - return; - } - - app::AppManifest manifest; - if (!app::parseManifest(manifest_path, manifest)) { - LOG_E(TAG, "Failed to parse manifest at %s", manifest_path.c_str()); - return; - } - - manifest.appCategory = app::Category::User; - manifest.appLocation = app::Location::external(path); - - app::addAppManifest(manifest); -} - -static void registerInstalledApps(const std::string& path) { - LOG_I(TAG, "Registering apps from %s", path.c_str()); - - file::listDirectory(path, [&path](const auto& entry) { - auto absolute_path = std::format("{}/{}", path, entry.d_name); - if (file::isDirectory(absolute_path)) { - registerInstalledApp(absolute_path); - } - }); -} - +// Registers every mounted filesystem's app install directory with app-module (see +// app_manager_install_path_add()/app_manager_install_path_scan() in app/install.h), then scans +// them once to register whatever's already installed there. static void registerInstalledAppsFromFileSystems() { file_system_for_each(nullptr, [](auto* fs, void* context) { if (!file_system_is_mounted(fs)) return true; @@ -293,11 +280,12 @@ static void registerInstalledAppsFromFileSystems() { if (file_system_get_path(fs, path, sizeof(path)) != ERROR_NONE) return true; const auto app_path = std::format("{}/tactility/app", path); if (!app_path.starts_with(file::MOUNT_POINT_SYSTEM) && file::isDirectory(app_path)) { - LOG_I(TAG, "Registering apps from %s", app_path.c_str()); - registerInstalledApps(app_path); + LOG_I(TAG, "Registering install path %s", app_path.c_str()); + app_manager_install_path_add(app_path.c_str()); } return true; }); + app_manager_install_path_scan(); } static void registerAndStartServices() { @@ -316,7 +304,6 @@ static void registerAndStartServices() { #ifdef ESP_PLATFORM addService(service::webserver::manifest); #endif - addService(service::loader::manifest); #if defined(ESP_PLATFORM) if (device_exists_of_type(&RTC_TYPE)) { addService(service::rtctime::manifest); @@ -357,14 +344,48 @@ void registerApps() { } static void stopAppFromToolbar(lv_event_t*) { - app::stop(); + // Default nav action for any toolbar that doesn't override it itself. Prefer the topmost + // new-model app if one is showing; fall back to the old system otherwise (this is what + // every not-yet-converted app's toolbar still relies on). + AppInstanceId topmost = 0; + check(app_manager_get_topmost_instance_id(&topmost) == ERROR_NONE); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that + // bound-waits (thread_join) for the app's own thread to finish, which needs the LVGL + // lock to clean up - but this callback runs ON the LVGL task, which would deadlock + // against itself. + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(topmost, &event); +} + +static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) { + lv_obj_t* vertical_container = lv_obj_create(root); + lv_obj_set_size(vertical_container, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(vertical_container, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(vertical_container, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_gap(vertical_container, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(vertical_container, lv_color_black(), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(vertical_container, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(vertical_container, 0, LV_STATE_DEFAULT); + + lvgl::statusbar_create(vertical_container); + + auto* app_container = lv_obj_create(vertical_container); + lv_obj_set_style_pad_all(app_container, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(app_container, 0, LV_STATE_DEFAULT); + lv_obj_set_width(app_container, LV_PCT(100)); + lv_obj_set_flex_grow(app_container, 1); + lv_obj_set_flex_flow(app_container, LV_FLEX_FLOW_COLUMN); + + return app_container; } static void onLvglStarted() { + window_manager_configure(windowManagerScreenInit); + check(module_ensure_started(&lvgl_window_manager_module) == ERROR_NONE); + ToolbarConfig toolbar_config = { .nav_action_callback = stopAppFromToolbar }; lvgl_toolbar_configure(&toolbar_config); - addService(service::gui::manifest); addService(service::statusbar::manifest); addService(service::memorychecker::manifest); #if defined(ESP_PLATFORM) @@ -377,12 +398,17 @@ static void onLvglStarted() { addService(service::screenshot::manifest); #endif + lvgl::startUsbHidInput(); lvgl::initTrackball(); memory_print_stats(); } static void onLvglStopped() { + module_stop(&lvgl_window_manager_module); + + lvgl::stopUsbHidInput(); + #if TT_FEATURE_SCREENSHOT_ENABLED check(service::removeService(service::screenshot::manifest.id)); #endif @@ -394,7 +420,6 @@ static void onLvglStopped() { #endif check(service::removeService(service::memorychecker::manifest.id)); check(service::removeService(service::statusbar::manifest.id)); - check(service::removeService(service::gui::manifest.id)); memory_print_stats(); } @@ -412,6 +437,9 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) { check(module_ensure_started(&gps_module) == ERROR_NONE); check(module_ensure_started(&gps_generic_module) == ERROR_NONE); check(module_ensure_started(&gps_meshtastic_module) == ERROR_NONE); + // Registers the APP_LOCATION_MEMORY app loader (boot/launcher need it below). + check(module_ensure_started(&app_module) == ERROR_NONE); + check(module_ensure_started(&app_esp32_module) == ERROR_NONE); #ifdef ESP_PLATFORM initEsp(); @@ -447,9 +475,11 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) { LOG_I(TAG, "Core systems ready"); LOG_I(TAG, "Starting boot app"); - // The boot app takes care of registering system apps, user services and user apps - addAppManifest(app::boot::manifest); - app::start(app::boot::manifest.appId); + // The boot app takes care of registering system apps, user services and user apps. + // It's a new-model (app-module + window-manager) app now, replacing the old app::start(). + app_manager_add(&app::boot::manifest); + uint32_t boot_instance_id = 0; + app_manager_start(app::boot::manifest.id, &boot_instance_id); LOG_I(TAG, "Main dispatcher ready"); while (true) { diff --git a/Tactility/Source/app/App.cpp b/Tactility/Source/app/App.cpp deleted file mode 100644 index 598cbe56b..000000000 --- a/Tactility/Source/app/App.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include -#include - -namespace tt::app { - -constexpr auto* TAG = "App"; - -LaunchId start(const std::string& id, std::shared_ptr parameters) { - const auto service = service::loader::findLoaderService(); - assert(service != nullptr); - return service->start(id, std::move(parameters)); -} - -void stop() { - const auto service = service::loader::findLoaderService(); - assert(service != nullptr); - service->stopTop(); -} - -void stop(const std::string& id) { - const auto service = service::loader::findLoaderService(); - assert(service != nullptr); - service->stopTop(id); -} - -void stopAll(const std::string& id) { - const auto service = service::loader::findLoaderService(); - assert(service != nullptr); - service->stopAll(id); -} - -bool isRunning(const std::string& id) { - const auto service = service::loader::findLoaderService(); - assert(service != nullptr); - return service->isRunning(id); -} - -std::shared_ptr getCurrentAppContext() { - const auto service = service::loader::findLoaderService(); - assert(service != nullptr); - return service->getCurrentAppContext(); -} - -std::shared_ptr getCurrentApp() { - const auto app_context = getCurrentAppContext(); - return (app_context != nullptr) ? app_context->getApp() : nullptr; -} - -} diff --git a/Tactility/Source/app/AppInstall.cpp b/Tactility/Source/app/AppInstall.cpp deleted file mode 100644 index 7b6e05561..000000000 --- a/Tactility/Source/app/AppInstall.cpp +++ /dev/null @@ -1,206 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace tt::app { - -constexpr auto* TAG = "App"; - -static bool untarFile(minitar* mp, const minitar_entry* entry, const std::string& destinationPath) { - const auto absolute_path = destinationPath + "/" + entry->metadata.path; - if (!file::findOrCreateDirectory(destinationPath, 0777)) { - LOG_E(TAG, "Can't find or create directory %s", destinationPath.c_str()); - return false; - } - - // minitar_read_contents(&mp, &entry, file_buffer, entry.metadata.size); - if (!minitar_read_contents_to_file(mp, entry, absolute_path.c_str())) { - LOG_E(TAG, "Failed to write data to %s", absolute_path.c_str()); - return false; - } - - // Note: fchmod() doesn't exist on ESP-IDF and chmod() does nothing on that platform - if (chmod(absolute_path.c_str(), entry->metadata.mode) < 0) { - return false; - } - - return true; -} - -static bool untarDirectory(const minitar_entry* entry, const std::string& destinationPath) { - auto absolute_path = destinationPath + "/" + entry->metadata.path; - if (!file::findOrCreateDirectory(absolute_path, 0777)) return false; - return true; -} - -static bool untar(const std::string& tarPath, const std::string& destinationPath) { - minitar mp; - if (minitar_open(tarPath.c_str(), &mp) != 0) { - perror(tarPath.c_str()); - return 1; - } - bool success = true; - minitar_entry entry; - - do { - if (minitar_read_entry(&mp, &entry) == 0) { - LOG_I(TAG, "Extracting %s", entry.metadata.path); - if (entry.metadata.type == MTAR_DIRECTORY) { - if (!strcmp(entry.metadata.name, ".") || !strcmp(entry.metadata.name, "..") || !strcmp(entry.metadata.name, "/")) continue; - if (!untarDirectory(&entry, destinationPath)) { - LOG_E(TAG, "Failed to create directory %s/%s: %s", destinationPath.c_str(), entry.metadata.name, strerror(errno)); - success = false; - break; - } - } else if (entry.metadata.type == MTAR_REGULAR) { - if (!untarFile(&mp, &entry, destinationPath)) { - LOG_E(TAG, "Failed to extract file %s: %s", entry.metadata.path, strerror(errno)); - success = false; - break; - } - } else if (entry.metadata.type == MTAR_SYMLINK) { - LOG_E(TAG, "SYMLINK not supported"); - } else if (entry.metadata.type == MTAR_HARDLINK) { - LOG_E(TAG, "HARDLINK not supported"); - } else if (entry.metadata.type == MTAR_FIFO) { - LOG_E(TAG, "FIFO not supported"); - } else if (entry.metadata.type == MTAR_BLKDEV) { - LOG_E(TAG, "BLKDEV not supported"); - } else if (entry.metadata.type == MTAR_CHRDEV) { - LOG_E(TAG, "CHRDEV not supported"); - } else { - LOG_E(TAG, "Unknown entry type: %d", static_cast(entry.metadata.type)); - success = false; - break; - } - } else break; - } while (true); - minitar_close(&mp); - return success; -} - -void cleanupInstallDirectory(const std::string& path) { - if (!file::deleteRecursively(path)) { - LOG_W(TAG, "Failed to delete existing installation at %s", path.c_str()); - } -} - -bool install(const std::string& path) { - // We lock and unlock frequently because SPI SD card devices share - // the lock with the display. We don't want to lock the display for very long. - - auto app_parent_path = getAppInstallPath(); - LOG_I(TAG, "Installing app %s to %s", path.c_str(), app_parent_path.c_str()); - - auto filename = file::getLastPathSegment(path); - const std::string app_target_path = std::format("{}/{}", app_parent_path, filename); - if (file::isDirectory(app_target_path) && !file::deleteRecursively(app_target_path)) { - LOG_W(TAG, "Failed to delete %s", app_target_path.c_str()); - } - - if (!file::findOrCreateDirectory(app_target_path, 0777)) { - LOG_I(TAG, "Failed to create directory %s", app_target_path.c_str()); - return false; - } - - FileMutex target_path_mutex; - file_mutex_get(&target_path_mutex, app_parent_path.c_str()); - FileMutex source_path_mutex; - file_mutex_get(&source_path_mutex, path.c_str()); - - file_mutex_lock(&target_path_mutex); - file_mutex_lock(&source_path_mutex); - LOG_I(TAG, "Extracting app from %s to %s", path.c_str(), app_target_path.c_str()); - bool untar_success = untar(path, app_target_path); - file_mutex_unlock(&source_path_mutex); - file_mutex_unlock(&target_path_mutex); - if (!untar_success) { - LOG_E(TAG, "Failed to extract"); - return false; - } - - auto manifest_path = app_target_path + "/manifest.properties"; - if (!file::isFile(manifest_path)) { - LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str()); - cleanupInstallDirectory(app_target_path); - return false; - } - - AppManifest manifest; - if (!parseManifest(manifest_path, manifest)) { - LOG_W(TAG, "Invalid manifest"); - cleanupInstallDirectory(app_target_path); - return false; - } - - // If the app was already running, then stop it - if (isRunning(manifest.appId)) { - stopAll(manifest.appId); - } - - const std::string renamed_target_path = std::format("{}/{}", app_parent_path, manifest.appId); - if (file::isDirectory(renamed_target_path)) { - if (!file::deleteRecursively(renamed_target_path)) { - LOG_W(TAG, "Failed to delete existing installation at %s", renamed_target_path.c_str()); - cleanupInstallDirectory(app_target_path); - return false; - } - } - - file_mutex_lock(&target_path_mutex); - bool rename_success = rename(app_target_path.c_str(), renamed_target_path.c_str()) == 0; - file_mutex_unlock(&target_path_mutex); - - if (!rename_success) { - LOG_E(TAG, R"(Failed to rename "%s" to "%s")", app_target_path.c_str(), manifest.appId.c_str()); - cleanupInstallDirectory(app_target_path); - return false; - } - - manifest.appLocation = Location::external(renamed_target_path); - - addAppManifest(manifest); - - return true; -} - -bool uninstall(const std::string& appId) { - LOG_I(TAG, "Uninstalling app %s", appId.c_str()); - - // If the app was running, then stop it - if (isRunning(appId)) { - stopAll(appId); - } - - auto app_path = getAppInstallPath(appId); - if (!file::isDirectory(app_path)) { - LOG_E(TAG, "App %s not found at %s", appId.c_str(), app_path.c_str()); - return false; - } - - if (!file::deleteRecursively(app_path)) { - return false; - } - - if (!removeAppManifest(appId)) { - LOG_W(TAG, "Failed to remove app %s from registry", appId.c_str()); - } - - return true; -} - -} // namespace \ No newline at end of file diff --git a/Tactility/Source/app/AppInstance.cpp b/Tactility/Source/app/AppInstance.cpp deleted file mode 100644 index 16acc8886..000000000 --- a/Tactility/Source/app/AppInstance.cpp +++ /dev/null @@ -1,55 +0,0 @@ -#include -#include - -namespace tt::app { - -void AppInstance::setState(State newState) { - mutex.lock(); - state = newState; - mutex.unlock(); -} - -State AppInstance::getState() const { - mutex.lock(); - auto result = state; - mutex.unlock(); - return result; -} - -/** TODO: Make this thread-safe. - * In practice, the bundle is writeable, so someone could be writing to it - * while it is being accessed from another thread. - * Consider creating MutableBundle vs Bundle. - * Consider not exposing bundle, but expose `app_get_bundle_int(key)` methods with locking in it. - */ -const AppManifest& AppInstance::getManifest() const { - assert(manifest != nullptr); - return *manifest; -} - -Flags AppInstance::getFlags() const { - mutex.lock(); - auto result = flags; - mutex.unlock(); - return result; -} - -void AppInstance::setFlags(Flags newFlags) { - mutex.lock(); - flags = newFlags; - mutex.unlock(); -} - -std::shared_ptr AppInstance::getParameters() const { - mutex.lock(); - std::shared_ptr result = parameters; - mutex.unlock(); - return result; -} - -std::unique_ptr AppInstance::getPaths() const { - assert(manifest != nullptr); - return std::make_unique(*manifest); -} - -} // namespace diff --git a/Tactility/Source/app/AppManifestParsing.cpp b/Tactility/Source/app/AppManifestParsing.cpp deleted file mode 100644 index 6a4295aff..000000000 --- a/Tactility/Source/app/AppManifestParsing.cpp +++ /dev/null @@ -1,97 +0,0 @@ -#include -#include - -#include -#include -#include - -#include -#include - -namespace tt::app { - -constexpr auto* TAG = "AppManifest"; - -constexpr bool validateString(const std::string& value, const std::function& isValidChar) { - return std::ranges::all_of(value, isValidChar); -} - -bool getValueFromManifest(const std::map& map, const std::string& key, std::string& output) { - const auto iterator = map.find(key); - if (iterator == map.end()) { - LOG_E(TAG, "Failed to find %s in manifest", key.c_str()); - return false; - } - output = iterator->second; - return true; -} - -bool isValidId(const std::string& id) { - return id.size() >= 5 && validateString(id, [](const char c) { - return std::isalnum(c) != 0 || c == '.'; - }); -} - -bool isValidManifestVersion(const std::string& version) { - return !version.empty() && validateString(version, [](const char c) { - return std::isalnum(c) != 0 || c == '.'; - }); -} - -bool isValidAppVersionName(const std::string& version) { - return !version.empty() && validateString(version, [](const char c) { - return std::isalnum(c) != 0 || c == '.' || c == '-' || c == '_'; - }); -} - -bool isValidAppVersionCode(const std::string& version) { - return !version.empty() && validateString(version, [](const char c) { - return std::isdigit(c) != 0; - }); -} - -bool isValidName(const std::string& name) { - return name.size() >= 2 && validateString(name, [](const char c) { - return std::isalnum(c) != 0 || c == ' ' || c == '-'; - }); -} - -/** 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]"; -} - -bool parseManifest(const std::string& filePath, AppManifest& manifest) { - LOG_I(TAG, "Parsing manifest %s", filePath.c_str()); - - bool is_v1_format = detectIsV1Format(filePath); - - std::map properties; - if (!file::loadPropertiesFile(filePath, properties)) { - LOG_E(TAG, "Failed to load manifest at %s", filePath.c_str()); - return false; - } - - bool success = is_v1_format - ? parseManifestV1(properties, manifest) - : parseManifestV2(properties, manifest); - - if (!success) { - return false; - } - - manifest.appCategory = Category::User; - manifest.appLocation = Location::external(""); - - return true; -} - -} diff --git a/Tactility/Source/app/AppManifestParsingV1.cpp b/Tactility/Source/app/AppManifestParsingV1.cpp deleted file mode 100644 index 0858962b8..000000000 --- a/Tactility/Source/app/AppManifestParsingV1.cpp +++ /dev/null @@ -1,77 +0,0 @@ -#include -#include - -#include - -namespace tt::app { - -constexpr auto* TAG = "AppManifestV1"; - -bool parseManifestV1(const std::map& map, AppManifest& manifest) { - // [manifest] - - std::string manifest_version; - if (!getValueFromManifest(map, "[manifest]version", manifest_version)) { - return false; - } - - if (!isValidManifestVersion(manifest_version)) { - LOG_E(TAG, "Invalid version"); - return false; - } - - // [app] - - if (!getValueFromManifest(map, "[app]id", manifest.appId)) { - return false; - } - - if (!isValidId(manifest.appId)) { - LOG_E(TAG, "Invalid app id"); - return false; - } - - if (!getValueFromManifest(map, "[app]name", manifest.appName)) { - return false; - } - - if (!isValidName(manifest.appName)) { - LOG_E(TAG, "Invalid app name"); - return false; - } - - if (!getValueFromManifest(map, "[app]versionName", manifest.appVersionName)) { - return false; - } - - if (!isValidAppVersionName(manifest.appVersionName)) { - LOG_E(TAG, "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)) { - LOG_E(TAG, "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; -} - -} diff --git a/Tactility/Source/app/AppManifestParsingV2.cpp b/Tactility/Source/app/AppManifestParsingV2.cpp deleted file mode 100644 index b29291901..000000000 --- a/Tactility/Source/app/AppManifestParsingV2.cpp +++ /dev/null @@ -1,77 +0,0 @@ -#include -#include - -#include - -namespace tt::app { - -constexpr auto* TAG = "AppManifestV2"; - -bool parseManifestV2(const std::map& map, AppManifest& manifest) { - // manifest - - std::string manifest_version; - if (!getValueFromManifest(map, "manifest.version", manifest_version)) { - return false; - } - - if (!isValidManifestVersion(manifest_version)) { - LOG_E(TAG, "Invalid version"); - return false; - } - - // app - - if (!getValueFromManifest(map, "app.id", manifest.appId)) { - return false; - } - - if (!isValidId(manifest.appId)) { - LOG_E(TAG, "Invalid app id"); - return false; - } - - if (!getValueFromManifest(map, "app.name", manifest.appName)) { - return false; - } - - if (!isValidName(manifest.appName)) { - LOG_E(TAG, "Invalid app name"); - return false; - } - - if (!getValueFromManifest(map, "app.version.name", manifest.appVersionName)) { - return false; - } - - if (!isValidAppVersionName(manifest.appVersionName)) { - LOG_E(TAG, "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)) { - LOG_E(TAG, "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; -} - -} diff --git a/Tactility/Source/app/AppPaths.cpp b/Tactility/Source/app/AppPaths.cpp deleted file mode 100644 index 3f41edf62..000000000 --- a/Tactility/Source/app/AppPaths.cpp +++ /dev/null @@ -1,44 +0,0 @@ -#include - -#include -#include -#include - -#include - -#ifdef ESP_PLATFORM -constexpr auto PARTITION_PREFIX = std::string("/"); -#else -constexpr auto PARTITION_PREFIX = std::string(""); -#endif - -namespace tt::app { - -std::string AppPaths::getUserDataPath() const { - if (manifest.appLocation.isInternal()) { - return std::format("{}{}/tactility/user/app/{}", PARTITION_PREFIX, file::DATA_PARTITION_NAME, manifest.appId); - } else { - return std::format("{}/tactility/user/app/{}", file::getFirstPathSegment(manifest.appLocation.getPath()), manifest.appId); - } -} - -std::string AppPaths::getUserDataPath(const std::string& childPath) const { - assert(!childPath.starts_with('/')); - return std::format("{}/{}", getUserDataPath(), childPath); -} - - -std::string AppPaths::getAssetsPath() const { - if (manifest.appLocation.isInternal()) { - return std::format("{}{}/app/{}/assets", PARTITION_PREFIX, file::SYSTEM_PARTITION_NAME, manifest.appId); - } else { - return std::format("{}/assets", manifest.appLocation.getPath()); - } -} - -std::string AppPaths::getAssetsPath(const std::string& childPath) const { - assert(!childPath.starts_with('/')); - return std::format("{}/{}", getAssetsPath(), childPath); -} - -} diff --git a/Tactility/Source/app/AppRegistration.cpp b/Tactility/Source/app/AppRegistration.cpp deleted file mode 100644 index 8a5d60a08..000000000 --- a/Tactility/Source/app/AppRegistration.cpp +++ /dev/null @@ -1,63 +0,0 @@ -#include -#include - -#include - -#include -#include -#include - -namespace tt::app { - -constexpr auto* TAG = "AppRegistration"; - -typedef std::unordered_map> AppManifestMap; - -static AppManifestMap app_manifest_map; -static Mutex hash_mutex; - -void addAppManifest(const AppManifest& manifest) { - LOG_I(TAG, "Registering manifest %s", manifest.appId.c_str()); - - hash_mutex.lock(); - - if (app_manifest_map.contains(manifest.appId)) { - LOG_W(TAG, "Overwriting existing manifest for %s", manifest.appId.c_str()); - } - - app_manifest_map[manifest.appId] = std::make_shared(manifest); - - hash_mutex.unlock(); -} - -bool removeAppManifest(const std::string& id) { - LOG_I(TAG, "Removing manifest for %s", id.c_str()); - - auto lock = hash_mutex.asScopedLock(); - lock.lock(); - - return app_manifest_map.erase(id) == 1; -} - -std::shared_ptr findAppManifestById(const std::string& id) { - hash_mutex.lock(); - auto result = app_manifest_map.find(id); - hash_mutex.unlock(); - if (result != app_manifest_map.end()) { - return result->second; - } else { - return nullptr; - } -} - -std::vector> getAppManifests() { - std::vector> manifests; - hash_mutex.lock(); - for (const auto& item: app_manifest_map) { - manifests.push_back(item.second); - } - hash_mutex.unlock(); - return manifests; -} - -} // namespace diff --git a/Tactility/Source/app/ElfApp.cpp b/Tactility/Source/app/ElfApp.cpp deleted file mode 100644 index 8597fe77d..000000000 --- a/Tactility/Source/app/ElfApp.cpp +++ /dev/null @@ -1,235 +0,0 @@ -#ifdef ESP_PLATFORM - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace tt::app { - -constexpr auto* TAG = "ElfApp"; - -static std::string getErrorCodeString(int error_code) { - switch (error_code) { - case ENOMEM: - return "out of memory"; - case ENOSYS: - return "missing symbol"; - case EINVAL: - return "invalid argument or main() missing"; - default: - return std::format("code {}", error_code); - } -} - -class ElfApp final : public App { - -public: - - struct Parameters { - CreateData createData = nullptr; - DestroyData destroyData = nullptr; - OnCreate onCreate = nullptr; - OnDestroy onDestroy = nullptr; - OnShow onShow = nullptr; - OnHide onHide = nullptr; - OnResult onResult = nullptr; - }; - - static void setParameters(const Parameters& parameters) { - staticParameters = parameters; - staticParametersSetCount++; - } - -private: - - static Parameters staticParameters; - static size_t staticParametersSetCount; - static std::shared_ptr staticParametersLock; - - const std::string appPath; - std::unique_ptr elfFileData; - esp_elf_t elf { - .psegment = nullptr, - .svaddr = 0, - .ptext = nullptr, - .pdata = nullptr, - .sec = { }, - .entry = nullptr - }; - bool shouldCleanupElf = false; // Whether we have to clean up the above "elf" object - std::unique_ptr manifest; - void* data = nullptr; - std::string lastError = ""; - - bool startElf() { - const std::string elf_path = std::format("{}/elf/{}.elf", appPath, CONFIG_IDF_TARGET); - LOG_I(TAG, "Starting ELF %s", elf_path.c_str()); - assert(elfFileData == nullptr); - - size_t size = 0; - { - file::FileMutexGuard guard(elf_path); - elfFileData = file::readBinary(elf_path, size); - } - - if (elfFileData == nullptr) { - return false; - } - - if (esp_elf_init(&elf) != ESP_OK) { - lastError = "Failed to initialize"; - LOG_E(TAG, "%s", lastError.c_str()); - elfFileData = nullptr; - return false; - } - - auto relocate_result = esp_elf_relocate(&elf, elfFileData.get()); - if (relocate_result != 0) { - // Note: the result code maps to values from cstdlib's errno.h - lastError = getErrorCodeString(-relocate_result); - LOG_E(TAG, "Application failed to load: %s", lastError.c_str()); - esp_elf_deinit(&elf); - elfFileData = nullptr; - return false; - } - - int argc = 0; - char* argv[] = {}; - - if (esp_elf_request(&elf, 0, argc, argv) != ESP_OK) { - lastError = "Executable returned error code"; - LOG_E(TAG, "%s", lastError.c_str()); - esp_elf_deinit(&elf); - elfFileData = nullptr; - return false; - } - - shouldCleanupElf = true; - return true; - } - - void stopElf() { - LOG_I(TAG, "Cleaning up ELF"); - - if (shouldCleanupElf) { - esp_elf_deinit(&elf); - } - - if (elfFileData != nullptr) { - elfFileData = nullptr; - } - } - -public: - - explicit ElfApp(std::string appPath) : appPath(std::move(appPath)) {} - - void onCreate(AppContext& appContext) override { - // Because we use global variables, we have to ensure that we are not starting 2 apps in parallel - // We use a ScopedLock so we don't have to safeguard all branches - auto lock = staticParametersLock->asScopedLock(); - lock.lock(); - - staticParametersSetCount = 0; - if (!startElf()) { - stop(); - auto message = lastError.empty() ? "Application failed to start." : std::format("Application failed to start: {}", lastError); - alertdialog::start("Error", message); - return; - } - - if (staticParametersSetCount == 0) { - stop(); - alertdialog::start("Error", "Application failed to start: application failed to register itself"); - return; - } - - manifest = std::make_unique(staticParameters); - lock.unlock(); - - if (manifest->createData != nullptr) { - data = manifest->createData(); - } - - if (manifest->onCreate != nullptr) { - manifest->onCreate(&appContext, data); - } - } - - void onDestroy(AppContext& appContext) override { - LOG_I(TAG, "Cleaning up app"); - if (manifest != nullptr) { - if (manifest->onDestroy != nullptr) { - manifest->onDestroy(&appContext, data); - } - - if (manifest->destroyData != nullptr && data != nullptr) { - manifest->destroyData(data); - } - - this->manifest = nullptr; - } - stopElf(); - } - - void onShow(AppContext& appContext, lv_obj_t* parent) override { - if (manifest != nullptr && manifest->onShow != nullptr) { - manifest->onShow(&appContext, data, parent); - } - } - - void onHide(AppContext& appContext) override { - if (manifest != nullptr && manifest->onHide != nullptr) { - manifest->onHide(&appContext, data); - } - } - - void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr resultBundle) override { - if (manifest != nullptr && manifest->onResult != nullptr) { - manifest->onResult(&appContext, data, launchId, result, resultBundle.get()); - } - } -}; - -ElfApp::Parameters ElfApp::staticParameters; -size_t ElfApp::staticParametersSetCount = 0; -std::shared_ptr ElfApp::staticParametersLock = std::make_shared(); - -void setElfAppParameters( - CreateData createData, - DestroyData destroyData, - OnCreate onCreate, - OnDestroy onDestroy, - OnShow onShow, - OnHide onHide, - OnResult onResult -) { - ElfApp::setParameters({ - .createData = createData, - .destroyData = destroyData, - .onCreate = onCreate, - .onDestroy = onDestroy, - .onShow = onShow, - .onHide = onHide, - .onResult = onResult - }); -} - -std::shared_ptr createElfApp(const std::shared_ptr& manifest) { - LOG_I(TAG, "createElfApp"); - assert(manifest != nullptr); - assert(manifest->appLocation.isExternal()); - return std::make_shared(manifest->appLocation.getPath()); -} - -} // namespace - -#endif // ESP_PLATFORM diff --git a/Tactility/Source/app/addgps/AddGps.cpp b/Tactility/Source/app/addgps/AddGps.cpp index c1d187e5e..51979c57c 100644 --- a/Tactility/Source/app/addgps/AddGps.cpp +++ b/Tactility/Source/app/addgps/AddGps.cpp @@ -1,10 +1,15 @@ #include -#include #include #include -#include + +#include +#include +#include + +#include #include +#include #include #include @@ -18,8 +23,12 @@ namespace tt::app::addgps { constexpr auto* TAG = "AddGps"; -class AddGpsApp final : public App { +extern const ::AppManifest manifest; +namespace { + +struct Context { + uint32_t appInstanceId; lv_obj_t* uartDropdown = nullptr; lv_obj_t* modelDropdown = nullptr; lv_obj_t* baudDropdown = nullptr; @@ -30,168 +39,209 @@ class AddGpsApp final : public App { // We only need to parse back to int when adding the new GPS entry std::array baudRates = { 9600, 19200, 28800, 38400, 57600, 115200 }; const char* baudRatesDropdownValues = "9600\n19200\n28800\n38400\n57600\n115200"; +}; - static std::vector getModelNames() { - std::vector result; - for (int model = GpsModel::GPS_MODEL_UNKNOWN; model <= GpsModel::GPS_MODEL_UC6580; model++) { - result.emplace_back(gps_model_to_string(static_cast(model))); - } - return result; - } - static void onAddGpsCallback(lv_event_t* event) { - auto* app = (AddGpsApp*)lv_event_get_user_data(event); - app->onAddGps(); +std::vector getModelNames() { + std::vector result; + for (int model = GpsModel::GPS_MODEL_UNKNOWN; model <= GpsModel::GPS_MODEL_UC6580; model++) { + result.emplace_back(gps_model_to_string(static_cast(model))); } + return result; +} - void onAddGps() { - auto selected_baud_index = lv_dropdown_get_selected(baudDropdown); - - GpsConfiguration new_configuration = { - .uart_name = { 0x00 }, - .baud_rate = baudRates[selected_baud_index], - // Warning: This assumes that the enum is a regularly indexed one that starts at 0 - .model = (GpsModel)lv_dropdown_get_selected(modelDropdown) - }; - - lv_dropdown_get_selected_str(uartDropdown, new_configuration.uart_name, sizeof(new_configuration.uart_name)); - if (new_configuration.uart_name[0] == 0x00) { - alertdialog::start("Error", "You must select a bus/uart."); - return; - } +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - LOG_I(TAG, "Saving: uart=%s, model=%d, baud=%u", new_configuration.uart_name, (int)new_configuration.model, (unsigned)new_configuration.baud_rate); - if (gps_settings_add_configuration(&new_configuration) != ERROR_NONE) { - alertdialog::start("Error", "Failed to add configuration"); - } else { - stop(); - } +void onAddGpsPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto selected_baud_index = lv_dropdown_get_selected(ctx->baudDropdown); + + GpsConfiguration new_configuration = { + .uart_name = { 0x00 }, + .baud_rate = ctx->baudRates[selected_baud_index], + // Warning: This assumes that the enum is a regularly indexed one that starts at 0 + .model = (GpsModel)lv_dropdown_get_selected(ctx->modelDropdown) + }; + + lv_dropdown_get_selected_str(ctx->uartDropdown, new_configuration.uart_name, sizeof(new_configuration.uart_name)); + if (new_configuration.uart_name[0] == 0x00) { + alertdialog::start(ctx->appInstanceId, "Error", "You must select a bus/uart."); + return; } - void updateUartDevices() { - devices.clear(); - device_for_each_of_type(&UART_CONTROLLER_TYPE, &devices, [](auto* device, auto* context){ - auto* vector_ptr = static_cast*>(context); - vector_ptr->push_back(device); - return true; - }); + LOG_I(TAG, "Saving: uart=%s, model=%d, baud=%u", new_configuration.uart_name, (int)new_configuration.model, (unsigned)new_configuration.baud_rate); + if (gps_settings_add_configuration(&new_configuration) != ERROR_NONE) { + alertdialog::start(ctx->appInstanceId, "Error", "Failed to add configuration"); + } else { + onBackPressed(event); } +} - std::string getUartDropdownNames() { - std::vector names; - names.push_back(""); - for (auto* device: devices) { - names.push_back(device->name); - } - return string::join(names, "\n"); +void updateUartDevices(Context* ctx) { + ctx->devices.clear(); + device_for_each_of_type(&UART_CONTROLLER_TYPE, &ctx->devices, [](auto* device, auto* context) { + auto* vector_ptr = static_cast*>(context); + vector_ptr->push_back(device); + return true; + }); +} + +std::string getUartDropdownNames(Context* ctx) { + std::vector names; + names.push_back(""); + for (auto* device: ctx->devices) { + names.push_back(device->name); } + return string::join(names, "\n"); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); -public: + auto* toolbar = lvgl_toolbar_create(parent, "Add GPS"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - void onShow(AppContext& app, lv_obj_t* parent) final { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(main_wrapper, 0, 0); + lv_obj_set_style_border_width(main_wrapper, 0, 0); + lvgl::obj_set_style_bg_invisible(main_wrapper); - lvgl::toolbar_create(parent, app); + // region Uart - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(main_wrapper, 0, 0); - lv_obj_set_style_border_width(main_wrapper, 0, 0); - lvgl::obj_set_style_bg_invisible(main_wrapper); + auto* uart_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(uart_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(uart_wrapper, 0, 0); + lv_obj_set_style_border_width(uart_wrapper, 0, 0); + lvgl::obj_set_style_bg_invisible(uart_wrapper); - // region Uart + ctx->uartDropdown = lv_dropdown_create(uart_wrapper); - auto* uart_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(uart_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_ver(uart_wrapper, 0, 0); - lv_obj_set_style_border_width(uart_wrapper, 0, 0); - lvgl::obj_set_style_bg_invisible(uart_wrapper); + updateUartDevices(ctx); - uartDropdown = lv_dropdown_create(uart_wrapper); + auto uart_options = getUartDropdownNames(ctx); + lv_dropdown_set_options(ctx->uartDropdown, uart_options.c_str()); + lv_obj_align(ctx->uartDropdown, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_set_width(ctx->uartDropdown, LV_PCT(50)); - updateUartDevices(); + auto* uart_label = lv_label_create(uart_wrapper); + lv_obj_align(uart_label, LV_ALIGN_TOP_LEFT, 0, 10); + lv_label_set_text(uart_label, "Bus"); - auto uart_options = getUartDropdownNames(); - lv_dropdown_set_options(uartDropdown, uart_options.c_str()); - lv_obj_align(uartDropdown, LV_ALIGN_TOP_RIGHT, 0, 0); - lv_obj_set_width(uartDropdown, LV_PCT(50)); + // region Model - auto* uart_label = lv_label_create(uart_wrapper); - lv_obj_align(uart_label, LV_ALIGN_TOP_LEFT, 0, 10); - lv_label_set_text(uart_label, "Bus"); + auto* model_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(model_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(model_wrapper, 0, 0); + lv_obj_set_style_border_width(model_wrapper, 0, 0); + lvgl::obj_set_style_bg_invisible(model_wrapper); - // region Model + ctx->modelDropdown = lv_dropdown_create(model_wrapper); - auto* model_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(model_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_ver(model_wrapper, 0, 0); - lv_obj_set_style_border_width(model_wrapper, 0, 0); - lvgl::obj_set_style_bg_invisible(model_wrapper); + auto model_names = getModelNames(); + auto model_options = string::join(model_names, "\n"); + lv_dropdown_set_options(ctx->modelDropdown, model_options.c_str()); + lv_obj_align(ctx->modelDropdown, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_set_width(ctx->modelDropdown, LV_PCT(50)); - modelDropdown = lv_dropdown_create(model_wrapper); + auto* model_label = lv_label_create(model_wrapper); + lv_obj_align(model_label, LV_ALIGN_TOP_LEFT, 0, 10); + lv_label_set_text(model_label, "Model"); - auto model_names = getModelNames(); - auto model_options = string::join(model_names, "\n"); - lv_dropdown_set_options(modelDropdown, model_options.c_str()); - lv_obj_align(modelDropdown, LV_ALIGN_TOP_RIGHT, 0, 0); - lv_obj_set_width(modelDropdown, LV_PCT(50)); + // endregion - auto* model_label = lv_label_create(model_wrapper); - lv_obj_align(model_label, LV_ALIGN_TOP_LEFT, 0, 10); - lv_label_set_text(model_label, "Model"); + // region Baud + auto* baud_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(baud_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(baud_wrapper, 0, 0); + lv_obj_set_style_border_width(baud_wrapper, 0, 0); + lvgl::obj_set_style_bg_invisible(baud_wrapper); - // endregion + ctx->baudDropdown = lv_dropdown_create(baud_wrapper); + lv_dropdown_set_options(ctx->baudDropdown, ctx->baudRatesDropdownValues); + lv_obj_align(ctx->baudDropdown, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_set_width(ctx->baudDropdown, LV_PCT(50)); - // region Baud - auto* baud_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(baud_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_ver(baud_wrapper, 0, 0); - lv_obj_set_style_border_width(baud_wrapper, 0, 0); - lvgl::obj_set_style_bg_invisible(baud_wrapper); + auto* baud_rate_label = lv_label_create(baud_wrapper); + lv_obj_align(baud_rate_label, LV_ALIGN_TOP_LEFT, 0, 10); + lv_label_set_text(baud_rate_label, "Baud"); - baudDropdown = lv_dropdown_create(baud_wrapper); - lv_dropdown_set_options(baudDropdown, baudRatesDropdownValues); - lv_obj_align(baudDropdown, LV_ALIGN_TOP_RIGHT, 0, 0); - lv_obj_set_width(baudDropdown, LV_PCT(50)); + // endregion - auto* baud_rate_label = lv_label_create(baud_wrapper); - lv_obj_align(baud_rate_label, LV_ALIGN_TOP_LEFT, 0, 10); - lv_label_set_text(baud_rate_label, "Baud"); + // region Button - // endregion + auto* button_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(button_wrapper, 0, 0); + lv_obj_set_style_border_width(button_wrapper, 0, 0); + lvgl::obj_set_style_bg_invisible(button_wrapper); - // region Button + auto* add_button = lv_button_create(button_wrapper); + lv_obj_align(add_button, LV_ALIGN_TOP_MID, 0, 0); + auto* add_label = lv_label_create(add_button); + lv_label_set_text(add_label, "Add"); + lv_obj_add_event_cb(add_button, onAddGpsPressed, LV_EVENT_SHORT_CLICKED, ctx); - auto* button_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_ver(button_wrapper, 0, 0); - lv_obj_set_style_border_width(button_wrapper, 0, 0); - lvgl::obj_set_style_bg_invisible(button_wrapper); + // endregion +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; - auto* add_button = lv_button_create(button_wrapper); - lv_obj_align(add_button, LV_ALIGN_TOP_MID, 0, 0); - auto* add_label = lv_label_create(add_button); - lv_label_set_text(add_label, "Add"); - lv_obj_add_event_cb(add_button, onAddGpsCallback, LV_EVENT_SHORT_CLICKED, this); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - // endregion + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + app_manager_stop(event.result.launch_id); + break; + default: + break; + } } -}; -extern const AppManifest manifest = { - .appId = "AddGps", - .appName = "Add GPS", - .appIcon = LVGL_ICON_SHARED_NAVIGATION, - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); -void start() { - app::start(manifest.appId); + return 0; } } // namespace + +extern const ::AppManifest manifest = { + .id = "AddGps", + .name = "Add GPS", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + +} // namespace diff --git a/Tactility/Source/app/alertdialog/AlertDialog.cpp b/Tactility/Source/app/alertdialog/AlertDialog.cpp index 656a73ea2..eb8082ff5 100644 --- a/Tactility/Source/app/alertdialog/AlertDialog.cpp +++ b/Tactility/Source/app/alertdialog/AlertDialog.cpp @@ -1,7 +1,10 @@ #include "Tactility/app/alertdialog/AlertDialog.h" -#include -#include +#include +#include +#include + +#include #include @@ -10,133 +13,150 @@ namespace tt::app::alertdialog { -#define PARAMETER_BUNDLE_KEY_TITLE "title" -#define PARAMETER_BUNDLE_KEY_MESSAGE "message" -#define PARAMETER_BUNDLE_KEY_BUTTON_LABELS "buttonLabels" -#define RESULT_BUNDLE_KEY_INDEX "index" - -#define PARAMETER_ITEM_CONCATENATION_TOKEN ";;" -#define DEFAULT_TITLE "" - constexpr auto* TAG = "AlertDialog"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; + // Set once in appMain() from its own argc/argv parameters, read by createWidgets() (which + // may run on a different task - the LVGL task, or another app's task via + // window_manager_remove()'s cross-thread rebuild-on-remove path). Safe to hold onto without a + // lock: the deep copy stays valid for exactly as long as appMain() is running, which is + // longer than createWidgets() ever needs it. + int argc = 0; + char** argv = nullptr; + // The eventual appMain() return value (= this dialog's APP_EVENT_RESULT result code) - + // written here by onButtonPressed() (LVGL thread) before it emits APP_EVENT_CLOSE, read by + // appMain() (this dialog's own thread) after waking from that event. No atomic/lock needed: + // the emit/await pair between the two already establishes happens-before ordering, same as + // every other cross-thread Context field write in this codebase's converted apps. + int32_t result = 1; // Cancelled - safety-net default if closed without pressing a button +}; -LaunchId start(const std::string& title, const std::string& message, const std::vector& buttonLabels) { - std::string items_joined = string::join(buttonLabels, PARAMETER_ITEM_CONCATENATION_TOKEN); - auto bundle = std::make_shared(); - bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title); - bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message); - bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_joined); - return app::start(manifest.appId, bundle); -} +struct ButtonContext { + Context* ctx; + int32_t index; +}; -LaunchId start(const std::string& title, const std::string& message, const std::vector& buttonLabels) { - std::string items_joined = string::join(buttonLabels, PARAMETER_ITEM_CONCATENATION_TOKEN); - auto bundle = std::make_shared(); - bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title); - bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message); - bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_joined); - return app::start(manifest.appId, bundle); +void onButtonDeleted(lv_event_t* e) { + delete static_cast(lv_event_get_user_data(e)); } -LaunchId start(const std::string& title, const std::string& message) { - auto bundle = std::make_shared(); - bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title); - bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message); - bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, "OK"); - return app::start(manifest.appId, bundle); +void onButtonPressed(lv_event_t* e) { + auto* btnCtx = static_cast(lv_event_get_user_data(e)); + LOG_I(TAG, "Selected item at index %d", (int)btnCtx->index); + btnCtx->ctx->result = btnCtx->index; + // Async, non-blocking - just wakes this dialog's own thread. Must NOT call + // app_manager_stop() here: that bound-waits (thread_join) for the dialog's thread to + // finish, which needs the LVGL lock (window_manager_remove()) - but this callback is + // running ON the LVGL task, which would deadlock against itself. The caller reaps this + // instance via app_manager_stop() after it receives the APP_EVENT_RESULT instead. + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(btnCtx->ctx->appInstanceId, &event); } -int32_t getResultIndex(const Bundle& bundle) { - int32_t index = -1; - bundle.optInt32(RESULT_BUNDLE_KEY_INDEX, index); - return index; +void createButton(Context* ctx, lv_obj_t* parent, const std::string& text, int32_t index) { + lv_obj_t* button = lv_button_create(parent); + lv_obj_t* button_label = lv_label_create(button); + lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(button_label, text.c_str()); + auto* btnCtx = new ButtonContext { ctx, index }; + lv_obj_add_event_cb(button, onButtonPressed, LV_EVENT_SHORT_CLICKED, btnCtx); + lv_obj_add_event_cb(button, onButtonDeleted, LV_EVENT_DELETE, btnCtx); } -static std::string getTitleParameter(std::shared_ptr bundle) { - std::string result; - if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) { - return result; - } else { - return DEFAULT_TITLE; +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + // argv layout: [0]=title, [1]=message, [2..argc)=button labels. + int argc = ctx->argc; + char** argv = ctx->argv; + + lv_obj_t* toolbar = lvgl_toolbar_create(parent, argv[0]); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + lv_obj_t* message_label = lv_label_create(parent); + lv_obj_align(message_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_width(message_label, LV_PCT(80)); + lv_obj_set_style_text_align(message_label, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(message_label, argv[1]); + lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP); + + lv_obj_t* button_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(button_wrapper, 0, 0); + lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_border_width(button_wrapper, 0, 0); + lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4); + + for (int32_t index = 0; index < argc - 2; index++) { + createButton(ctx, button_wrapper, argv[2 + index], index); } } +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx { appInstanceId }; + ctx.argc = argc; + ctx.argv = argv; -class AlertDialogApp : public App { + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - static void onButtonClickedCallback(lv_event_t* e) { - auto app = std::static_pointer_cast(getCurrentApp()); - assert(app != nullptr); - app->onButtonClicked(e); - } - - void onButtonClicked(lv_event_t* e) { - auto index = reinterpret_cast(lv_event_get_user_data(e)); - LOG_I(TAG, "Selected item at index %d", (int)index); - - auto bundle = std::make_unique(); - bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, (int32_t)index); - setResult(Result::Ok, std::move(bundle)); + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - stop(manifest.appId); + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); // no-op: modal children never supersede anything + break; + } } - static void createButton(lv_obj_t* parent, const std::string& text, size_t index) { - lv_obj_t* button = lv_button_create(parent); - lv_obj_t* button_label = lv_label_create(button); - lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(button_label, text.c_str()); - lv_obj_add_event_cb(button, onButtonClickedCallback, LV_EVENT_SHORT_CLICKED, (void*)index); - } + window_manager_remove(window); + app_event_unsubscribe(&sub); -public: + return ctx.result; +} - void onShow(AppContext& app, lv_obj_t* parent) override { - auto parameters = app.getParameters(); - check(parameters != nullptr, "Parameters missing"); +} // namespace - std::string title = getTitleParameter(app.getParameters()); - lv_obj_t* toolbar = lvgl_toolbar_create(parent, title.c_str()); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); +namespace { - lv_obj_t* message_label = lv_label_create(parent); - lv_obj_align(message_label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_width(message_label, LV_PCT(80)); - lv_obj_set_style_text_align(message_label, LV_TEXT_ALIGN_CENTER, 0); +// Builds argv = [title, message, buttonLabels...] for app_manager_start_for_result(). +std::vector buildArgv(const std::string& title, const std::string& message, const std::vector& buttonLabels) { + std::vector argv { title.c_str(), message.c_str() }; + for (const auto& label: buttonLabels) { + argv.push_back(label.c_str()); + } + return argv; +} - std::string message; - if (parameters->optString(PARAMETER_BUNDLE_KEY_MESSAGE, message)) { - lv_label_set_text(message_label, message.c_str()); - lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP); - } +} // namespace - lv_obj_t* button_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(button_wrapper, 0, 0); - lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_border_width(button_wrapper, 0, 0); - lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4); - - std::string items_concatenated; - if (parameters->optString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_concatenated)) { - std::vector labels = string::split(items_concatenated, PARAMETER_ITEM_CONCATENATION_TOKEN); - size_t index = 0; - for (const auto& label: labels) { - createButton(button_wrapper, label, index++); - } - } - } -}; +uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::vector& buttonLabels) { + auto argv = buildArgv(title, message, buttonLabels); + uint32_t instanceId = 0; + app_manager_start_for_result(manifest.id, callerAppInstanceId, static_cast(argv.size()), argv.data(), &instanceId); + return instanceId; +} + +uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message) { + return start(callerAppInstanceId, title, message, std::vector { "OK" }); +} -extern const AppManifest manifest = { - .appId = "AlertDialog", - .appName = "Alert Dialog", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create +extern const ::AppManifest manifest = { + .id = "AlertDialog", + .name = "Alert Dialog", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } diff --git a/Tactility/Source/app/appdetails/AppDetails.cpp b/Tactility/Source/app/appdetails/AppDetails.cpp index 88257d6f5..d7f1b72a9 100644 --- a/Tactility/Source/app/appdetails/AppDetails.cpp +++ b/Tactility/Source/app/appdetails/AppDetails.cpp @@ -1,114 +1,168 @@ -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include -#include +#include #include #include -#include +#include + +#include +#include +#include +#include + +#include + +constexpr auto* TAG = "AppDetails"; namespace tt::app::appdetails { -extern const AppManifest manifest; +extern const ::AppManifest manifest; -void start(const std::string& appId) { - auto bundle = std::make_shared(); - bundle->putString("appId", appId); - app::start(manifest.appId, bundle); -} +namespace { -class AppDetailsApp : public App { +struct Context { + uint32_t appInstanceId; + std::string targetAppId; + // findAppManifestById() returns the old-model registry's AppManifest type - AppDetails + // shows details for apps in that registry regardless of which system they run under. + AppManifest targetManifest = { }; + uint32_t pendingUninstallDialogId = 0; +}; - std::shared_ptr manifest; - static void onPressUninstall(lv_event_t* event) { - auto* self = static_cast(lv_event_get_user_data(event)); - std::vector choices = { - "Yes", - "No" - }; - alertdialog::start("Confirmation", std::format("Uninstall {}?", self->manifest->appName), choices); - } +void onPressUninstall(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + std::vector choices = { "Yes", "No" }; + ctx->pendingUninstallDialogId = alertdialog::start( + ctx->appInstanceId, + "Confirmation", + std::format("Uninstall {}?", ctx->targetManifest.name), + choices + ); +} -public: +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - void onCreate(AppContext& app) override { - const auto parameters = app.getParameters(); - check(parameters != nullptr, "Parameters missing"); - auto app_id = parameters->getString("appId"); - manifest = findAppManifestById(app_id); - assert(manifest != nullptr); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto title = std::format("{} details", ctx->targetManifest.name); + auto* toolbar = lvgl_toolbar_create(parent, title.c_str()); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lvgl::obj_set_style_bg_invisible(wrapper); + + auto identifier = std::format("Identifier: {}", ctx->targetManifest.id); + auto* identifier_label = lv_label_create(wrapper); + lv_label_set_text(identifier_label, identifier.c_str()); + + auto* location_label = lv_label_create(wrapper); + std::string location; + bool is_internal = ctx->targetManifest.location.type == APP_LOCATION_MEMORY; + bool is_external = ctx->targetManifest.location.type == APP_LOCATION_PATH; + if (is_internal) { + location = "internal"; + } else if (is_external) { + if (!string::getPathParent(static_cast(ctx->targetManifest.location.location), location)) { + location = "external"; + } + } else { + LOG_E(TAG, "Unknown app location type %d", ctx->targetManifest.location.type); + return; + } + std::string location_label_text = std::format("Location: {}", location); + lv_label_set_text(location_label, location_label_text.c_str()); + + if (is_external) { + auto* uninstall_button = lv_button_create(wrapper); + lv_obj_set_width(uninstall_button, LV_PCT(100)); + lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, ctx); + auto* uninstall_label = lv_label_create(uninstall_button); + lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(uninstall_label, "Uninstall"); } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - auto title = std::format("{} details", manifest->appName); - lvgl_toolbar_create(parent, title.c_str()); - - auto* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); - lvgl::obj_set_style_bg_invisible(wrapper); - - auto identifier = std::format("Identifier: {}", manifest->appId); - auto* identifier_label = lv_label_create(wrapper); - lv_label_set_text(identifier_label, identifier.c_str()); - - auto* location_label = lv_label_create(wrapper); - std::string location; - if (manifest->appLocation.isInternal()) { - location = "internal"; - } else { - if (!string::getPathParent(manifest->appLocation.getPath(), location)) { - location = "external"; - } + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.targetAppId = (argc > 0) ? argv[0] : std::string(); + ctx.targetManifest = *app_manager_find_manifest(ctx.targetAppId.c_str()); + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; } - std::string location_label_text = std::format("Location: {}", location); - lv_label_set_text(location_label, location_label_text.c_str()); - - if (manifest->appLocation.isExternal()) { - auto* uninstall_button = lv_button_create(wrapper); - lv_obj_set_width(uninstall_button, LV_PCT(100)); - lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, this); - auto* uninstall_label = lv_label_create(uninstall_button); - lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(uninstall_label, "Uninstall"); + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + if (event.result.launch_id == ctx.pendingUninstallDialogId) { + if (event.result.result == 0) { // 0 = Yes + app_uninstall(ctx.targetManifest.id); + app_manager_finish(appInstanceId); + shouldClose = true; + } + app_manager_stop(event.result.launch_id); + } + break; + default: + break; } } - void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr bundle) override { - if (result != Result::Ok || bundle == nullptr) { - return; - } + window_manager_remove(window); + app_event_unsubscribe(&sub); - if (alertdialog::getResultIndex(*bundle) != 0) { // 0 = Yes - return; - } + return 0; +} - uninstall(manifest->appId); +} // namespace - // Stop app - stop(); - } -}; +void start(const std::string& appId) { + const char* argv[] = { appId.c_str() }; + uint32_t instanceId = 0; + app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId); +} -extern const AppManifest manifest = { - .appId = "AppDetails", - .appName = "App Details", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create +extern const ::AppManifest manifest = { + .id = "AppDetails", + .name = "App Details", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } // namespace - diff --git a/Tactility/Source/app/apphub/AppHubApp.cpp b/Tactility/Source/app/apphub/AppHubApp.cpp index e1c43a321..8521636f1 100644 --- a/Tactility/Source/app/apphub/AppHubApp.cpp +++ b/Tactility/Source/app/apphub/AppHubApp.cpp @@ -1,18 +1,23 @@ +#include #include #include #include #include #include -#include #include -#include #include +#include +#include +#include + +#include + #include -#include #include #include +#include #include #include @@ -21,166 +26,189 @@ namespace tt::app::apphub { constexpr auto* TAG = "AppHub"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; + +namespace { -class AppHubApp final : public App { +struct Context { + uint32_t appInstanceId; lv_obj_t* contentWrapper = nullptr; lv_obj_t* refreshButton = nullptr; std::string cachedAppsJsonFile = std::format("{}/app_hub.json", getTempPath()); - std::unique_ptr thread; std::vector entries; Mutex mutex; +}; - static std::shared_ptr findAppInstance() { - auto app_context = getCurrentAppContext(); - if (app_context->getManifest().appId != manifest.appId) { - return nullptr; - } - return std::static_pointer_cast(app_context->getApp()); - } - static void onAppPressed(lv_event_t* e) { - const auto* self = static_cast(lv_event_get_user_data(e)); - auto* widget = lv_event_get_target_obj(e); - const auto* user_data = lv_obj_get_user_data(widget); - const intptr_t index = reinterpret_cast(user_data); - self->mutex.lock(); - if (index < self->entries.size()) { - apphubdetails::start(self->entries[index]); +void showApps(Context* ctx); +void refresh(Context* ctx); + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void onAppPressed(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + auto* widget = lv_event_get_target_obj(e); + const auto* user_data = lv_obj_get_user_data(widget); + const intptr_t index = reinterpret_cast(user_data); + ctx->mutex.lock(); + if (index < ctx->entries.size()) { + apphubdetails::start(ctx->entries[index]); + } + ctx->mutex.unlock(); +} + +void onRefreshPressed(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + refresh(ctx); +} + +void showRefreshFailedError(Context* ctx, const char* message) { + lv_obj_clean(ctx->contentWrapper); + + auto* label = lv_label_create(ctx->contentWrapper); + lv_label_set_text(label, message); + lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); + + lv_obj_remove_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN); +} + +void showNoInternet(Context* ctx) { + showRefreshFailedError(ctx, "No Internet Connection"); +} + +void showApps(Context* ctx) { + lv_obj_clean(ctx->contentWrapper); + ctx->mutex.lock(); + if (parseJson(ctx->cachedAppsJsonFile, ctx->entries)) { + std::ranges::sort(ctx->entries, [](auto left, auto right) { + return left.appName < right.appName; + }); + + auto* list = lv_list_create(ctx->contentWrapper); + lv_obj_set_style_pad_all(list, 0, LV_STATE_DEFAULT); + lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT); + for (int i = 0; i < ctx->entries.size(); i++) { + auto& entry = ctx->entries[i]; + LOG_I(TAG, "Adding %s", entry.appName.c_str()); + const char* icon = app_manager_find_manifest(entry.appId.c_str()) != nullptr ? LV_SYMBOL_OK : nullptr; + auto* entry_button = lv_list_add_button(list, icon, entry.appName.c_str()); + auto int_as_voidptr = reinterpret_cast(i); + lv_obj_set_user_data(entry_button, int_as_voidptr); + lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx); } - self->mutex.unlock(); + } else { + showRefreshFailedError(ctx, "Failed to load content"); } + ctx->mutex.unlock(); +} - static void onRefreshPressed(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->refresh(); - } +void refresh(Context* ctx) { + lv_obj_clean(ctx->contentWrapper); + auto* spinner = lvgl_spinner_create(ctx->contentWrapper); + lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0); - void onRefreshSuccess() { - LOG_I(TAG, "Request success"); - lvgl_lock(); - showApps(); - lvgl_unlock(); - } + lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN); - void onRefreshError(const char* error) { - LOG_E(TAG, "Request failed: %s", error); - lvgl_lock(); - showRefreshFailedError("Cannot reach server"); - lvgl_unlock(); + if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) { + showNoInternet(ctx); + return; } - static void createAppWidget(const std::shared_ptr& manifest, lv_obj_t* list) { - lv_obj_t* btn = lv_list_add_button(list, nullptr, manifest->appName.c_str()); - lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get()); + if (file::isFile(ctx->cachedAppsJsonFile)) { + showApps(ctx); } - void showRefreshFailedError(const char* message) { - lv_obj_clean(contentWrapper); + // These callbacks run on a background network thread and reach back into this app's + // widgets via the captured ctx pointer - same convention as AppHubDetailsApp.cpp's + // download callback for the sibling "install/update" flow. + network::http::download( + getAppsJsonUrl(), + CERTIFICATE_PATH, + ctx->cachedAppsJsonFile, + [ctx] { + LOG_I(TAG, "Request success"); + lvgl_lock(); + showApps(ctx); + lvgl_unlock(); + }, + [ctx](const char* error) { + LOG_E(TAG, "Request failed: %s", error); + lvgl_lock(); + showRefreshFailedError(ctx, "Cannot reach server"); + lvgl_unlock(); + } + ); +} - auto* label = lv_label_create(contentWrapper); - lv_label_set_text(label, message); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - lv_obj_remove_flag(refreshButton, LV_OBJ_FLAG_HIDDEN); - } + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - void showNoInternet() { - showRefreshFailedError("No Internet Connection"); - } + auto* toolbar = lvgl_toolbar_create(parent, "App Hub"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx); + lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN); - void showTimeNotSynced() { - showRefreshFailedError("Time is not synced yet.\nIt's required to establish a secure connection."); - } + ctx->contentWrapper = lv_obj_create(parent); + lv_obj_set_width(ctx->contentWrapper, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->contentWrapper, 1); + lv_obj_set_style_pad_all(ctx->contentWrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_ver(ctx->contentWrapper, 0, LV_STATE_DEFAULT); - void showApps() { - lv_obj_clean(contentWrapper); - mutex.lock(); - if (parseJson(cachedAppsJsonFile, entries)) { - std::ranges::sort(entries, [](auto left, auto right) { - return left.appName < right.appName; - }); - - auto* list = lv_list_create(contentWrapper); - lv_obj_set_style_pad_all(list, 0, LV_STATE_DEFAULT); - lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT); - for (int i = 0; i < entries.size(); i++) { - auto& entry = entries[i]; - LOG_I(TAG, "Adding %s", entry.appName.c_str()); - const char* icon = findAppManifestById(entry.appId) != nullptr ? LV_SYMBOL_OK : nullptr; - auto* entry_button = lv_list_add_button(list, icon, entry.appName.c_str()); - auto int_as_voidptr = reinterpret_cast(i); - lv_obj_set_user_data(entry_button, int_as_voidptr); - lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, this); - } - } else { - showRefreshFailedError("Failed to load content"); - } - mutex.unlock(); - } + refresh(ctx); +} - void refresh() { - lv_obj_clean(contentWrapper); - auto* spinner = lvgl_spinner_create(contentWrapper); - lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx; + ctx.appInstanceId = appInstanceId; - lv_obj_add_flag(refreshButton, LV_OBJ_FLAG_HIDDEN); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) { - showNoInternet(); - return; - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - if (file::isFile(cachedAppsJsonFile)) { - showApps(); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } - - network::http::download( - getAppsJsonUrl(), - CERTIFICATE_PATH, - cachedAppsJsonFile, - [] { - auto app = findAppInstance(); - if (app != nullptr) { - app->onRefreshSuccess(); - } - }, - [](const char* error) { - auto app = findAppInstance(); - if (app != nullptr) { - app->onRefreshError(error); - } - } - ); } -public: + window_manager_remove(window); + app_event_unsubscribe(&sub); - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + return 0; +} - auto* toolbar = lvgl::toolbar_create(parent, app); - refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, this); - lv_obj_add_flag(refreshButton, LV_OBJ_FLAG_HIDDEN); - - contentWrapper = lv_obj_create(parent); - lv_obj_set_width(contentWrapper, LV_PCT(100)); - lv_obj_set_flex_grow(contentWrapper, 1); - lv_obj_set_style_pad_all(contentWrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_ver(contentWrapper, 0, LV_STATE_DEFAULT); - - refresh(); - } -}; +} // namespace -extern const AppManifest manifest = { - .appId = "AppHub", - .appName = "App Hub", - .appIcon = LVGL_ICON_SHARED_HUB, - .appCategory = Category::System, - .createApp = create, +extern const ::AppManifest manifest = { + .id = "AppHub", + .name = "App Hub", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace diff --git a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp index 20611476d..9ea2b50ed 100644 --- a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp +++ b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp @@ -1,255 +1,317 @@ +#include "../../../../Modules/app-module/private/app/private/app_ledger.h" +#include "app/metadata.h" + + #include #include -#include #include #include #include #include #include -#include + +#include +#include +#include +#include + +#include #include #include #include +#include +#include #include namespace tt::app::apphubdetails { constexpr auto* TAG = "AppHubDetails"; -extern const AppManifest manifest; - -static std::shared_ptr toBundle(const apphub::AppHubEntry& entry) { - auto bundle = std::make_shared(); - bundle->putString("appId", entry.appId); - bundle->putString("appVersionName", entry.appVersionName); - bundle->putInt32("appVersionCode", entry.appVersionCode); - bundle->putString("appName", entry.appName); - bundle->putString("appDescription", entry.appDescription); - bundle->putString("targetSdk", entry.targetSdk); - bundle->putString("file", entry.file); - bundle->putString("targetPlatforms", string::join(entry.targetPlatforms, ",")); - return bundle; -} - -static bool fromBundle(const Bundle& bundle, apphub::AppHubEntry& entry) { - std::string target_platforms_string; - auto result = bundle.optString("appId", entry.appId) && - bundle.optString("appVersionName", entry.appVersionName) && - bundle.optInt32("appVersionCode", entry.appVersionCode) && - bundle.optString("appName", entry.appName) && - bundle.optString("appDescription", entry.appDescription) && - bundle.optString("targetSdk", entry.targetSdk) && - bundle.optString("file", entry.file) && - bundle.optString("targetPlatforms", target_platforms_string); - entry.targetPlatforms = string::split(target_platforms_string, ","); - return result; -} +extern const ::AppManifest manifest; -class AppHubDetailsApp final : public App { +namespace { - static constexpr auto* CONFIRM_TEXT = "Confirm"; - static constexpr auto* CANCEL_TEXT = "Cancel"; - static constexpr auto CONFIRMATION_BUTTON_INDEX = 0; - const std::vector CONFIRM_CANCEL_LABELS = { CONFIRM_TEXT, CANCEL_TEXT }; +constexpr auto* CONFIRM_TEXT = "Confirm"; +constexpr auto* CANCEL_TEXT = "Cancel"; +constexpr int32_t CONFIRMATION_BUTTON_INDEX = 0; +struct Context { + uint32_t appInstanceId; apphub::AppHubEntry entry; - std::shared_ptr entryManifest; + lv_obj_t* toolbar = nullptr; lv_obj_t* spinner = nullptr; lv_obj_t* updateButton = nullptr; lv_obj_t* updateLabel = nullptr; - LaunchId installLaunchId = -1; - LaunchId uninstallLaunchId = -1; - LaunchId updateLaunchId = -1; - LaunchId showConfirmDialog(const char* action) { - const auto message = std::format("{} {}?", action, entry.appName); - return alertdialog::start(CONFIRM_TEXT, message, CONFIRM_CANCEL_LABELS); - } + // Set from the LVGL task (button press), read from this app's own thread (event loop) - + // both directions cross threads, hence atomic. + std::atomic installDialogId = 0; + std::atomic uninstallDialogId = 0; + std::atomic updateDialogId = 0; +}; - static void onInstallPressed(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->installLaunchId = self->showConfirmDialog("Install"); - } - static void onUninstallPressed(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->uninstallLaunchId = self->showConfirmDialog("Uninstall"); - } +void updateViews(Context* ctx); - static void onUpdatePressed(lv_event_t* e) { - auto* self = static_cast(lv_event_get_user_data(e)); - self->updateLaunchId = self->showConfirmDialog("Update"); - } +uint32_t showConfirmDialog(Context* ctx, const char* action) { + const auto message = std::format("{} {}?", action, ctx->entry.appName); + return alertdialog::start(ctx->appInstanceId, CONFIRM_TEXT, message, std::vector { CONFIRM_TEXT, CANCEL_TEXT }); +} - void uninstallApp() { - LOG_I(TAG, "Uninstall"); +void onBackPressed(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - lvgl_lock(); - lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN); - lvgl_unlock(); +void onInstallPressed(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + ctx->installDialogId = showConfirmDialog(ctx, "Install"); +} - uninstall(entry.appId); +void onUninstallPressed(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + ctx->uninstallDialogId = showConfirmDialog(ctx, "Uninstall"); +} - lvgl_lock(); - updateViews(); - lvgl_unlock(); - } +void onUpdatePressed(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + ctx->updateDialogId = showConfirmDialog(ctx, "Update"); +} - void doInstall() { - auto url = apphub::getDownloadUrl(entry.file); - auto file_name = file::getLastPathSegment(entry.file); - auto temp_file_path = std::format("{}/{}", getTempPath(), file_name); - network::http::download( - url, - apphub::CERTIFICATE_PATH, - temp_file_path, - [this, temp_file_path] { - install(temp_file_path); - - if (!file::deleteFile(temp_file_path)) { - LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str()); - } else { - LOG_I(TAG, "Deleted temporary file %s", temp_file_path.c_str()); - } +void uninstallApp(Context* ctx) { + LOG_I(TAG, "Uninstall"); - lvgl_lock(); - updateViews(); - lvgl_unlock(); - }, - [temp_file_path](const char* errorMessage) { - LOG_E(TAG, "Download failed: %s", errorMessage); - alertdialog::start("Error", "Failed to install app"); + lvgl_lock(); + lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN); + lvgl_unlock(); - if (file::isFile(temp_file_path) && !file::deleteFile(temp_file_path.c_str())) { - LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str()); - } + app_uninstall(ctx->entry.appId.c_str()); + + lvgl_lock(); + updateViews(ctx); + lvgl_unlock(); +} + +void doInstall(Context* ctx) { + auto url = apphub::getDownloadUrl(ctx->entry.file); + auto file_name = file::getLastPathSegment(ctx->entry.file); + auto temp_file_path = std::format("{}/{}", getTempPath(), file_name); + network::http::download( + url, + apphub::CERTIFICATE_PATH, + temp_file_path, + [ctx, temp_file_path] { + app_install(temp_file_path.c_str()); + + if (!file::deleteFile(temp_file_path)) { + LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str()); + } else { + LOG_I(TAG, "Deleted temporary file %s", temp_file_path.c_str()); } - ); - } - void installApp() { - LOG_I(TAG, "Install"); + lvgl_lock(); + updateViews(ctx); + lvgl_unlock(); + }, + [ctx, temp_file_path](const char* errorMessage) { + LOG_E(TAG, "Download failed: %s", errorMessage); + alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app"); - lvgl_lock(); - lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN); - lvgl_unlock(); + if (file::isFile(temp_file_path) && !file::deleteFile(temp_file_path.c_str())) { + LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str()); + } + } + ); +} - doInstall(); - } +void installApp(Context* ctx) { + LOG_I(TAG, "Install"); - void updateApp() { - LOG_I(TAG, "Update"); + lvgl_lock(); + lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN); + lvgl_unlock(); - lvgl_lock(); - lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN); - lvgl_unlock(); + doInstall(ctx); +} - LOG_I(TAG, "Removing previous version"); - uninstall(entry.appId); - LOG_I(TAG, "Installing new version"); - doInstall(); - } +void updateApp(Context* ctx) { + LOG_I(TAG, "Update"); - void updateViews() { - lvgl_toolbar_clear_actions(toolbar); - const auto manifest = findAppManifestById(entry.appId); - spinner = lvgl_toolbar_add_spinner_action(toolbar); - lv_obj_add_flag(spinner, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(updateLabel, LV_OBJ_FLAG_HIDDEN); - if (manifest != nullptr) { - if (manifest->appVersionCode < entry.appVersionCode) { - updateButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, this); - lv_obj_remove_flag(updateLabel, LV_OBJ_FLAG_HIDDEN); - } - lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_TRASH, onUninstallPressed, this); - } else { - lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onInstallPressed, this); - } + lvgl_lock(); + lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN); + lvgl_unlock(); + + LOG_I(TAG, "Removing previous version"); + app_uninstall(ctx->entry.appId.c_str()); + LOG_I(TAG, "Installing new version"); + doInstall(ctx); +} + +void updateViews(Context* ctx) { + lvgl_toolbar_clear_actions(ctx->toolbar); + auto app_id = ctx->entry.appId.c_str(); + const auto manifest = app_manager_find_manifest(app_id); + ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar); + lv_obj_add_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN); + + char install_path[128]; + if (app_get_install_path(app_id, install_path, sizeof(install_path)) != ERROR_NONE) { + LOG_E(TAG, "Install path not found for %s", app_id); + return; } -public: + std::string metadata_path = std::string(install_path) + "/manifest.properties"; + AppMetadata metadata; + if (app_metadata_parse(metadata_path.c_str(), &metadata) != ERROR_NONE) { + LOG_E(TAG, "Failed to parse metadata at %s", metadata_path.c_str()); + return; + } - void onCreate(AppContext& appContext) override { - auto parameters = appContext.getParameters(); - if (parameters == nullptr) { - LOG_E(TAG, "No parameters"); - stop(); - return; + if (manifest != nullptr) { + if (metadata.app_version_code < ctx->entry.appVersionCode) { + ctx->updateButton = lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, ctx); + lv_obj_remove_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN); } + lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_TRASH, onUninstallPressed, ctx); + } else { + lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onInstallPressed, ctx); + } +} - if (!fromBundle(*parameters.get(), entry)) { - LOG_E(TAG, "Invalid parameters"); - stop(); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + ctx->toolbar = lvgl_toolbar_create(parent, ctx->entry.appName.c_str()); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + auto* wrapper = lv_obj_create(parent); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + + ctx->updateLabel = lv_label_create(wrapper); + lv_label_set_text(ctx->updateLabel, "Update available!"); + lv_obj_set_style_text_color(ctx->updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT); + + auto* description_label = lv_label_create(wrapper); + lv_obj_set_width(description_label, LV_PCT(100)); + lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP); + if (!ctx->entry.appDescription.empty()) { + std::string description = ctx->entry.appDescription; + for (size_t pos = 0; (pos = description.find("\\n", pos)) != std::string::npos;) { + description.replace(pos, 2, "\n"); } + lv_label_set_text(description_label, description.c_str()); + } else { + lv_label_set_text(description_label, "This app has no description yet."); } - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - toolbar = lvgl_toolbar_create(parent, entry.appName.c_str()); - auto* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - - updateLabel = lv_label_create(wrapper); - lv_label_set_text(updateLabel, "Update available!"); - lv_obj_set_style_text_color(updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT); - - auto* description_label = lv_label_create(wrapper); - lv_obj_set_width(description_label, LV_PCT(100)); - lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP); - if (!entry.appDescription.empty()) { - std::string description = entry.appDescription; - for (size_t pos = 0; (pos = description.find("\\n", pos)) != std::string::npos;) { - description.replace(pos, 2, "\n"); - } - lv_label_set_text(description_label, description.c_str()); - } else { - lv_label_set_text(description_label, "This app has no description yet."); - } + auto* version_label = lv_label_create(wrapper); + lv_label_set_text_fmt(version_label, "Version %s", ctx->entry.appVersionName.c_str()); - auto* version_label = lv_label_create(wrapper); - lv_label_set_text_fmt(version_label, "Version %s", entry.appVersionName.c_str()); + updateViews(ctx); +} - updateViews(); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + // argv layout: [0]=appId, [1]=appVersionName, [2]=appVersionCode, [3]=appName, + // [4]=appDescription, [5]=targetSdk, [6]=file, [7..argc)=targetPlatforms. + + Context ctx {}; + ctx.appInstanceId = appInstanceId; + if (argc >= 7) { + ctx.entry.appId = argv[0]; + ctx.entry.appVersionName = argv[1]; + ctx.entry.appVersionCode = static_cast(strtol(argv[2], nullptr, 10)); + ctx.entry.appName = argv[3]; + ctx.entry.appDescription = argv[4]; + ctx.entry.targetSdk = argv[5]; + ctx.entry.file = argv[6]; + for (int i = 7; i < argc; i++) { + ctx.entry.targetPlatforms.emplace_back(argv[i]); + } } - void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr resultData) override { - if (result != Result::Ok) { - return; - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - if (alertdialog::getResultIndex(*resultData.get()) != CONFIRMATION_BUTTON_INDEX) { - return; - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - if (launchId == installLaunchId) { - installApp(); - } else if (launchId == uninstallLaunchId) { - uninstallApp(); - } else if (launchId == updateLaunchId) { - updateApp(); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: { + bool confirmed = event.result.result == CONFIRMATION_BUTTON_INDEX; + if (event.result.launch_id == ctx.installDialogId && confirmed) { + installApp(&ctx); + } else if (event.result.launch_id == ctx.uninstallDialogId && confirmed) { + uninstallApp(&ctx); + } else if (event.result.launch_id == ctx.updateDialogId && confirmed) { + updateApp(&ctx); + } + app_manager_stop(event.result.launch_id); + break; + } + default: + break; } } -}; + + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace void start(const apphub::AppHubEntry& entry) { - const auto bundle = toBundle(entry); - app::start(manifest.appId, bundle); + // Fire-and-forget (parent_instance_id 0): AppHub's own multi-app browsing list isn't + // waiting on a result. targetPlatforms is variable-length, so it goes last in argv. + std::string versionCode = std::to_string(entry.appVersionCode); + std::vector argv { + entry.appId.c_str(), + entry.appVersionName.c_str(), + versionCode.c_str(), + entry.appName.c_str(), + entry.appDescription.c_str(), + entry.targetSdk.c_str(), + entry.file.c_str(), + }; + for (const auto& platform: entry.targetPlatforms) { + argv.push_back(platform.c_str()); + } + uint32_t instanceId = 0; + app_manager_start_for_result(manifest.id, /*parent_instance_id=*/0, static_cast(argv.size()), argv.data(), &instanceId); } -extern const AppManifest manifest = { - .appId = "AppHubDetails", - .appName = "App Details", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create, +extern const ::AppManifest manifest = { + .id = "AppHubDetails", + .name = "App Details", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } // namespace diff --git a/Tactility/Source/app/applist/AppList.cpp b/Tactility/Source/app/applist/AppList.cpp index 1957ea7d6..d0ea68901 100644 --- a/Tactility/Source/app/applist/AppList.cpp +++ b/Tactility/Source/app/applist/AppList.cpp @@ -1,63 +1,116 @@ -#include -#include -#include +#include +#include +#include + +#include #include #include +#include +#include #include #include +#include namespace tt::app::applist { -class AppListApp final : public App { +namespace { - static void onAppPressed(lv_event_t* e) { - const auto* manifest = static_cast(lv_event_get_user_data(e)); - start(manifest->appId); - } +uint32_t appListInstanceId = 0; - static void createAppWidget(const std::shared_ptr& manifest, lv_obj_t* list) { - const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR; - lv_obj_t* btn = lv_list_add_button(list, icon, manifest->appName.c_str()); - lv_obj_t* image = lv_obj_get_child(btn, 0); - lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN); - lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get()); - } +void onAppPressed(lv_event_t* e) { + // Fire-and-forget top-level navigation, same as Launcher's own app-launch buttons. + const auto* manifest = static_cast(lv_event_get_user_data(e)); + uint32_t instanceId = 0; + app_manager_start(manifest->id, &instanceId); +} + +void onBackPressed(lv_event_t*) { + // The global toolbar nav callback (ToolbarConfig.nav_action_callback, set once in + // Tactility.cpp) only knows how to stop old-model apps, so this new-model app overrides + // its own toolbar's nav action to close itself instead. Async, non-blocking - must NOT + // call app_manager_stop() directly here: that bound-waits (thread_join) for this app's + // own thread to finish, which needs the LVGL lock (window_manager_remove()) - but this + // callback runs ON the LVGL task, which would deadlock against itself. + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(appListInstanceId, &event); +} -public: +void createAppWidget(const ::AppManifest* manifest, lv_obj_t* list) { + // The new AppManifest has no per-app icon - use a shared generic one for every entry, + // same fallback the old model used for apps that didn't provide one. + lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, manifest->name); + lv_obj_t* image = lv_obj_get_child(btn, 0); + lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN); + lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(manifest)); +} - void onShow(AppContext& app, lv_obj_t* parent) override { - auto* toolbar = lvgl::toolbar_create(parent, app); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); +void collectManifest(const ::AppManifest* manifest, void* context) { + auto* manifests = static_cast*>(context); + manifests->push_back(manifest); +} - lv_obj_t* list = lv_list_create(parent); - lv_obj_set_width(list, LV_PCT(100)); - lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0); +void createWidgets(lv_obj_t* parent, void*) { + auto* toolbar = lvgl_toolbar_create(parent, "Apps"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - auto toolbar_height = lv_obj_get_height(toolbar); - auto parent_content_height = lv_obj_get_content_height(parent); - lv_obj_set_height(list, parent_content_height - toolbar_height); + lv_obj_t* list = lv_list_create(parent); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0); - auto manifests = getAppManifests(); - std::ranges::sort(manifests, SortAppManifestByName); + auto toolbar_height = lv_obj_get_height(toolbar); + auto parent_content_height = lv_obj_get_content_height(parent); + lv_obj_set_height(list, parent_content_height - toolbar_height); - for (const auto& manifest: manifests) { - bool is_valid_category = (manifest->appCategory == Category::User) || (manifest->appCategory == Category::System); - bool is_visible = (manifest->appFlags & AppManifest::Flags::Hidden) == 0u; - if (is_valid_category && is_visible) { - createAppWidget(manifest, list); - } + std::vector manifests; + app_manager_for_each_manifest(collectManifest, &manifests); + std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) { + return strcmp(a->name, b->name) < 0; + }); + + for (const auto* manifest: manifests) { + bool is_valid_category = (manifest->category == APP_CATEGORY_USER) || (manifest->category == APP_CATEGORY_SYSTEM); + if (is_valid_category && (manifest->flags & APP_MANIFEST_FLAG_HIDDEN) == 0) { + createAppWidget(manifest, list); } } -}; +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + appListInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr); + + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + return 0; +} + +} // namespace -extern const AppManifest manifest = { - .appId = "AppList", - .appName = "Apps", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create, +extern const ::AppManifest manifest = { + .id = "AppList", + .name = "Apps", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } // namespace diff --git a/Tactility/Source/app/appsettings/AppSettings.cpp b/Tactility/Source/app/appsettings/AppSettings.cpp index 0e25c72be..8f2c26bba 100644 --- a/Tactility/Source/app/appsettings/AppSettings.cpp +++ b/Tactility/Source/app/appsettings/AppSettings.cpp @@ -1,70 +1,130 @@ #include #include -#include #include -#include -#include +#include +#include +#include + +#include + +#include #include #include +#include +#include namespace tt::app::appsettings { -class AppSettingsApp final : public App { +extern const ::AppManifest manifest; - static void onAppPressed(lv_event_t* e) { - const auto* manifest = static_cast(lv_event_get_user_data(e)); - appdetails::start(manifest->appId); - } +namespace { - static void createAppWidget(const std::shared_ptr& manifest, lv_obj_t* list) { - const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR; - lv_obj_t* btn = lv_list_add_button(list, icon, manifest->appName.c_str()); - lv_obj_t* image = lv_obj_get_child(btn, 0); - lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN); - lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get()); - } +// Set by appMain() right before window_manager_create(), read by onBackPressed(). +uint32_t appSettingsInstanceId = 0; -public: +void onAppPressed(lv_event_t* e) { + const auto* target_manifest = static_cast(lv_event_get_user_data(e)); + appdetails::start(target_manifest->id); +} - void onShow(AppContext& app, lv_obj_t* parent) override { - auto* toolbar = lvgl_toolbar_create(parent, "Installed Apps"); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); +void onBackPressed(lv_event_t*) { + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(appSettingsInstanceId, &event); +} - lv_obj_t* list = lv_list_create(parent); - lv_obj_set_width(list, LV_PCT(100)); - lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0); +void createAppWidget(const ::AppManifest* target_manifest, lv_obj_t* list) { + // The new AppManifest has no per-app icon - use a shared generic one for every entry, same + // fallback AppList.cpp uses. + lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, target_manifest->name); + lv_obj_t* image = lv_obj_get_child(btn, 0); + lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN); + lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(target_manifest)); +} - auto toolbar_height = lv_obj_get_height(toolbar); - auto parent_content_height = lv_obj_get_content_height(parent); - lv_obj_set_height(list, parent_content_height - toolbar_height); +void collectManifest(const ::AppManifest* manifest, void* context) { + auto* manifests = static_cast*>(context); + manifests->push_back(manifest); +} - auto manifests = getAppManifests(); - std::ranges::sort(manifests, SortAppManifestByName); +void createWidgets(lv_obj_t* parent, void*) { + auto* toolbar = lvgl_toolbar_create(parent, "Installed Apps"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - size_t app_count = 0; - for (const auto& manifest: manifests) { - if (manifest->appLocation.isExternal()) { - app_count++; - createAppWidget(manifest, list); - } + lv_obj_t* list = lv_list_create(parent); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0); + + auto toolbar_height = lv_obj_get_height(toolbar); + auto parent_content_height = lv_obj_get_content_height(parent); + lv_obj_set_height(list, parent_content_height - toolbar_height); + + std::vector manifests; + app_manager_for_each_manifest(collectManifest, &manifests); + std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) { + return strcmp(a->name, b->name) < 0; + }); + + size_t app_count = 0; + for (const auto* target_manifest: manifests) { + if (target_manifest->location.type == APP_LOCATION_PATH) { + app_count++; + createAppWidget(target_manifest, list); } + } - if (app_count == 0) { - auto* no_apps_label = lv_label_create(parent); - lv_label_set_text(no_apps_label, "No apps installed"); - lv_obj_align(no_apps_label, LV_ALIGN_CENTER, 0, 0); + if (app_count == 0) { + auto* no_apps_label = lv_label_create(parent); + lv_label_set_text(no_apps_label, "No apps installed"); + lv_obj_align(no_apps_label, LV_ALIGN_CENTER, 0, 0); + } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + appSettingsInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "AppSettings", - .appName = "Apps", - .appIcon = LVGL_ICON_SHARED_APPS, - .appCategory = Category::Settings, - .createApp = create, + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "AppSettings", + .name = "Apps", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace diff --git a/Tactility/Source/app/apwebserver/ApWebServer.cpp b/Tactility/Source/app/apwebserver/ApWebServer.cpp index 02525f651..dd1087295 100644 --- a/Tactility/Source/app/apwebserver/ApWebServer.cpp +++ b/Tactility/Source/app/apwebserver/ApWebServer.cpp @@ -1,130 +1,186 @@ #ifdef ESP_PLATFORM #include -#include -#include #include #include +#include +#include +#include + +#include + #include +#include #include namespace tt::app::apwebserver { constexpr auto* TAG = "ApWebServerApp"; -class ApWebServerApp final : public App { +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; lv_obj_t* labelSsidValue = nullptr; lv_obj_t* labelPasswordValue = nullptr; lv_obj_t* labelIpValue = nullptr; - + bool webServerEnabledChanged = false; settings::webserver::WebServerSettings wsSettings; +}; + + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + + auto* toolbar = lvgl_toolbar_create(parent, "AP Web Server"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + lv_obj_t* wrapper = lv_obj_create(parent); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(wrapper, 4, LV_PART_MAIN); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t* labelSsid = lv_label_create(wrapper); + lv_label_set_text(labelSsid, "SSID:"); + lv_obj_set_style_text_color(labelSsid, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN); + + ctx->labelSsidValue = lv_label_create(wrapper); + lv_obj_set_style_text_align(ctx->labelSsidValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_width(ctx->labelSsidValue, LV_PCT(100)); + lv_label_set_long_mode(ctx->labelSsidValue, LV_LABEL_LONG_SCROLL); + lv_obj_set_style_margin_hor(ctx->labelSsidValue, 2, LV_PART_MAIN); + + lv_obj_t* labelPassword = lv_label_create(wrapper); + lv_label_set_text(labelPassword, "Pass:"); + lv_obj_set_style_text_color(labelPassword, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN); + + ctx->labelPasswordValue = lv_label_create(wrapper); + lv_obj_set_style_text_align(ctx->labelPasswordValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_width(ctx->labelPasswordValue, LV_PCT(100)); + lv_label_set_long_mode(ctx->labelPasswordValue, LV_LABEL_LONG_SCROLL); + lv_obj_set_style_margin_hor(ctx->labelPasswordValue, 2, LV_PART_MAIN); + + lv_obj_t* labelIp = lv_label_create(wrapper); + lv_label_set_text(labelIp, "IP:"); + lv_obj_set_style_text_color(labelIp, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN); + + ctx->labelIpValue = lv_label_create(wrapper); + lv_obj_set_style_text_align(ctx->labelIpValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_width(ctx->labelIpValue, LV_PCT(100)); + lv_label_set_long_mode(ctx->labelIpValue, LV_LABEL_LONG_SCROLL); + lv_obj_set_style_margin_hor(ctx->labelIpValue, 2, LV_PART_MAIN); + + // Start AP Mode and WebServer + settings::webserver::WebServerSettings apSettings = ctx->wsSettings; + apSettings.wifiMode = settings::webserver::WiFiMode::AccessPoint; + apSettings.webServerEnabled = true; + + if (apSettings.apSsid.empty()) { + apSettings.apSsid = settings::webserver::generateDefaultApSsid(); + } -public: - void onCreate(AppContext& app) override { - wsSettings = settings::webserver::loadOrGetDefault(); + // Generate password if it's an open network or if password is empty + if (apSettings.apOpenNetwork || apSettings.apPassword.empty()) { + apSettings.apPassword = settings::webserver::generateRandomCredential(12); + apSettings.apOpenNetwork = false; } - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - - lvgl::toolbar_create(parent, app); - - lv_obj_t* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN); - lv_obj_set_style_pad_row(wrapper, 4, LV_PART_MAIN); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - lv_obj_t* labelSsid = lv_label_create(wrapper); - lv_label_set_text(labelSsid, "SSID:"); - lv_obj_set_style_text_color(labelSsid, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN); - - labelSsidValue = lv_label_create(wrapper); - lv_obj_set_style_text_align(labelSsidValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); - lv_obj_set_width(labelSsidValue, LV_PCT(100)); - lv_label_set_long_mode(labelSsidValue, LV_LABEL_LONG_SCROLL); - lv_obj_set_style_margin_hor(labelSsidValue, 2, LV_PART_MAIN); - - lv_obj_t* labelPassword = lv_label_create(wrapper); - lv_label_set_text(labelPassword, "Pass:"); - lv_obj_set_style_text_color(labelPassword, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN); - - labelPasswordValue = lv_label_create(wrapper); - lv_obj_set_style_text_align(labelPasswordValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); - lv_obj_set_width(labelPasswordValue, LV_PCT(100)); - lv_label_set_long_mode(labelPasswordValue, LV_LABEL_LONG_SCROLL); - lv_obj_set_style_margin_hor(labelPasswordValue, 2, LV_PART_MAIN); - - lv_obj_t* labelIp = lv_label_create(wrapper); - lv_label_set_text(labelIp, "IP:"); - lv_obj_set_style_text_color(labelIp, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN); - - labelIpValue = lv_label_create(wrapper); - lv_obj_set_style_text_align(labelIpValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); - lv_obj_set_width(labelIpValue, LV_PCT(100)); - lv_label_set_long_mode(labelIpValue, LV_LABEL_LONG_SCROLL); - lv_obj_set_style_margin_hor(labelIpValue, 2, LV_PART_MAIN); - - // Start AP Mode and WebServer - settings::webserver::WebServerSettings apSettings = wsSettings; - apSettings.wifiMode = settings::webserver::WiFiMode::AccessPoint; - apSettings.webServerEnabled = true; - - if (apSettings.apSsid.empty()) { - apSettings.apSsid = settings::webserver::generateDefaultApSsid(); + lv_label_set_text(ctx->labelSsidValue, apSettings.apSsid.c_str()); + lv_label_set_text(ctx->labelPasswordValue, apSettings.apPassword.c_str()); + lv_label_set_text(ctx->labelIpValue, "192.168.4.1"); + + // Apply settings and start services + getMainDispatcher().dispatch([apSettings] { + if (!settings::webserver::save(apSettings)) { + LOG_E(TAG, "Failed to save AP settings"); + return; + } + service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); + service::webserver::setWebServerEnabled(true); + }); + ctx->webServerEnabledChanged = true; +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.wsSettings = settings::webserver::loadOrGetDefault(); + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } + } + + // Equivalent of the old model's onHide(): persist the ORIGINAL settings (as loaded at + // startup, not the temporary AP-mode config createWidgets() applied above) and revert the + // web server's enabled state accordingly. + const auto copy = ctx.wsSettings; + const bool webServerChanged = ctx.webServerEnabledChanged; - // Generate password if it's an open network or if password is empty - if (apSettings.apOpenNetwork || apSettings.apPassword.empty()) { - apSettings.apPassword = settings::webserver::generateRandomCredential(12); - apSettings.apOpenNetwork = false; + getMainDispatcher().dispatch([copy, webServerChanged] { + if (!settings::webserver::save(copy)) { + LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot"); } - lv_label_set_text(labelSsidValue, apSettings.apSsid.c_str()); - lv_label_set_text(labelPasswordValue, apSettings.apPassword.c_str()); - lv_label_set_text(labelIpValue, "192.168.4.1"); - - // Apply settings and start services - getMainDispatcher().dispatch([apSettings] { - if (!settings::webserver::save(apSettings)) { - LOG_E(TAG, "Failed to save AP settings"); - return; - } - service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); - service::webserver::setWebServerEnabled(true); - }); - webServerEnabledChanged = true; - } + service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); - void onHide(AppContext& app) override { - const auto copy = wsSettings; - const bool webServerChanged = webServerEnabledChanged; + if (webServerChanged) { + LOG_I(TAG, "WebServer %s", copy.webServerEnabled ? "enabling..." : "disabling..."); + service::webserver::setWebServerEnabled(copy.webServerEnabled); + } + }); - getMainDispatcher().dispatch([copy, webServerChanged] { - if (!settings::webserver::save(copy)) { - LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot"); - } + window_manager_remove(window); + app_event_unsubscribe(&sub); - service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); + return 0; +} - if (webServerChanged) { - LOG_I(TAG, "WebServer %s", copy.webServerEnabled ? "enabling..." : "disabling..."); - service::webserver::setWebServerEnabled(copy.webServerEnabled); - } - }); - } -}; +} // namespace -extern const AppManifest manifest = { - .appId = "ApWebServer", - .appName = "AP Web Server", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create +extern const ::AppManifest manifest = { + .id = "ApWebServer", + .name = "AP Web Server", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } // namespace tt::app::apwebserver diff --git a/Tactility/Source/app/audiosettings/AudioSettings.cpp b/Tactility/Source/app/audiosettings/AudioSettings.cpp index f5cbc8f37..237e3f009 100644 --- a/Tactility/Source/app/audiosettings/AudioSettings.cpp +++ b/Tactility/Source/app/audiosettings/AudioSettings.cpp @@ -1,17 +1,25 @@ #include #include -#include -#include #include -#include +#include +#include +#include + +#include + #include #include +#include namespace tt::app::audiosettings { -class AudioSettingsApp final : public App { +extern const ::AppManifest manifest; +namespace { + +struct Context { + uint32_t appInstanceId; PubSub::SubscriptionHandle audioSubscription = nullptr; lv_obj_t* inputEnabledSwitch = nullptr; @@ -21,195 +29,232 @@ class AudioSettingsApp final : public App { lv_obj_t* outputEnabledSwitch = nullptr; lv_obj_t* outputMuteSwitch = nullptr; lv_obj_t* outputVolumeSlider = nullptr; +}; - static void onInputEnabledSwitch(lv_event_t* event) { - auto* sw = static_cast(lv_event_get_target(event)); - bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); - service::audio::setInputEnabled(enabled); - } - static void onOutputEnabledSwitch(lv_event_t* event) { - auto* sw = static_cast(lv_event_get_target(event)); - bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); - service::audio::setOutputEnabled(enabled); +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void onInputEnabledSwitch(lv_event_t* event) { + auto* sw = static_cast(lv_event_get_target(event)); + bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); + service::audio::setInputEnabled(enabled); +} + +void onOutputEnabledSwitch(lv_event_t* event) { + auto* sw = static_cast(lv_event_get_target(event)); + bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); + service::audio::setOutputEnabled(enabled); +} + +void onInputMuteSwitch(lv_event_t* event) { + auto* sw = static_cast(lv_event_get_target(event)); + bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED); + service::audio::setInputMuted(muted); +} + +void onOutputMuteSwitch(lv_event_t* event) { + auto* sw = static_cast(lv_event_get_target(event)); + bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED); + service::audio::setOutputMuted(muted); +} + +void onInputVolumeSlider(lv_event_t* event) { + auto* sliderBox = static_cast(lv_event_get_target(event)); + float percent = static_cast(lvgl_sliderbox_get_value(sliderBox)); + service::audio::setInputVolume(percent); +} + +void onOutputVolumeSlider(lv_event_t* event) { + auto* sliderBox = static_cast(lv_event_get_target(event)); + float percent = static_cast(lvgl_sliderbox_get_value(sliderBox)); + service::audio::setOutputVolume(percent); +} + +lv_obj_t* createSection(lv_obj_t* parent, const char* title) { + auto* wrapper = lv_obj_create(parent); + lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_hor(wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + + auto* title_label = lv_label_create(wrapper); + lv_label_set_text(title_label, title); + + return wrapper; +} + +lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb, void* userData) { + auto* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + + auto* row_label = lv_label_create(row); + lv_label_set_text(row_label, label); + lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0); + + auto* sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData); + + return sw; +} + +lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialValue, lv_event_cb_t cb, void* userData) { + auto* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + + auto* row_label = lv_label_create(row); + lv_label_set_text(row_label, label); + lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0); + + auto* sliderBox = lvgl_sliderbox_create(row, 0, 100, 10, initialValue); + lv_obj_set_width(sliderBox, LV_PCT(50)); + lv_obj_align(sliderBox, LV_ALIGN_RIGHT_MID, 0, 0); + lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData); + + return sliderBox; +} + +void refresh(Context* ctx) { + if (ctx->inputEnabledSwitch) { + if (service::audio::isInputEnabled()) lv_obj_add_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED); + else lv_obj_remove_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED); } - - static void onInputMuteSwitch(lv_event_t* event) { - auto* sw = static_cast(lv_event_get_target(event)); - bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED); - service::audio::setInputMuted(muted); + if (ctx->inputMuteSwitch) { + if (service::audio::isInputMuted()) lv_obj_add_state(ctx->inputMuteSwitch, LV_STATE_CHECKED); + else lv_obj_remove_state(ctx->inputMuteSwitch, LV_STATE_CHECKED); } - - static void onOutputMuteSwitch(lv_event_t* event) { - auto* sw = static_cast(lv_event_get_target(event)); - bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED); - service::audio::setOutputMuted(muted); + if (ctx->inputVolumeSlider) { + lvgl_sliderbox_set_value(ctx->inputVolumeSlider, static_cast(service::audio::getInputVolume()), LV_ANIM_OFF); } - static void onInputVolumeSlider(lv_event_t* event) { - auto* sliderBox = static_cast(lv_event_get_target(event)); - float percent = static_cast(lvgl_sliderbox_get_value(sliderBox)); - service::audio::setInputVolume(percent); + if (ctx->outputEnabledSwitch) { + if (service::audio::isOutputEnabled()) lv_obj_add_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED); + else lv_obj_remove_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED); } - - static void onOutputVolumeSlider(lv_event_t* event) { - auto* sliderBox = static_cast(lv_event_get_target(event)); - float percent = static_cast(lvgl_sliderbox_get_value(sliderBox)); - service::audio::setOutputVolume(percent); + if (ctx->outputMuteSwitch) { + if (service::audio::isOutputMuted()) lv_obj_add_state(ctx->outputMuteSwitch, LV_STATE_CHECKED); + else lv_obj_remove_state(ctx->outputMuteSwitch, LV_STATE_CHECKED); } - - static lv_obj_t* createSection(lv_obj_t* parent, const char* title) { - auto* wrapper = lv_obj_create(parent); - lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_hor(wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); - - auto* title_label = lv_label_create(wrapper); - lv_label_set_text(title_label, title); - - return wrapper; + if (ctx->outputVolumeSlider) { + lvgl_sliderbox_set_value(ctx->outputVolumeSlider, static_cast(service::audio::getOutputVolume()), LV_ANIM_OFF); } +} - static lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb, void* userData) { - auto* row = lv_obj_create(parent); - lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); - - auto* row_label = lv_label_create(row); - lv_label_set_text(row_label, label); - lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - auto* sw = lv_switch_create(row); - lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - return sw; - } + auto* toolbar = lvgl_toolbar_create(parent, "Audio"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - static lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialValue, lv_event_cb_t cb, void* userData) { - auto* row = lv_obj_create(parent); - lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); - auto* row_label = lv_label_create(row); - lv_label_set_text(row_label, label); - lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0); + if (!service::audio::isAvailable()) { + auto* label = lv_label_create(main_wrapper); + lv_label_set_text(label, "No audio hardware available"); + lv_obj_center(label); + return; + } - auto* sliderBox = lvgl_sliderbox_create(row, 0, 100, 10, initialValue); - lv_obj_set_width(sliderBox, LV_PCT(50)); - lv_obj_align(sliderBox, LV_ALIGN_RIGHT_MID, 0, 0); - lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData); + // Gated per-direction, not just isAvailable() - a mic-only or speaker-only + // device (e.g. a dedicated input codec with no output codec bound) should + // only show the section it actually has, not a dead section for the other. + if (service::audio::isInputAvailable()) { + auto* input_section = createSection(main_wrapper, "Microphone"); + ctx->inputEnabledSwitch = createSwitchRow(input_section, "Enabled", onInputEnabledSwitch, ctx); + ctx->inputMuteSwitch = createSwitchRow(input_section, "Mute", onInputMuteSwitch, ctx); + ctx->inputVolumeSlider = createSliderRow(input_section, "Volume", static_cast(service::audio::getInputVolume()), onInputVolumeSlider, ctx); + } - return sliderBox; + if (service::audio::isOutputAvailable()) { + auto* output_section = createSection(main_wrapper, "Speaker"); + ctx->outputEnabledSwitch = createSwitchRow(output_section, "Enabled", onOutputEnabledSwitch, ctx); + ctx->outputMuteSwitch = createSwitchRow(output_section, "Mute", onOutputMuteSwitch, ctx); + ctx->outputVolumeSlider = createSliderRow(output_section, "Volume", static_cast(service::audio::getOutputVolume()), onOutputVolumeSlider, ctx); } -public: + // isAvailable() only reflects that the audio-stream device exists, not that any + // codec is actually bound to it (the stream device is constructed unconditionally + // at module-start time, before devicetree codecs exist, and binds lazily on first + // use) -- so a board with no input or output codec at all reaches here with both + // sections skipped above and would otherwise show an empty page. + if (!service::audio::isInputAvailable() && !service::audio::isOutputAvailable()) { + auto* label = lv_label_create(main_wrapper); + lv_label_set_text(label, "No supported audio controls"); + lv_obj_center(label); + } - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + refresh(ctx); - lvgl::toolbar_create(parent, app); + ctx->audioSubscription = service::audio::getPubsub()->subscribe([ctx](auto) { + lvgl_lock(); + refresh(ctx); + lvgl_unlock(); + }); +} - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; - if (!service::audio::isAvailable()) { - auto* label = lv_label_create(main_wrapper); - lv_label_set_text(label, "No audio hardware available"); - lv_obj_center(label); - return; - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - // Gated per-direction, not just isAvailable() - a mic-only or speaker-only - // device (e.g. a dedicated input codec with no output codec bound) should - // only show the section it actually has, not a dead section for the other. - if (service::audio::isInputAvailable()) { - auto* input_section = createSection(main_wrapper, "Microphone"); - inputEnabledSwitch = createSwitchRow(input_section, "Enabled", onInputEnabledSwitch, this); - inputMuteSwitch = createSwitchRow(input_section, "Mute", onInputMuteSwitch, this); - inputVolumeSlider = createSliderRow(input_section, "Volume", static_cast(service::audio::getInputVolume()), onInputVolumeSlider, this); - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - if (service::audio::isOutputAvailable()) { - auto* output_section = createSection(main_wrapper, "Speaker"); - outputEnabledSwitch = createSwitchRow(output_section, "Enabled", onOutputEnabledSwitch, this); - outputMuteSwitch = createSwitchRow(output_section, "Mute", onOutputMuteSwitch, this); - outputVolumeSlider = createSliderRow(output_section, "Volume", static_cast(service::audio::getOutputVolume()), onOutputVolumeSlider, this); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; } - - // isAvailable() only reflects that the audio-stream device exists, not that any - // codec is actually bound to it (the stream device is constructed unconditionally - // at module-start time, before devicetree codecs exist, and binds lazily on first - // use) -- so a board with no input or output codec at all reaches here with both - // sections skipped above and would otherwise show an empty page. - if (!service::audio::isInputAvailable() && !service::audio::isOutputAvailable()) { - auto* label = lv_label_create(main_wrapper); - lv_label_set_text(label, "No supported audio controls"); - lv_obj_center(label); + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } - - refresh(); - - audioSubscription = service::audio::getPubsub()->subscribe([this](auto) { - lvgl_lock(); - refresh(); - lvgl_unlock(); - }); } - void onHide(AppContext& app) override { - if (audioSubscription != nullptr) { - service::audio::getPubsub()->unsubscribe(audioSubscription); - audioSubscription = nullptr; - } - - inputEnabledSwitch = nullptr; - inputMuteSwitch = nullptr; - inputVolumeSlider = nullptr; - outputEnabledSwitch = nullptr; - outputMuteSwitch = nullptr; - outputVolumeSlider = nullptr; + if (ctx.audioSubscription != nullptr) { + service::audio::getPubsub()->unsubscribe(ctx.audioSubscription); + ctx.audioSubscription = nullptr; } - void refresh() const { - if (inputEnabledSwitch) { - if (service::audio::isInputEnabled()) lv_obj_add_state(inputEnabledSwitch, LV_STATE_CHECKED); - else lv_obj_remove_state(inputEnabledSwitch, LV_STATE_CHECKED); - } - if (inputMuteSwitch) { - if (service::audio::isInputMuted()) lv_obj_add_state(inputMuteSwitch, LV_STATE_CHECKED); - else lv_obj_remove_state(inputMuteSwitch, LV_STATE_CHECKED); - } - if (inputVolumeSlider) { - lvgl_sliderbox_set_value(inputVolumeSlider, static_cast(service::audio::getInputVolume()), LV_ANIM_OFF); - } + window_manager_remove(window); + app_event_unsubscribe(&sub); - if (outputEnabledSwitch) { - if (service::audio::isOutputEnabled()) lv_obj_add_state(outputEnabledSwitch, LV_STATE_CHECKED); - else lv_obj_remove_state(outputEnabledSwitch, LV_STATE_CHECKED); - } - if (outputMuteSwitch) { - if (service::audio::isOutputMuted()) lv_obj_add_state(outputMuteSwitch, LV_STATE_CHECKED); - else lv_obj_remove_state(outputMuteSwitch, LV_STATE_CHECKED); - } - if (outputVolumeSlider) { - lvgl_sliderbox_set_value(outputVolumeSlider, static_cast(service::audio::getOutputVolume()), LV_ANIM_OFF); - } - } -}; + return 0; +} + +} // namespace -extern const AppManifest manifest = { - .appId = "AudioSettings", - .appName = "Audio", - .appIcon = LVGL_ICON_SHARED_MUSIC_NOTE, - .appCategory = Category::Settings, - .createApp = create +extern const ::AppManifest manifest = { + .id = "AudioSettings", + .name = "Audio", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace tt::app::audiosettings diff --git a/Tactility/Source/app/boot/Boot.cpp b/Tactility/Source/app/boot/Boot.cpp index bc904d9b0..d3b681581 100644 --- a/Tactility/Source/app/boot/Boot.cpp +++ b/Tactility/Source/app/boot/Boot.cpp @@ -1,28 +1,30 @@ #include "tactility/system_event.h" - #include #include #include #include #include -#include +#include +#include +#include + +#include + +#include #include #include -#include -#include -#include #include #include #include -#include #include #include #include #include +#include #ifdef ESP_PLATFORM #include @@ -37,243 +39,293 @@ namespace tt::app::boot { constexpr auto* TAG = "Boot"; -extern const AppManifest manifest; - -class BootApp : public App { - - // Snapshot of hal::usb::isUsbBootMode(), taken before the boot thread starts and - // potentially clears the underlying flag via setupUsbBootMode()/resetUsbBootMode(). - // onShow() reads this instead of the live flag to avoid a race between the two. - static std::atomic 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 sdCardMissing; - - Thread thread = Thread( - "boot", - 5120, - [] { return bootThreadCallback(); }, - getCpuAffinityConfiguration().system - ); - - static void setupDisplay() { - Device* display = nullptr; - if (device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE) { - Device* backlight; - if (display_get_backlight(display, &backlight) == ERROR_NONE) { - if (!device_is_ready(backlight)) { - if (device_start(backlight) != ERROR_NONE) { - LOG_E(TAG, "Failed to start %s", backlight->name); - } - } +extern const ::AppManifest manifest; - settings::display::DisplaySettings settings; - if (settings::display::load(settings)) { - } else { - settings = settings::display::getDefault(); - } +namespace { + +// Snapshot of hal::usb::isUsbBootMode(), taken before boot work starts and potentially clears +// the underlying flag via setupUsbBootMode()/resetUsbBootMode(). createSplashWidgets() reads +// this instead of the live flag to avoid a race between the two. +std::atomic isUsbBootSplash = false; + +// Set when CONFIG_TT_USER_DATA_LOCATION_SD is defined but no SD card is mounted. Switches the +// window to an error screen and halts before starting the next app. +std::atomic sdCardMissing = false; + +uint32_t bootAppInstanceId = 0; +WindowId bootWindowId = 0; + +#ifdef ESP_PLATFORM +constexpr auto PARTITION_PREFIX = std::string("/"); +#else +constexpr auto PARTITION_PREFIX = std::string(""); +#endif - if (backlight_set_brightness(backlight, settings.backlightDuty) == ERROR_NONE) { - LOG_I(TAG, "Backlight for %s set to %d", display->name, settings.backlightDuty); - } else { - LOG_E(TAG, "Failed to set brightness of %s", backlight->name); +// Equivalent of AppPaths::getAssetsPath() for the internal "Boot" app id, without needing a +// live AppContext (which this app no longer has under the new app-module model). +std::string getBootAssetsPath(const std::string& childPath) { + return std::format("{}{}/app/Boot/assets/{}", PARTITION_PREFIX, file::SYSTEM_PARTITION_NAME, childPath); +} + +void setupDisplay() { + Device* display = nullptr; + if (device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE) { + Device* backlight; + if (display_get_backlight(display, &backlight) == ERROR_NONE) { + if (!device_is_ready(backlight)) { + if (device_start(backlight) != ERROR_NONE) { + LOG_E(TAG, "Failed to start %s", backlight->name); } + } + + settings::display::DisplaySettings settings; + if (settings::display::load(settings)) { } else { - LOG_I(TAG, "No backlight for %s", display->name); + settings = settings::display::getDefault(); + } + + if (backlight_set_brightness(backlight, settings.backlightDuty) == ERROR_NONE) { + LOG_I(TAG, "Backlight for %s set to %d", display->name, settings.backlightDuty); + } else { + LOG_E(TAG, "Failed to set brightness of %s", backlight->name); } - device_put(display); } else { - LOG_I(TAG, "No kernel display"); + LOG_I(TAG, "No backlight for %s", display->name); } + device_put(display); + } else { + LOG_I(TAG, "No kernel display"); } +} - static bool setupUsbBootMode() { - if (!hal::usb::isUsbBootMode()) { +bool setupUsbBootMode() { + if (!hal::usb::isUsbBootMode()) { + return false; + } + + LOG_I(TAG, "Rebooting into mass storage device mode"); + auto mode = hal::usb::getUsbBootMode(); // Get mode before reset + hal::usb::resetUsbBootMode(); + if (mode == hal::usb::BootMode::Flash) { + if (!hal::usb::startMassStorageWithFlash(true)) { + LOG_E(TAG, "Unable to start flash mass storage"); return false; } - - LOG_I(TAG, "Rebooting into mass storage device mode"); - auto mode = hal::usb::getUsbBootMode(); // Get mode before reset - hal::usb::resetUsbBootMode(); - if (mode == hal::usb::BootMode::Flash) { - if (!hal::usb::startMassStorageWithFlash(true)) { - LOG_E(TAG, "Unable to start flash mass storage"); - return false; - } - } else if (mode == hal::usb::BootMode::Sdmmc) { - if (!hal::usb::startMassStorageWithSdmmc(true)) { - LOG_E(TAG, "Unable to start SD mass storage"); - return false; - } + } else if (mode == hal::usb::BootMode::Sdmmc) { + if (!hal::usb::startMassStorageWithSdmmc(true)) { + LOG_E(TAG, "Unable to start SD mass storage"); + return false; } + } + + return true; +} - return true; +void waitForMinimalSplashDuration(TickType_t startTime) { + const auto end_time = get_ticks(); + const auto ticks_passed = end_time - startTime; + constexpr auto minimum_ticks = (CONFIG_TT_SPLASH_DURATION / portTICK_PERIOD_MS); + if (minimum_ticks > ticks_passed) { + delay_ticks(minimum_ticks - ticks_passed); } +} - static void waitForMinimalSplashDuration(TickType_t startTime) { - const auto end_time = get_ticks(); - const auto ticks_passed = end_time - startTime; - constexpr auto minimum_ticks = (CONFIG_TT_SPLASH_DURATION / portTICK_PERIOD_MS); - if (minimum_ticks > ticks_passed) { - delay_ticks(minimum_ticks - ticks_passed); - } +std::string getLauncherAppId() { + settings::BootSettings boot_properties; + // When boot.properties hasn't been overridden, return default + if (!settings::loadBootSettings(boot_properties)) { + return CONFIG_TT_LAUNCHER_APP_ID; } - static int32_t bootThreadCallback() { - LOG_I(TAG, "Starting boot thread"); - const auto start_time = get_ticks(); + // When boot properties didn't specify an override, return default + if (boot_properties.launcherAppId.empty()) { + LOG_E(TAG, "Failed to load launcher configuration, or launcher not configured"); + return CONFIG_TT_LAUNCHER_APP_ID; + } - // Give the UI some time to redraw - // If we don't do this, various init calls will read files and block SPI IO for the display - // This would result in a blank/black screen being shown during this phase of the boot process - // This works with 5 ms on a T-Lora Pager, so we give it 10 ms to be safe - delay_millis(10); + // If the app in the boot.properties does not exist, return default + if (app_manager_find_manifest(boot_properties.launcherAppId.c_str()) == nullptr) { + LOG_E(TAG, "Launcher app %s not found", boot_properties.launcherAppId.c_str()); + return CONFIG_TT_LAUNCHER_APP_ID; + } - // TODO: Support for multiple displays - LOG_I(TAG, "Setup display"); - setupDisplay(); - LOG_I(TAG, "Prepare file systems"); - prepareFileSystems(); + // The boot.properties launcher app id is valid + return boot_properties.launcherAppId; +} + +int getSmallestDimension() { + auto* display = lv_display_get_default(); + int width = lv_display_get_horizontal_resolution(display); + int height = lv_display_get_vertical_resolution(display); + return std::min(width, height); +} + +void createSplashWidgets(lv_obj_t* root, void*) { + lvgl::obj_set_style_bg_blacken(root); + lv_obj_set_style_border_width(root, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(root, 0, LV_STATE_DEFAULT); + + auto* image = lv_image_create(root); + lv_obj_set_size(image, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_align(image, LV_ALIGN_CENTER, 0, 0); + + const char* logo; + // TODO: Replace with automatic asset buckets like on Android + if (getSmallestDimension() < 150) { // e.g. Cardputer + logo = isUsbBootSplash ? "logo_usb.png" : "logo_small.png"; + } else { + logo = isUsbBootSplash ? "logo_usb.png" : "logo.png"; + } + const auto logo_path = lvgl::PATH_PREFIX + getBootAssetsPath(logo); + LOG_I(TAG, "%s", logo_path.c_str()); + lv_image_set_src(image, logo_path.c_str()); -#ifdef CONFIG_TT_USER_DATA_LOCATION_SD - std::string sd_path; - if (!findFirstMountedSdCardPath(sd_path)) { - LOG_E(TAG, "SD card not found"); - sdCardMissing = true; - } +#ifdef ESP_PLATFORM + if (isUsbBootSplash) { + auto* button = lv_button_create(root); + lv_obj_align(button, LV_ALIGN_BOTTOM_MID, 0, -16); + auto* label = lv_label_create(button); + lv_label_set_text(label, "Return to OS"); + lv_obj_add_event_cb(button, [](lv_event_t*) { + hal::usb::stop(); + esp_restart(); + }, LV_EVENT_SHORT_CLICKED, nullptr); + } #endif +} + +void createSdCardMissingWidgets(lv_obj_t* root, void*) { + lvgl::obj_set_style_bg_blacken(root); + lv_obj_set_style_border_width(root, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(root, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(root, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + auto* label = lv_label_create(root); + lv_label_set_text(label, "SD card not found.\nPlease insert one and reboot."); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); + lv_obj_set_style_text_color(label, lv_color_white(), LV_STATE_DEFAULT); + + auto* button = lv_button_create(root); + lv_obj_set_style_margin_top(button, 16, LV_STATE_DEFAULT); + auto* button_label = lv_label_create(button); + lv_label_set_text(button_label, "Reboot"); + lv_obj_add_event_cb(button, [](lv_event_t*) { +#ifdef ESP_PLATFORM + esp_restart(); +#endif + }, LV_EVENT_SHORT_CLICKED, nullptr); +} + +// Replaces the splash with a self-contained error screen (no dependency on the old alertdialog +// app - this app has no parent in the old App stack to deliver a result back to). +void showSdCardMissingScreen() { + if (bootWindowId != 0) { + window_manager_remove(bootWindowId); + } + bootWindowId = window_manager_create(bootAppInstanceId, createSdCardMissingWidgets, nullptr); +} - if (!setupUsbBootMode()) { - LOG_I(TAG, "initFromBootApp"); - registerApps(); - waitForMinimalSplashDuration(start_time); - // When SD card is missing, wait for dialog result - if (!sdCardMissing) stop(manifest.appId); - startNextApp(); - } - - // This event will likely block as other systems are initialized - // e.g. Wi-Fi reads AP configs from SD card - LOG_I(TAG, "Publish event"); - system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); - - return 0; +void startNextApp() { + if (sdCardMissing) { + showSdCardMissingScreen(); + return; } - static std::string getLauncherAppId() { - settings::BootSettings boot_properties; - // When boot.properties hasn't been overridden, return default - if (!settings::loadBootSettings(boot_properties)) { - return CONFIG_TT_LAUNCHER_APP_ID; - } +#ifdef ESP_PLATFORM + if (esp_reset_reason() == ESP_RST_PANIC) { + crashdiagnostics::start(); // fire-and-forget; no result expected back + return; + } +#endif - // When boot properties didn't specify an override, return default - if (boot_properties.launcherAppId.empty()) { - LOG_E(TAG, "Failed to load launcher configuration, or launcher not configured"); - return CONFIG_TT_LAUNCHER_APP_ID; - } + auto launcher_app_id = getLauncherAppId(); + uint32_t launcher_instance_id = 0; + app_manager_start(launcher_app_id.c_str(), &launcher_instance_id); +} - // If the app in the boot.properties does not exist, return default - if (findAppManifestById(boot_properties.launcherAppId) == nullptr) { - LOG_E(TAG, "Launcher app %s not found", boot_properties.launcherAppId.c_str()); - return CONFIG_TT_LAUNCHER_APP_ID; - } +void runBootSequence(TickType_t startTime) { + LOG_I(TAG, "Starting boot sequence"); - // The boot.properties launcher app id is valid - return boot_properties.launcherAppId; - } + // Give the UI some time to redraw + // If we don't do this, various init calls will read files and block SPI IO for the display + // This would result in a blank/black screen being shown during this phase of the boot process + // This works with 5 ms on a T-Lora Pager, so we give it 10 ms to be safe + delay_millis(10); - static void startNextApp() { - if (sdCardMissing) { - alertdialog::start("Error", "SD card not found.\nPlease insert one and reboot.", std::vector { "Reboot" }); - return; - } + // TODO: Support for multiple displays + LOG_I(TAG, "Setup display"); + setupDisplay(); + LOG_I(TAG, "Prepare file systems"); + prepareFileSystems(); -#ifdef ESP_PLATFORM - if (esp_reset_reason() == ESP_RST_PANIC) { - crashdiagnostics::start(); - return; - } -#endif - auto launcher_app_id = getLauncherAppId(); - start(launcher_app_id); +#ifdef CONFIG_TT_USER_DATA_LOCATION_SD + std::string sd_path; + if (!findFirstMountedSdCardPath(sd_path)) { + LOG_E(TAG, "SD card not found"); + sdCardMissing = true; } +#endif - static int getSmallestDimension() { - auto* display = lv_display_get_default(); - int width = lv_display_get_horizontal_resolution(display); - int height = lv_display_get_vertical_resolution(display); - return std::min(width, height); + if (!setupUsbBootMode()) { + LOG_I(TAG, "initFromBootApp"); + registerApps(); + waitForMinimalSplashDuration(startTime); + startNextApp(); } -public: + // This event will likely block as other systems are initialized + // e.g. Wi-Fi reads AP configs from SD card + LOG_I(TAG, "Publish event"); + system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0); +} - void onCreate(AppContext& app) override { - // Snapshot before the boot thread potentially clears the flag via setupUsbBootMode() - isUsbBootSplash = hal::usb::isUsbBootMode(); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + bootAppInstanceId = appInstanceId; + const auto start_time = get_ticks(); - // Just in case this app is somehow resumed - if (thread.getState() == Thread::State::Stopped) { - thread.start(); - } - } + // Snapshot before runBootSequence() potentially clears the flag via setupUsbBootMode() + isUsbBootSplash = hal::usb::isUsbBootMode(); + sdCardMissing = false; - void onDestroy(AppContext& app) override { - thread.join(); - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onResult(AppContext& /*app*/, LaunchId /*launchId*/, Result /*result*/, std::unique_ptr /*bundle*/) override { -#ifdef ESP_PLATFORM - esp_restart(); -#endif - } + bootWindowId = window_manager_create(appInstanceId, createSplashWidgets, nullptr); - 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); - lv_obj_set_style_radius(parent, 0, LV_STATE_DEFAULT); + runBootSequence(start_time); - auto* image = lv_image_create(parent); - lv_obj_set_size(image, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_align(image, LV_ALIGN_CENTER, 0, 0); - - const auto paths = app.getPaths(); - const char* logo; - // TODO: Replace with automatic asset buckets like on Android - if (getSmallestDimension() < 150) { // e.g. Cardputer - logo = isUsbBootSplash ? "logo_usb.png" : "logo_small.png"; - } else { - logo = isUsbBootSplash ? "logo_usb.png" : "logo.png"; + // Waits until app_manager_start(launcher) (or a permanent stop) tells us to give up - + // startNextApp() above is what triggers that, via app-module's "save the previously active + // app" policy, unless sdCardMissing halted before it. + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; } - const auto logo_path = lvgl::PATH_PREFIX + paths->getAssetsPath(logo); - LOG_I(TAG, "%s", logo_path.c_str()); - lv_image_set_src(image, logo_path.c_str()); - -#ifdef ESP_PLATFORM - if (isUsbBootSplash) { - auto* button = lv_button_create(parent); - lv_obj_align(button, LV_ALIGN_BOTTOM_MID, 0, -16); - auto* label = lv_label_create(button); - lv_label_set_text(label, "Return to OS"); - lv_obj_add_event_cb(button, [](lv_event_t*) { - hal::usb::stop(); - esp_restart(); - }, LV_EVENT_SHORT_CLICKED, nullptr); + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); + break; } -#endif } -}; -std::atomic BootApp::isUsbBootSplash = false; -std::atomic BootApp::sdCardMissing = false; + if (bootWindowId != 0) { + window_manager_remove(bootWindowId); + } + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace -extern const AppManifest manifest = { - .appId = "Boot", - .appName = "Boot", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::HideStatusBar | AppManifest::Flags::Hidden, - .createApp = create +extern const ::AppManifest manifest = { + .id = "Boot", + .name = "Boot", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } // namespace diff --git a/Tactility/Source/app/btmanage/BtManage.cpp b/Tactility/Source/app/btmanage/BtManage.cpp index 77f4524c1..4735fd984 100644 --- a/Tactility/Source/app/btmanage/BtManage.cpp +++ b/Tactility/Source/app/btmanage/BtManage.cpp @@ -4,20 +4,25 @@ #include #include -#include -#include -#include +#include +#include +#include + +#include + #include namespace tt::app::btmanage { constexpr auto* TAG = "BtManage"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; + -static void onBtToggled(bool requestOn) { +static void onBtToggled(void* context, bool requestOn) { #if defined(CONFIG_BT_NIMBLE_ENABLED) + auto* ctx = static_cast(context); Device* dev; if (device_get_first_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) { bool radio_on = bluetooth::isRadioOnOrPending(dev); @@ -25,17 +30,15 @@ static void onBtToggled(bool requestOn) { LOG_I(TAG, "Turning on"); if (bluetooth::start(dev)) { // The driver only allocates its callback list once the device is started, - // so the registration attempted in onShow() (while radio was off) was a + // so the registration attempted at startup (while radio was off) was a // no-op. Register again now that the device is actually up. - auto bt = std::static_pointer_cast(getCurrentApp()); - bt->registerDeviceCallback(dev); + registerDeviceCallback(ctx, dev); } } else if (!requestOn && radio_on) { LOG_I(TAG, "Turning off"); if (bluetooth::stop(dev)) { // A completed stop frees the driver's callback list. - auto bt = std::static_pointer_cast(getCurrentApp()); - bt->forgetCallbackRegistration(); + forgetCallbackRegistration(ctx); } } device_put(dev); @@ -46,7 +49,7 @@ static void onBtToggled(bool requestOn) { #endif } -static void onScanToggled(bool enabled) { +static void onScanToggled(void* /*context*/, bool enabled) { Device* dev; if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) != ERROR_NONE) { LOG_W(TAG, "Scan: No bluetooth device found"); @@ -70,7 +73,7 @@ static void onDisconnectPeer(const std::array& addr, int profileId) bluetooth::disconnect(addr, profileId); } -static void onPairPeer(const std::array& addr) { +static void onPairPeer(void* /*context*/, const std::array& addr) { // Clicking an unrecognised scan result initiates a HID host connection. // Bond exchange happens automatically during the first connection. bluetooth::hidHostConnect(addr); @@ -80,67 +83,48 @@ static void onForgetPeer(const std::array& addr) { bluetooth::unpair(addr); } -BtManage::BtManage() { - bindings = (Bindings) { - .onBtToggled = onBtToggled, - .onScanToggled = onScanToggled, - .onConnectPeer = onConnectPeer, - .onDisconnectPeer = onDisconnectPeer, - .onPairPeer = onPairPeer, - .onForgetPeer = onForgetPeer, - }; -} +static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event); -void BtManage::lock() { - mutex.lock(); -} - -void BtManage::unlock() { - mutex.unlock(); -} - -void BtManage::requestViewUpdate() { - // Lock order must match onShow()/onHide(): both run under GuiService's lvgl_lock() - // and then take `mutex` internally. Taking `mutex` before lvgl_lock() here would - // invert that order and deadlock against a concurrent onHide()/onShow() (GUI task - // holding LVGL lock, waiting on `mutex`; this task holding `mutex`, waiting on LVGL - // lock) - exactly what happens when BT events fire rapidly (e.g. during scanning) - // while the app is being hidden. +void requestViewUpdate(Context* ctx) { + // Lock order must match appMain()'s setup/teardown: both run under the LVGL lock + // and then take `ctx->mutex` internally. Taking `mutex` before lvgl_lock() here would + // invert that order and deadlock against a concurrent teardown (GUI task holding the + // LVGL lock, waiting on `mutex`; this task holding `mutex`, waiting on the LVGL lock) - + // exactly what happens when BT events fire rapidly (e.g. during scanning) while the app + // is closing. lvgl_lock(); - lock(); - if (isViewEnabled) { - view.update(); - } - unlock(); + ctx->lock(); + ctx->view.update(); + ctx->unlock(); lvgl_unlock(); } -void BtManage::onBtEvent(const BtEvent& event) { +void onBtEvent(Context* ctx, const BtEvent& event) { auto radio_state = bluetooth::getRadioState(); LOG_I(TAG, "Update with state %s", bluetooth::radioStateToString(radio_state)); - getState().setRadioState(radio_state); + ctx->state.setRadioState(radio_state); switch (event.type) { case BT_EVENT_SCAN_STARTED: - getState().setScanning(true); + ctx->state.setScanning(true); break; case BT_EVENT_SCAN_FINISHED: - getState().setScanning(false); - getState().updateScanResults(); - getState().updatePairedPeers(); + ctx->state.setScanning(false); + ctx->state.updateScanResults(); + ctx->state.updatePairedPeers(); break; case BT_EVENT_PEER_FOUND: - getState().updateScanResults(); + ctx->state.updateScanResults(); break; case BT_EVENT_PAIR_RESULT: - getState().updatePairedPeers(); + ctx->state.updatePairedPeers(); break; case BT_EVENT_PROFILE_STATE_CHANGED: - getState().updateScanResults(); - getState().updatePairedPeers(); + ctx->state.updateScanResults(); + ctx->state.updatePairedPeers(); break; case BT_EVENT_RADIO_STATE_CHANGED: if (event.radio_state == BT_RADIO_STATE_ON) { - getState().updatePairedPeers(); + ctx->state.updatePairedPeers(); Device* dev = nullptr; if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE && !bluetooth_is_scanning(dev)) { bluetooth_scan_start(dev); @@ -154,7 +138,7 @@ void BtManage::onBtEvent(const BtEvent& event) { break; } - requestViewUpdate(); + requestViewUpdate(ctx); } static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event) { @@ -163,65 +147,88 @@ static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event) { // task would block it on the LVGL mutex (held by the LVGL task waiting in // nimble_port_stop), creating a permanent deadlock. Dispatch to the main task so // the NimBLE host task is never blocked by BtManage's state updates or LVGL lock. - auto* self = static_cast(context); - // Captured while `self` is still guaranteed valid (the callback is only invoked - // while registered, i.e. before onHide() removes it). Comparing this later - without - // dereferencing `self` - lets the dispatched lambda detect a stale event from a - // session that has since been hidden (and possibly destroyed) without a UAF. - auto generation = self->getGeneration(); + auto* ctx = static_cast(context); + // Captured while `ctx` is still guaranteed valid (the callback is only invoked while + // registered, i.e. before appMain()'s cleanup removes it). Comparing this later - + // without dereferencing `ctx` - lets the dispatched lambda detect a stale event from an + // instance that has since closed (and had its Context destroyed) without a UAF: the + // generation bump in appMain()'s cleanup always happens before window_manager_remove() + // destroys ctx's widgets, and this dispatched lambda always re-reads the live generation + // at run time (not at dispatch time), so a bump landing anywhere before this lambda + // actually runs is enough to make it skip touching ctx. + auto generation = ctx->generation; int expectedGeneration = generation->load(); - getMainDispatcher().dispatch([self, generation, expectedGeneration, event] { + getMainDispatcher().dispatch([ctx, generation, expectedGeneration, event] { if (generation->load() != expectedGeneration) { return; } - self->onBtEvent(event); + onBtEvent(ctx, event); }); } -void BtManage::registerDeviceCallback(Device* dev) { - lock(); - if (btDevice == dev && !callbackRegistered) { +void registerDeviceCallback(Context* ctx, Device* dev) { + ctx->lock(); + if (ctx->btDevice == dev && !ctx->callbackRegistered) { // Only latch the flag on success: while the radio is off the driver has no // callback list yet, so this add is a silent no-op and must be retried once // bluetooth::start() actually brings the device up. - if (bluetooth_add_event_callback(dev, this, onKernelBtEvent) == ERROR_NONE) { - callbackRegistered = true; + if (bluetooth_add_event_callback(dev, ctx, onKernelBtEvent) == ERROR_NONE) { + ctx->callbackRegistered = true; } } - unlock(); + ctx->unlock(); } -void BtManage::forgetCallbackRegistration() { - lock(); - callbackRegistered = false; - unlock(); +void forgetCallbackRegistration(Context* ctx) { + ctx->lock(); + ctx->callbackRegistered = false; + ctx->unlock(); } -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()); +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + ctx->lock(); + ctx->view.init(ctx, parent); + ctx->view.update(); + ctx->unlock(); +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx; + ctx.appInstanceId = appInstanceId; + ctx.bindings = (Bindings) { + .onBtToggled = onBtToggled, + .onScanToggled = onScanToggled, + .onConnectPeer = onConnectPeer, + .onDisconnectPeer = onDisconnectPeer, + .onPairPeer = onPairPeer, + .onForgetPeer = onForgetPeer, + }; + + // Initialise state before subscribing to avoid incoming events racing with it. + ctx.state.setRadioState(bluetooth::getRadioState()); Device* dev = nullptr; device_get_first_by_type(&BLUETOOTH_TYPE, &dev); - state.setScanning(dev ? bluetooth_is_scanning(dev) : false); - state.updateScanResults(); - state.updatePairedPeers(); + ctx.state.setScanning(dev ? bluetooth_is_scanning(dev) : false); + ctx.state.updateScanResults(); + ctx.state.updatePairedPeers(); - lock(); - isViewEnabled = true; - view.init(app, parent); - view.update(); - unlock(); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - if (btDevice) { - // Decrease refcount before re-ssignment - device_put(btDevice); - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - btDevice = dev; - if (btDevice) { - registerDeviceCallback(btDevice); + ctx.btDevice = dev; + if (ctx.btDevice) { + registerDeviceCallback(&ctx, ctx.btDevice); } auto radio_state = bluetooth::getRadioState(); @@ -233,37 +240,53 @@ void BtManage::onShow(AppContext& app, lv_obj_t* parent) { if (can_scan && dev && !bluetooth_is_scanning(dev)) { bluetooth_scan_start(dev); } -} -void BtManage::onHide(AppContext& app) { - // Invalidate any BT event dispatched-but-not-yet-run for this session before doing - // anything else, so it can't race a subsequent destruction of this instance (see - // onKernelBtEvent()/getGeneration()). - generation->fetch_add(1); - - lock(); - if (btDevice) { - if (callbackRegistered) { - bluetooth_remove_event_callback(btDevice, onKernelBtEvent); - callbackRegistered = false; + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } - device_put(btDevice); - btDevice = nullptr; } - isViewEnabled = false; - unlock(); -} -extern const AppManifest manifest = { - .appId = "BtManage", - .appName = "Bluetooth", - .appIcon = LVGL_ICON_SHARED_BLUETOOTH, - .appCategory = Category::Settings, - .createApp = create -}; + // Invalidate any BT event dispatched-but-not-yet-run for this instance before doing + // anything else, so it can't race the teardown below (see onKernelBtEvent()). + ctx.generation->fetch_add(1); + + if (ctx.btDevice) { + if (ctx.callbackRegistered) { + bluetooth_remove_event_callback(ctx.btDevice, onKernelBtEvent); + ctx.callbackRegistered = false; + } + device_put(ctx.btDevice); + ctx.btDevice = nullptr; + } -LaunchId start() { - return app::start(manifest.appId); + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +uint32_t start() { + uint32_t instanceId = 0; + app_manager_start(manifest.id, &instanceId); + return instanceId; } +extern const ::AppManifest manifest = { + .id = "BtManage", + .name = "Bluetooth", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } +}; + } // namespace tt::app::btmanage diff --git a/Tactility/Source/app/btmanage/View.cpp b/Tactility/Source/app/btmanage/View.cpp index caf997789..bb16aaf57 100644 --- a/Tactility/Source/app/btmanage/View.cpp +++ b/Tactility/Source/app/btmanage/View.cpp @@ -13,13 +13,26 @@ #include #include +#include +#include + namespace tt::app::btmanage { +static void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + static void onEnableSwitchChanged(lv_event_t* event) { auto* enable_switch = static_cast(lv_event_get_target(event)); bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED); - auto bt = std::static_pointer_cast(getCurrentApp()); - bt->getBindings().onBtToggled(is_on); + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->bindings.onBtToggled(ctx, is_on); } static void onEnableOnBootSwitchChanged(lv_event_t* event) { @@ -45,39 +58,40 @@ static void onEnableOnBootParentClicked(lv_event_t* event) { } static void onScanButtonClicked(lv_event_t* event) { - auto bt = std::static_pointer_cast(getCurrentApp()); + auto* ctx = static_cast(lv_event_get_user_data(event)); Device* dev = nullptr; device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev); bool scanning = dev ? bluetooth_is_scanning(dev) : false; if (dev) { device_put(dev); } - bt->getBindings().onScanToggled(!scanning); + ctx->bindings.onScanToggled(ctx, !scanning); } // region Peer list callbacks struct PeerListItemData { + void* context; + State* state; + Bindings* bindings; size_t index; bool isPaired; }; void View::onConnect(lv_event_t* event) { auto* data = static_cast(lv_event_get_user_data(event)); - auto bt = std::static_pointer_cast(getCurrentApp()); - auto& state = bt->getState(); if (data->isPaired) { // Open the per-device settings screen for paired devices - auto peers = state.getPairedPeers(); + auto peers = data->state->getPairedPeers(); if (data->index < peers.size()) { btpeersettings::start(bluetooth::settings::addrToHex(peers[data->index].addr)); } } else { // Unrecognised scan result — initiate pairing - auto peers = state.getScanResults(); + auto peers = data->state->getScanResults(); if (data->index < peers.size()) { - bt->getBindings().onPairPeer(peers[data->index].addr); + data->bindings->onPairPeer(data->context, peers[data->index].addr); } } } @@ -102,7 +116,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 { index, isPaired }; + auto* item_data = new PeerListItemData { context, state, bindings, 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) { @@ -210,26 +224,28 @@ void View::updatePeerList() { lv_obj_set_style_margin_ver(scan_button, 4, LV_STATE_DEFAULT); auto* scan_label = lv_label_create(scan_button); lv_label_set_text(scan_label, state->isScanning() ? "Stop scan" : "Scan"); - lv_obj_add_event_cb(scan_button, onScanButtonClicked, LV_EVENT_SHORT_CLICKED, nullptr); + lv_obj_add_event_cb(scan_button, onScanButtonClicked, LV_EVENT_SHORT_CLICKED, context); } } // endregion Secondary updates -void View::init(const AppContext& app, lv_obj_t* parent) { +void View::init(void* newContext, lv_obj_t* parent) { + context = newContext; + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); root = parent; - paths = app.getPaths(); // Toolbar - auto* toolbar = lvgl::toolbar_create(parent, app); + auto* toolbar = lvgl_toolbar_create(parent, "Bluetooth"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, context); scanning_spinner = lvgl_toolbar_add_spinner_action(toolbar); enable_switch = lvgl_toolbar_add_switch_action(toolbar); - lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, context); // Peer list peers_list = lv_list_create(parent); diff --git a/Tactility/Source/app/btpeersettings/BtPeerSettings.cpp b/Tactility/Source/app/btpeersettings/BtPeerSettings.cpp index c78cf7b80..87c935ccd 100644 --- a/Tactility/Source/app/btpeersettings/BtPeerSettings.cpp +++ b/Tactility/Source/app/btpeersettings/BtPeerSettings.cpp @@ -3,15 +3,17 @@ #include #include -#include -#include -#include #include #include #include #include -#include +#include +#include +#include + +#include + #include #include #include @@ -20,211 +22,245 @@ namespace tt::app::btpeersettings { constexpr auto* TAG = "BtPeerSettings"; -extern const AppManifest manifest; - -void start(const std::string& addrHex) { - auto bundle = std::make_shared(); - bundle->putString("addr", addrHex); - app::start(manifest.appId, bundle); -} +extern const ::AppManifest manifest; -class BtPeerSettings : public App { +namespace { - bool viewEnabled = false; - lv_obj_t* connectButton = nullptr; - lv_obj_t* disconnectButton = nullptr; +struct Context { + uint32_t appInstanceId; std::string addrHex; std::array addr = {}; int profileId = BT_PROFILE_HID_HOST; - bool isCurrentlyConnected() const { - for (const auto& p : bluetooth::getPairedPeers()) { - if (p.addr == addr) return p.connected; - } - return false; - } - static void onPressConnect(lv_event_t* event) { - auto* self = static_cast(lv_event_get_user_data(event)); - if (self->profileId == BT_PROFILE_HID_HOST) { - bluetooth::hidHostConnect(self->addr); - } else { - bluetooth::connect(self->addr, self->profileId); - } - lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED); - } + lv_obj_t* connectButton = nullptr; + lv_obj_t* disconnectButton = nullptr; +}; - static void onPressDisconnect(lv_event_t* event) { - auto* self = static_cast(lv_event_get_user_data(event)); - if (self->profileId == BT_PROFILE_HID_HOST) { - bluetooth::hidHostDisconnect(); - } else { - bluetooth::disconnect(self->addr, self->profileId); - } - lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED); - } - static void onPressForget(lv_event_t* event) { - std::vector choices = { "Yes", "No" }; - alertdialog::start("Confirmation", "Forget this device?", choices); +bool isCurrentlyConnected(const Context* ctx) { + for (const auto& p : bluetooth::getPairedPeers()) { + if (p.addr == ctx->addr) return p.connected; } + return false; +} - static void onToggleAutoConnect(lv_event_t* event) { - auto* self = static_cast(lv_event_get_user_data(event)); - bool is_on = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED); - bluetooth::settings::PairedDevice device; - if (bluetooth::settings::load(self->addrHex, device)) { - device.autoConnect = is_on; - if (!bluetooth::settings::save(device)) { - LOG_E(TAG, "Failed to save auto-connect setting"); - } - } +void updateViews(const Context* ctx) { + if (isCurrentlyConnected(ctx)) { + lv_obj_remove_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_state(ctx->disconnectButton, LV_STATE_DISABLED); + } else { + lv_obj_add_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_state(ctx->connectButton, LV_STATE_DISABLED); } +} - void requestViewUpdate() const { - if (viewEnabled) { - lvgl_lock(); - updateViews(); - lvgl_unlock(); - } +void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent /*event*/) { + auto* ctx = static_cast(context); + lvgl_lock(); + updateViews(ctx); + lvgl_unlock(); +} + +void onPressConnect(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (ctx->profileId == BT_PROFILE_HID_HOST) { + bluetooth::hidHostConnect(ctx->addr); + } else { + bluetooth::connect(ctx->addr, ctx->profileId); } + lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED); +} - void updateViews() const { - if (isCurrentlyConnected()) { - lv_obj_remove_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(connectButton, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_state(disconnectButton, LV_STATE_DISABLED); - } else { - lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_flag(connectButton, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_state(connectButton, LV_STATE_DISABLED); - } +void onPressDisconnect(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (ctx->profileId == BT_PROFILE_HID_HOST) { + bluetooth::hidHostDisconnect(); + } else { + bluetooth::disconnect(ctx->addr, ctx->profileId); } + lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED); +} -public: +void onPressForget(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Result isn't tracked by launch id (matches the original's behavior) - this app only + // ever has one dialog in flight at a time. + alertdialog::start(ctx->appInstanceId, "Confirmation", "Forget this device?", std::vector { "Yes", "No" }); +} - void onCreate(AppContext& app) override { - const auto parameters = app.getParameters(); - check(parameters != nullptr, "Parameters missing"); - addrHex = parameters->getString("addr"); +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - // Load addr and profileId from stored settings — avoids manual hex parsing - // (std::stoul throws on invalid input and exceptions are disabled). - bluetooth::settings::PairedDevice device; - if (bluetooth::settings::load(addrHex, device)) { - addr = device.addr; - profileId = device.profileId; +void onToggleAutoConnect(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + bool is_on = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED); + bluetooth::settings::PairedDevice device; + if (bluetooth::settings::load(ctx->addrHex, device)) { + device.autoConnect = is_on; + if (!bluetooth::settings::save(device)) { + LOG_E(TAG, "Failed to save auto-connect setting"); } } +} - static void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent /*event*/) { - static_cast(context)->requestViewUpdate(); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + bluetooth::settings::PairedDevice device; + bool deviceLoaded = bluetooth::settings::load(ctx->addrHex, device); + std::string title = (deviceLoaded && !device.name.empty()) ? device.name : ctx->addrHex; + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, title.c_str()); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lvgl::obj_set_style_bg_invisible(wrapper); + + ctx->connectButton = lv_button_create(wrapper); + lv_obj_set_width(ctx->connectButton, LV_PCT(100)); + lv_obj_add_event_cb(ctx->connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, ctx); + auto* connect_label = lv_label_create(ctx->connectButton); + lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(connect_label, "Connect"); + + ctx->disconnectButton = lv_button_create(wrapper); + lv_obj_set_width(ctx->disconnectButton, LV_PCT(100)); + lv_obj_add_event_cb(ctx->disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, ctx); + auto* disconnect_label = lv_label_create(ctx->disconnectButton); + lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(disconnect_label, "Disconnect"); + + auto* forget_button = lv_button_create(wrapper); + lv_obj_set_width(forget_button, LV_PCT(100)); + lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, ctx); + auto* forget_label = lv_label_create(forget_button); + lv_obj_align(forget_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(forget_label, "Forget"); + + // Auto-connect toggle row + auto* auto_connect_wrapper = lv_obj_create(wrapper); + lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lvgl::obj_set_style_bg_invisible(auto_connect_wrapper); + lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT); + + auto* auto_connect_label = lv_label_create(auto_connect_wrapper); + lv_label_set_text(auto_connect_label, "Auto-connect"); + lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0); + + auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper); + lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx); + lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0); + + if (deviceLoaded && device.autoConnect) { + lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED); } - void onShow(AppContext& app, lv_obj_t* parent) override { - { - Device* dev; - if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) { - bluetooth_add_event_callback(dev, this, onKernelBtEvent); - device_put(dev); - } - } + updateViews(ctx); +} - // Load stored settings (name, autoConnect) - bluetooth::settings::PairedDevice device; - bool deviceLoaded = bluetooth::settings::load(addrHex, device); - std::string title = (deviceLoaded && !device.name.empty()) ? device.name : addrHex; - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - lvgl_toolbar_create(parent, title.c_str()); - - auto* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); - lvgl::obj_set_style_bg_invisible(wrapper); - - connectButton = lv_button_create(wrapper); - lv_obj_set_width(connectButton, LV_PCT(100)); - lv_obj_add_event_cb(connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, this); - auto* connect_label = lv_label_create(connectButton); - lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(connect_label, "Connect"); - - disconnectButton = lv_button_create(wrapper); - lv_obj_set_width(disconnectButton, LV_PCT(100)); - lv_obj_add_event_cb(disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, this); - auto* disconnect_label = lv_label_create(disconnectButton); - lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(disconnect_label, "Disconnect"); - - auto* forget_button = lv_button_create(wrapper); - lv_obj_set_width(forget_button, LV_PCT(100)); - lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, this); - auto* forget_label = lv_label_create(forget_button); - lv_obj_align(forget_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(forget_label, "Forget"); - - // Auto-connect toggle row - auto* auto_connect_wrapper = lv_obj_create(wrapper); - lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lvgl::obj_set_style_bg_invisible(auto_connect_wrapper); - lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT); - - auto* auto_connect_label = lv_label_create(auto_connect_wrapper); - lv_label_set_text(auto_connect_label, "Auto-connect"); - lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0); - - auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper); - lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, this); - lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0); - - if (deviceLoaded && device.autoConnect) { - lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED); - } else { - lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED); - } +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.addrHex = (argc > 0) ? argv[0] : std::string(); - viewEnabled = true; - updateViews(); + // Load addr and profileId from stored settings - avoids manual hex parsing (std::stoul + // throws on invalid input and exceptions are disabled). + bluetooth::settings::PairedDevice device; + if (bluetooth::settings::load(ctx.addrHex, device)) { + ctx.addr = device.addr; + ctx.profileId = device.profileId; } - void onHide(AppContext& app) override { - Device* dev; - if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) { - bluetooth_remove_event_callback(dev, onKernelBtEvent); - device_put(dev); - } - viewEnabled = false; + + Device* btDevice = nullptr; + if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &btDevice) == ERROR_NONE) { + bluetooth_add_event_callback(btDevice, &ctx, onKernelBtEvent); + device_put(btDevice); } - void onResult(AppContext& appContext, LaunchId /*launchId*/, Result result, std::unique_ptr bundle) override { - if (result != Result::Ok || bundle == nullptr) return; - if (alertdialog::getResultIndex(*bundle) != 0) return; // 0 = Yes - - // Disconnect first if connected - if (isCurrentlyConnected()) { - if (profileId == BT_PROFILE_HID_HOST) { - bluetooth::hidHostDisconnect(); - } else { - bluetooth::disconnect(addr, profileId); - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + if (event.result.result == 0) { // 0 = Yes + if (isCurrentlyConnected(&ctx)) { + if (ctx.profileId == BT_PROFILE_HID_HOST) { + bluetooth::hidHostDisconnect(); + } else { + bluetooth::disconnect(ctx.addr, ctx.profileId); + } + } + bluetooth::unpair(ctx.addr); + app_manager_finish(appInstanceId); + shouldClose = true; + } + app_manager_stop(event.result.launch_id); + break; + default: + break; } + } - bluetooth::unpair(addr); - stop(); + if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &btDevice) == ERROR_NONE) { + bluetooth_remove_event_callback(btDevice, onKernelBtEvent); + device_put(btDevice); } -}; -extern const AppManifest manifest = { - .appId = "BtPeerSettings", - .appName = "BT Device Settings", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +void start(const std::string& addrHex) { + const char* argv[] = { addrHex.c_str() }; + uint32_t instanceId = 0; + app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId); +} + +extern const ::AppManifest manifest = { + .id = "BtPeerSettings", + .name = "BT Device Settings", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } // namespace tt::app::btpeersettings diff --git a/Tactility/Source/app/chat/ChatApp.cpp b/Tactility/Source/app/chat/ChatApp.cpp index e06a845dc..722662c38 100644 --- a/Tactility/Source/app/chat/ChatApp.cpp +++ b/Tactility/Source/app/chat/ChatApp.cpp @@ -6,11 +6,15 @@ #include #include -#include + +#include +#include +#include + +#include #include -#include #include #include @@ -20,56 +24,34 @@ namespace tt::app::chat { +extern const ::AppManifest manifest; + constexpr auto* TAG = "ChatApp"; static constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; -void ChatApp::enableEspNow() { +void enableEspNow(Context* ctx) { static uint8_t defaultKey[ESP_NOW_KEY_LEN] = {}; auto config = service::espnow::EspNowConfig( - settings.hasEncryptionKey ? settings.encryptionKey.data() : defaultKey, + ctx->settings.hasEncryptionKey ? ctx->settings.encryptionKey.data() : defaultKey, service::espnow::Mode::Station, 1, // Channel 1 default; actual channel determined by WiFi if connected false, - settings.hasEncryptionKey + ctx->settings.hasEncryptionKey ); service::espnow::enable(config); } -void ChatApp::disableEspNow() { +void disableEspNow(Context* ctx) { + (void)ctx; if (service::espnow::isEnabled()) { service::espnow::disable(); } } -void ChatApp::onCreate(AppContext& appContext) { - isFirstLaunch = !settingsFileExists(); - settings = loadSettings(); - state.setLocalNickname(settings.nickname); - if (!settings.chatChannel.empty()) { - state.setCurrentChannel(settings.chatChannel); - } - enableEspNow(); +namespace { - receiveSubscription = service::espnow::subscribeReceiver( - [this](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { - onReceive(receiveInfo, data, length); - } - ); -} - -void ChatApp::onDestroy(AppContext& appContext) { - service::espnow::unsubscribeReceiver(receiveSubscription); - disableEspNow(); -} -void ChatApp::onShow(AppContext& context, lv_obj_t* parent) { - view.init(context, parent); - if (isFirstLaunch) { - view.showSettings(settings); - } -} - -void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { +void onReceive(Context* ctx, const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { if (length <= 0) return; ParsedMessage parsed; @@ -82,21 +64,31 @@ void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* d msg.target = parsed.target; msg.isOwn = false; - state.addMessage(msg); + ctx->state.addMessage(msg); lvgl_lock(); - view.displayMessage(msg); + ctx->view.displayMessage(msg); lvgl_unlock(); } -void ChatApp::sendMessage(const std::string& text) { +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + ctx->view.init(parent); + if (ctx->isFirstLaunch) { + ctx->view.showSettings(ctx->settings); + } +} + +} // namespace + +void sendMessage(Context* ctx, const std::string& text) { if (text.empty()) return; - std::string nickname = state.getLocalNickname(); - std::string channel = state.getCurrentChannel(); + std::string nickname = ctx->state.getLocalNickname(); + std::string channel = ctx->state.getCurrentChannel(); std::vector wireMsg; - if (!serializeTextMessage(settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) { + if (!serializeTextMessage(ctx->settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) { LOG_E(TAG, "Failed to serialize message"); return; } @@ -111,18 +103,18 @@ void ChatApp::sendMessage(const std::string& text) { msg.target = channel; msg.isOwn = true; - state.addMessage(msg); + ctx->state.addMessage(msg); lvgl_lock(); - view.displayMessage(msg); + ctx->view.displayMessage(msg); lvgl_unlock(); } -void ChatApp::applySettings(const std::string& nickname, const std::string& keyHex) { +void applySettings(Context* ctx, const std::string& nickname, const std::string& keyHex) { bool needRestart = false; // Trim nickname to protocol limit - settings.nickname = nickname.substr(0, MAX_NICKNAME_LEN); + ctx->settings.nickname = nickname.substr(0, MAX_NICKNAME_LEN); // Parse hex key if (keyHex.size() == ESP_NOW_KEY_LEN * 2) { @@ -134,50 +126,103 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH newKey[i] = static_cast(strtoul(hex, nullptr, 16)); } // Restart if key changed OR if encryption is being enabled - bool wasEnabled = settings.hasEncryptionKey; - if (!wasEnabled || !std::equal(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin())) { - std::copy(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin()); + bool wasEnabled = ctx->settings.hasEncryptionKey; + if (!wasEnabled || !std::equal(newKey, newKey + ESP_NOW_KEY_LEN, ctx->settings.encryptionKey.begin())) { + std::copy(newKey, newKey + ESP_NOW_KEY_LEN, ctx->settings.encryptionKey.begin()); needRestart = true; } - settings.hasEncryptionKey = true; + ctx->settings.hasEncryptionKey = true; } else { LOG_W(TAG, "Invalid hex characters in encryption key"); } } else if (keyHex.empty()) { - if (settings.hasEncryptionKey) { - settings.encryptionKey.fill(0); - settings.hasEncryptionKey = false; + if (ctx->settings.hasEncryptionKey) { + ctx->settings.encryptionKey.fill(0); + ctx->settings.hasEncryptionKey = false; needRestart = true; } } else { LOG_W(TAG, "Key must be exactly %d hex characters, got %d", (int)(ESP_NOW_KEY_LEN * 2), (int)keyHex.size()); } - state.setLocalNickname(settings.nickname); - saveSettings(settings); + ctx->state.setLocalNickname(ctx->settings.nickname); + saveSettings(ctx->settings); if (needRestart) { - disableEspNow(); - enableEspNow(); + disableEspNow(ctx); + enableEspNow(ctx); } } -void ChatApp::switchChannel(const std::string& chatChannel) { +void switchChannel(Context* ctx, const std::string& chatChannel) { const auto trimmedChannel = chatChannel.substr(0, MAX_TARGET_LEN); - state.setCurrentChannel(trimmedChannel); - settings.chatChannel = trimmedChannel; - saveSettings(settings); + ctx->state.setCurrentChannel(trimmedChannel); + ctx->settings.chatChannel = trimmedChannel; + saveSettings(ctx->settings); lvgl_lock(); - view.refreshMessageList(); + ctx->view.refreshMessageList(); lvgl_unlock(); } -extern const AppManifest manifest = { - .appId = "Chat", - .appName = "Chat", - .appIcon = LVGL_ICON_SHARED_FORUM, - .createApp = create +namespace { + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.isFirstLaunch = !settingsFileExists(); + ctx.settings = loadSettings(); + ctx.state.setLocalNickname(ctx.settings.nickname); + if (!ctx.settings.chatChannel.empty()) { + ctx.state.setCurrentChannel(ctx.settings.chatChannel); + } + enableEspNow(&ctx); + + ctx.receiveSubscription = service::espnow::subscribeReceiver( + [&ctx](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) { + onReceive(&ctx, receiveInfo, data, length); + } + ); + + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + + service::espnow::unsubscribeReceiver(ctx.receiveSubscription); + disableEspNow(&ctx); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "Chat", + .name = "Chat", + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace tt::app::chat diff --git a/Tactility/Source/app/chat/ChatView.cpp b/Tactility/Source/app/chat/ChatView.cpp index b2a01b678..9fcffd6e7 100644 --- a/Tactility/Source/app/chat/ChatView.cpp +++ b/Tactility/Source/app/chat/ChatView.cpp @@ -8,7 +8,9 @@ #include #include -#include +#include + +#include #include #include @@ -144,11 +146,23 @@ void ChatView::createChannelPanel(lv_obj_t* parent) { lv_label_set_text(cancelLbl, "Cancel"); } -void ChatView::init(AppContext& appContext, lv_obj_t* parent) { +void ChatView::onBackPressed(lv_event_t* e) { + auto* self = static_cast(lv_event_get_user_data(e)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(self->app->appInstanceId, &closeEvent); +} + +void ChatView::init(lv_obj_t* parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - toolbar = lvgl::toolbar_create(parent, appContext); + toolbar = lvgl_toolbar_create(parent, "Chat"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, this); lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onChannelClicked, this); lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsClicked, this); updateToolbarTitle(); @@ -245,14 +259,14 @@ void ChatView::onSendClicked(lv_event_t* e) { auto* self = static_cast(lv_event_get_user_data(e)); auto* text = lv_textarea_get_text(self->inputField); if (text && strlen(text) > 0) { - self->app->sendMessage(std::string(text)); + sendMessage(self->app, std::string(text)); lv_textarea_set_text(self->inputField, ""); } } void ChatView::onSettingsClicked(lv_event_t* e) { auto* self = static_cast(lv_event_get_user_data(e)); - self->showSettings(self->app->getSettings()); + self->showSettings(self->app->settings); } void ChatView::onSettingsSave(lv_event_t* e) { @@ -262,7 +276,8 @@ void ChatView::onSettingsSave(lv_event_t* e) { auto* keyHex = lv_textarea_get_text(self->keyInput); if (nickname && strlen(nickname) > 0) { - self->app->applySettings( + applySettings( + self->app, std::string(nickname), keyHex ? std::string(keyHex) : std::string() ); @@ -284,7 +299,7 @@ void ChatView::onChannelSave(lv_event_t* e) { auto* self = static_cast(lv_event_get_user_data(e)); auto* text = lv_textarea_get_text(self->channelInput); if (text && strlen(text) > 0) { - self->app->switchChannel(std::string(text)); + switchChannel(self->app, std::string(text)); } self->hideChannelSelector(); } diff --git a/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp b/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp index 3f7fdc4d4..1cb6a5371 100644 --- a/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp +++ b/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp @@ -4,137 +4,208 @@ #include #include #include -#include + +#include +#include +#include + +#include #include #include #include #include +#include + namespace tt::app::crashdiagnostics { constexpr auto* TAG = "CrashDiagnostics"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; + // Set when widget creation hit an unrecoverable error (e.g. the QR code doesn't fit on + // screen) - appMain() skips the event loop and closes immediately without ever starting + // the launcher, matching the old model's stop()-without-launcher-start() error paths. + bool hasFatalError = false; + // Set by onContinuePressed() right before it emits APP_EVENT_CLOSE - read by appMain() + // after its own thread finishes cleanup, to decide whether to start the launcher + // afterwards (matches the old model's onContinuePressed(): stop() then launcher::start()). + bool continuePressed = false; +}; + void onContinuePressed(lv_event_t* event) { - stop(manifest.appId); - launcher::start(); + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->continuePressed = true; + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. launcher::start() is deferred to appMain(), after this app's + // own thread has finished cleaning up. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); } -class CrashDiagnosticsApp : public App { +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); -public: + auto* display = lv_obj_get_display(parent); + int32_t parent_height = lv_display_get_vertical_resolution(display) - lvgl::statusbar_get_height(); - void onShow(AppContext& app, lv_obj_t* parent) override { - auto* display = lv_obj_get_display(parent); - int32_t parent_height = lv_display_get_vertical_resolution(display) - lvgl::statusbar_get_height(); + lv_obj_add_event_cb(parent, onContinuePressed, LV_EVENT_SHORT_CLICKED, ctx); + auto* top_label = lv_label_create(parent); + lv_label_set_text(top_label, "Oops! We've crashed ..."); // TODO: Funny messages + lv_obj_align(top_label, LV_ALIGN_TOP_MID, 0, 2); - lv_obj_add_event_cb(parent, onContinuePressed, LV_EVENT_SHORT_CLICKED, nullptr); - auto* top_label = lv_label_create(parent); - lv_label_set_text(top_label, "Oops! We've crashed ..."); // TODO: Funny messages - lv_obj_align(top_label, LV_ALIGN_TOP_MID, 0, 2); + auto* bottom_label = lv_label_create(parent); + if (device_has_active_by_type(&POINTER_TYPE)) { + lv_label_set_text(bottom_label, "Tap screen to continue"); + } else { + lv_label_set_text(bottom_label, "Reboot device to continue"); + } + lv_obj_align(bottom_label, LV_ALIGN_BOTTOM_MID, 0, -2); - auto* bottom_label = lv_label_create(parent); - if (device_has_active_by_type(&POINTER_TYPE)) { - lv_label_set_text(bottom_label, "Tap screen to continue"); - } else { - lv_label_set_text(bottom_label, "Reboot device to continue"); - } - lv_obj_align(bottom_label, LV_ALIGN_BOTTOM_MID, 0, -2); + std::string url = getUrlFromCrashData(); + LOG_I(TAG, "%s", url.c_str()); + size_t url_length = url.length(); - std::string url = getUrlFromCrashData(); - LOG_I(TAG, "%s", url.c_str()); - size_t url_length = url.length(); + int qr_version; + if (!getQrVersionForBinaryDataLength(url_length, qr_version)) { + LOG_E(TAG, "QR is too large"); + ctx->hasFatalError = true; + return; + } - int qr_version; - if (!getQrVersionForBinaryDataLength(url_length, qr_version)) { - LOG_E(TAG, "QR is too large"); - stop(manifest.appId); - return; - } + LOG_I(TAG, "QR version %d (length: %d)", qr_version, (int)url_length); + auto qrcodeData = std::make_shared(qrcode_getBufferSize(qr_version)); + if (qrcodeData == nullptr) { + LOG_E(TAG, "Failed to allocate QR buffer"); + ctx->hasFatalError = true; + return; + } - LOG_I(TAG, "QR version %d (length: %d)", qr_version, (int)url_length); - auto qrcodeData = std::make_shared(qrcode_getBufferSize(qr_version)); - if (qrcodeData == nullptr) { - LOG_E(TAG, "Failed to allocate QR buffer"); - stop(manifest.appId); - return; - } + QRCode qrcode; + LOG_I(TAG, "QR init text"); + if (qrcode_initText(&qrcode, qrcodeData.get(), qr_version, ECC_LOW, url.c_str()) != 0) { + LOG_E(TAG, "QR init text failed"); + ctx->hasFatalError = true; + return; + } - QRCode qrcode; - LOG_I(TAG, "QR init text"); - if (qrcode_initText(&qrcode, qrcodeData.get(), qr_version, ECC_LOW, url.c_str()) != 0) { - LOG_E(TAG, "QR init text failed"); - stop(manifest.appId); - return; - } + LOG_I(TAG, "QR size: %d", qrcode.size); + + // Calculate QR dot size + int32_t top_label_height = lv_obj_get_height(top_label) + 2; + int32_t bottom_label_height = lv_obj_get_height(bottom_label) + 2; + LOG_I(TAG, "Create canvas"); + int32_t available_height = parent_height - top_label_height - bottom_label_height; + int32_t available_width = lv_display_get_horizontal_resolution(display); + int32_t smallest_size = std::min(available_height, available_width); + int32_t pixel_size; + if (qrcode.size * 2 <= smallest_size) { + pixel_size = 2; + } else if (qrcode.size <= smallest_size) { + pixel_size = 1; + } else { + LOG_E(TAG, "QR code won't fit screen"); + ctx->hasFatalError = true; + return; + } - LOG_I(TAG, "QR size: %d", qrcode.size); - - // Calculate QR dot size - int32_t top_label_height = lv_obj_get_height(top_label) + 2; - int32_t bottom_label_height = lv_obj_get_height(bottom_label) + 2; - LOG_I(TAG, "Create canvas"); - int32_t available_height = parent_height - top_label_height - bottom_label_height; - int32_t available_width = lv_display_get_horizontal_resolution(display); - int32_t smallest_size = std::min(available_height, available_width); - int32_t pixel_size; - if (qrcode.size * 2 <= smallest_size) { - pixel_size = 2; - } else if (qrcode.size <= smallest_size) { - pixel_size = 1; - } else { - LOG_E(TAG, "QR code won't fit screen"); - stop(manifest.appId); - return; - } + auto* canvas = lv_canvas_create(parent); + lv_obj_set_size(canvas, pixel_size * qrcode.size, pixel_size * qrcode.size); + lv_obj_align(canvas, LV_ALIGN_CENTER, 0, 0); + lv_canvas_fill_bg(canvas, lv_color_black(), LV_OPA_COVER); + lv_obj_set_content_height(canvas, qrcode.size * pixel_size); + lv_obj_set_content_width(canvas, qrcode.size * pixel_size); + + LOG_I(TAG, "Create draw buffer"); + auto* draw_buf = lv_draw_buf_create(pixel_size * qrcode.size, pixel_size * qrcode.size, LV_COLOR_FORMAT_RGB565, LV_STRIDE_AUTO); + if (draw_buf == nullptr) { + LOG_E(TAG, "Failed to allocate draw buffer"); + ctx->hasFatalError = true; + return; + } - auto* canvas = lv_canvas_create(parent); - lv_obj_set_size(canvas, pixel_size * qrcode.size, pixel_size * qrcode.size); - lv_obj_align(canvas, LV_ALIGN_CENTER, 0, 0); - lv_canvas_fill_bg(canvas, lv_color_black(), LV_OPA_COVER); - lv_obj_set_content_height(canvas, qrcode.size * pixel_size); - lv_obj_set_content_width(canvas, qrcode.size * pixel_size); - - LOG_I(TAG, "Create draw buffer"); - auto* draw_buf = lv_draw_buf_create(pixel_size * qrcode.size, pixel_size * qrcode.size, LV_COLOR_FORMAT_RGB565, LV_STRIDE_AUTO); - if (draw_buf == nullptr) { - LOG_E(TAG, "Failed to allocate draw buffer"); - stop(manifest.appId); - return; + lv_canvas_set_draw_buf(canvas, draw_buf); + + for (uint8_t y = 0; y < qrcode.size; y++) { + for (uint8_t x = 0; x < qrcode.size; x++) { + bool colored = qrcode_getModule(&qrcode, x, y); + auto color = colored ? lv_color_white() : lv_color_black(); + int32_t pos_x = x * pixel_size; + int32_t pos_y = y * pixel_size; + for (int px = 0; px < pixel_size; px++) { + for (int py = 0; py < pixel_size; py++) { + lv_canvas_set_px(canvas, pos_x + px, pos_y + py, color, LV_OPA_COVER); + } + } } + } +} - lv_canvas_set_draw_buf(canvas, draw_buf); - - for (uint8_t y = 0; y < qrcode.size; y++) { - for (uint8_t x = 0; x < qrcode.size; x++) { - bool colored = qrcode_getModule(&qrcode, x, y); - auto color = colored ? lv_color_white() : lv_color_black(); - int32_t pos_x = x * pixel_size; - int32_t pos_y = y * pixel_size; - for (int px = 0; px < pixel_size; px++) { - for (int py = 0; py < pixel_size; py++) { - lv_canvas_set_px(canvas, pos_x + px, pos_y + py, color, LV_OPA_COVER); - } - } +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + if (!ctx.hasFatalError) { + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } } + } else { + app_manager_finish(appInstanceId); } -}; -extern const AppManifest manifest = { - .appId = "CrashDiagnostics", - .appName = "Crash Diagnostics", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); + + bool continuePressed = ctx.continuePressed; + + if (continuePressed) { + launcher::start(); + } + + return 0; +} + +} // namespace void start() { - app::start(manifest.appId); + uint32_t instanceId = 0; + app_manager_start(manifest.id, &instanceId); } +extern const ::AppManifest manifest = { + .id = "CrashDiagnostics", + .name = "Crash Diagnostics", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + } // namespace -#endif \ No newline at end of file +#endif diff --git a/Tactility/Source/app/development/Development.cpp b/Tactility/Source/app/development/Development.cpp index 782c03ab7..2ada04271 100644 --- a/Tactility/Source/app/development/Development.cpp +++ b/Tactility/Source/app/development/Development.cpp @@ -2,179 +2,230 @@ #include #include -#include #include -#include #include #include -#include #include +#include +#include +#include + +#include + #include -#include +#include #include +#include #include namespace tt::app::development { constexpr auto* TAG = "Development"; -extern const AppManifest manifest; -class DevelopmentApp final : public App { +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; lv_obj_t* enableSwitch = nullptr; lv_obj_t* enableOnBootSwitch = nullptr; lv_obj_t* statusLabel = nullptr; std::shared_ptr service; + std::unique_ptr timer; +}; - Timer timer = Timer(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [this] { - if (lvgl_is_running()) { - lvgl_lock(); - updateViewState(); - lvgl_unlock(); - } - }); - static void onEnableSwitchChanged(lv_event_t* event) { - lv_event_code_t code = lv_event_get_code(event); - auto* widget = static_cast(lv_event_get_target(event)); - if (code == LV_EVENT_VALUE_CHANGED) { - bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED); - auto* app = static_cast(lv_event_get_user_data(event)); - bool is_changed = is_on != app->service->isEnabled(); - if (is_changed) { - app->service->setEnabled(is_on); - } - } - } +void updateViewState(Context* ctx); + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - static void onEnableOnBootSwitchChanged(lv_event_t* event) { - lv_event_code_t code = lv_event_get_code(event); - auto* widget = static_cast(lv_event_get_target(event)); - if (code == LV_EVENT_VALUE_CHANGED) { - bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED); - bool is_changed = is_on != service::development::shouldEnableOnBoot(); - if (is_changed) { - // Dispatch it, so file IO doesn't block the UI - getMainDispatcher().dispatch([is_on] { - service::development::setEnableOnBoot(is_on); - }); - } +void onEnableSwitchChanged(lv_event_t* event) { + lv_event_code_t code = lv_event_get_code(event); + auto* widget = static_cast(lv_event_get_target(event)); + if (code == LV_EVENT_VALUE_CHANGED) { + bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED); + auto* ctx = static_cast(lv_event_get_user_data(event)); + bool is_changed = is_on != ctx->service->isEnabled(); + if (is_changed) { + ctx->service->setEnabled(is_on); } } +} - void updateViewState() { - if (!service->isEnabled()) { - lv_label_set_text(statusLabel, "Service disabled"); - } else if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) { - lv_label_set_text(statusLabel, "Waiting for connection..."); - } else { // enabled and connected to wifi - auto ip = service::wifi::getIp(); - if (ip.empty()) { - lv_label_set_text(statusLabel, "Waiting for IP..."); - } else { - const std::string status = std::format("Available at {}", ip); - lv_label_set_text(statusLabel, status.c_str()); - } +void onEnableOnBootSwitchChanged(lv_event_t* event) { + lv_event_code_t code = lv_event_get_code(event); + auto* widget = static_cast(lv_event_get_target(event)); + if (code == LV_EVENT_VALUE_CHANGED) { + bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED); + bool is_changed = is_on != service::development::shouldEnableOnBoot(); + if (is_changed) { + // Dispatch it, so file IO doesn't block the UI + getMainDispatcher().dispatch([is_on] { + service::development::setEnableOnBoot(is_on); + }); } } +} -public: - - void onCreate(AppContext& appContext) override { - service = service::development::findService(); - if (service == nullptr) { - LOG_E(TAG, "Service not found"); - stop(manifest.appId); +void updateViewState(Context* ctx) { + if (!ctx->service->isEnabled()) { + lv_label_set_text(ctx->statusLabel, "Service disabled"); + } else if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) { + lv_label_set_text(ctx->statusLabel, "Waiting for connection..."); + } else { // enabled and connected to wifi + auto ip = service::wifi::getIp(); + if (ip.empty()) { + lv_label_set_text(ctx->statusLabel, "Waiting for IP..."); + } else { + const std::string status = std::format("Available at {}", ip); + lv_label_set_text(ctx->statusLabel, status.c_str()); } } +} - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - lv_obj_t* toolbar = lvgl::toolbar_create(parent, app); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - enableSwitch = lvgl_toolbar_add_switch_action(toolbar); - lv_obj_add_event_cb(enableSwitch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, this); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - if (service->isEnabled()) { - lv_obj_add_state(enableSwitch, LV_STATE_CHECKED); - } else { - lv_obj_remove_state(enableSwitch, LV_STATE_CHECKED); - } + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Development"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - // Wrappers + ctx->enableSwitch = lvgl_toolbar_add_switch_action(toolbar); + lv_obj_add_event_cb(ctx->enableSwitch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx); - lv_obj_t* content_wrapper = lv_obj_create(parent); - lv_obj_set_width(content_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(content_wrapper, 1); - lv_obj_set_flex_flow(content_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_border_width(content_wrapper, 0, LV_STATE_DEFAULT); - lvgl::obj_set_style_bg_invisible(content_wrapper); + if (ctx->service->isEnabled()) { + lv_obj_add_state(ctx->enableSwitch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(ctx->enableSwitch, LV_STATE_CHECKED); + } - // Enable on boot + // Wrappers + + lv_obj_t* content_wrapper = lv_obj_create(parent); + lv_obj_set_width(content_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(content_wrapper, 1); + lv_obj_set_flex_flow(content_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_border_width(content_wrapper, 0, LV_STATE_DEFAULT); + lvgl::obj_set_style_bg_invisible(content_wrapper); + + // Enable on boot + + lv_obj_t* enable_wrapper = lv_obj_create(content_wrapper); + lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lvgl::obj_set_style_bg_invisible(enable_wrapper); + lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT); + + lv_obj_t* enable_label = lv_label_create(enable_wrapper); + lv_label_set_text(enable_label, "Enable on boot"); + lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0); + + ctx->enableOnBootSwitch = lv_switch_create(enable_wrapper); + lv_obj_add_event_cb(ctx->enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx); + lv_obj_align(ctx->enableOnBootSwitch, LV_ALIGN_RIGHT_MID, 0, 0); + if (service::development::shouldEnableOnBoot()) { + lv_obj_add_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED); + } - lv_obj_t* enable_wrapper = lv_obj_create(content_wrapper); - lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lvgl::obj_set_style_bg_invisible(enable_wrapper); - lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT); + // Status - lv_obj_t* enable_label = lv_label_create(enable_wrapper); - lv_label_set_text(enable_label, "Enable on boot"); - lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->statusLabel = lv_label_create(content_wrapper); - enableOnBootSwitch = lv_switch_create(enable_wrapper); - lv_obj_add_event_cb(enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, this); - lv_obj_align(enableOnBootSwitch, LV_ALIGN_RIGHT_MID, 0, 0); - if (service::development::shouldEnableOnBoot()) { - lv_obj_add_state(enableOnBootSwitch, LV_STATE_CHECKED); - } else { - lv_obj_remove_state(enableOnBootSwitch, LV_STATE_CHECKED); - } + // Warning - // Status + auto warning_label = lv_label_create(content_wrapper); + lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection."); + lv_obj_set_width(warning_label, LV_PCT(100)); + lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP); + if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) { + lv_obj_set_style_text_color(warning_label, lv_color_make(0xff, 0xff, 0x00), LV_STATE_DEFAULT); + } - statusLabel = lv_label_create(content_wrapper); + updateViewState(ctx); +} - // Warning +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.service = service::development::findService(); + + if (ctx.service == nullptr) { + LOG_E(TAG, "Service not found"); + // No window/subscription was ever created - matches the old model, where onCreate() + // aborting the app meant onShow() was never called either. + app_manager_finish(appInstanceId); + return 0; + } - auto warning_label = lv_label_create(content_wrapper); - lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection."); - lv_obj_set_width(warning_label, LV_PCT(100)); - lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP); - if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) { - lv_obj_set_style_text_color(warning_label, lv_color_make(0xff, 0xff, 0x00), LV_STATE_DEFAULT); + ctx.timer = std::make_unique(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [&ctx] { + if (lvgl_is_running()) { + lvgl_lock(); + updateViewState(&ctx); + lvgl_unlock(); } + }); - updateViewState(); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - timer.start(); - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + ctx.timer->start(); - void onHide(AppContext& appContext) override { - lvgl_lock(); - // Ensure that the update isn't already happening - timer.stop(); - lvgl_unlock(); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } } -}; -extern const AppManifest manifest = { - .appId = "Development", - .appName = "Development", - .appIcon = LVGL_ICON_SHARED_DEVICES, - .appCategory = Category::Settings, - .createApp = create -}; + // Equivalent of the old model's onHide(): ensure the periodic update isn't already happening. + lvgl_lock(); + ctx.timer->stop(); + lvgl_unlock(); -void start() { - app::start(manifest.appId); + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; } } // namespace -#endif // ESP_PLATFORM \ No newline at end of file +extern const ::AppManifest manifest = { + .id = "Development", + .name = "Development", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } +}; + +} // namespace + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/app/files/FilesApp.cpp b/Tactility/Source/app/files/FilesApp.cpp index 5f353cbf7..25594c2e0 100644 --- a/Tactility/Source/app/files/FilesApp.cpp +++ b/Tactility/Source/app/files/FilesApp.cpp @@ -1,50 +1,76 @@ #include #include -#include -#include +#include +#include +#include + +#include #include namespace tt::app::files { -extern const AppManifest manifest; +extern const ::AppManifest manifest; -class FilesApp final : public App { +namespace { - std::unique_ptr view; - std::shared_ptr state; +struct CreateContext { + View* view; + uint32_t appInstanceId; +}; -public: +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + ctx->view->init(ctx->appInstanceId, parent); +} - FilesApp() { - state = std::make_shared(); - view = std::make_unique(state); - } +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + auto state = std::make_shared(); + View view(state); + CreateContext createContext { &view, appInstanceId }; - void onShow(AppContext& appContext, lv_obj_t* parent) override { - view->init(appContext, parent); - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr bundle) override { - view->onResult(launchId, result, std::move(bundle)); - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &createContext); - void onHide(AppContext& appContext) override { - view->deinit(appContext); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + view.onResult(event.result.launch_id, event.result.result); + app_manager_stop(event.result.launch_id); + break; + default: + break; + } } -}; -extern const AppManifest manifest = { - .appId = "Files", - .appName = "Files", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create -}; + view.deinit(); + window_manager_remove(window); + app_event_unsubscribe(&sub); -void start() { - app::start(manifest.appId); + return 0; } } // namespace + +extern const ::AppManifest manifest = { + .id = "Files", + .name = "Files", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + +} // namespace diff --git a/Tactility/Source/app/files/View.cpp b/Tactility/Source/app/files/View.cpp index 15ae3376a..fafb3f399 100644 --- a/Tactility/Source/app/files/View.cpp +++ b/Tactility/Source/app/files/View.cpp @@ -1,14 +1,19 @@ +#include +#include + +#include +#include + #include #include -#include -#include -#include #include #include #include #include #include -#include +#include +#include +#include #include #include @@ -16,17 +21,11 @@ #include #include -#include - #include #include #include #include -#ifdef ESP_PLATFORM -#include -#endif - namespace tt::app::files { constexpr auto* TAG = "Files"; @@ -38,6 +37,11 @@ static void dirEntryListScrollBeginCallback(lv_event_t* event) { view->onDirEntryListScrollBegin(); } +static void onBackPressedCallback(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + view->onBackPressed(); +} + static void onDirEntryPressedCallback(lv_event_t* event) { auto* view = static_cast(lv_event_get_user_data(event)); auto* button = lv_event_get_target_obj(event); @@ -225,8 +229,8 @@ void View::viewFile(const std::string& path, const std::string& filename) { // install(filename); auto message = std::format("Do you want to install {}?", filename); installAppPath = processed_filepath; - auto choices = std::vector {"Yes", "No"}; - installAppLaunchId = alertdialog::start("Install?", message, choices); + auto choices = std::vector {"Yes", "No"}; + installDialogId = alertdialog::start(appInstanceId, "Install?", message, choices); #endif } else if (isSupportedImageFile(filename)) { imageviewer::start(processed_filepath); @@ -371,6 +375,15 @@ void View::createDirEntryWidget(lv_obj_t* list, dirent& dir_entry) { lv_obj_add_event_cb(button, &onDirEntryLongPressedCallback, LV_EVENT_LONG_PRESSED, this); } +void View::onBackPressed() { + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(appInstanceId, &event); +} + void View::onNavigateUpPressed() { if (state->getCurrentPath() != "/") { LOG_I(TAG, "Navigating upwards"); @@ -387,7 +400,7 @@ void View::onRenamePressed() { std::string entry_name = state->getSelectedChildEntry(); LOG_I(TAG, "Pending rename %s", entry_name.c_str()); state->setPendingAction(State::ActionRename); - inputdialog::start("Rename", "", entry_name); + inputdialog::start(appInstanceId, "Rename", "", entry_name); } void View::onDeletePressed() { @@ -396,19 +409,19 @@ void View::onDeletePressed() { state->setPendingAction(State::ActionDelete); std::string message = "Do you want to delete this?\n" + file_path; const std::vector choices = {"Yes", "No"}; - alertdialog::start("Are you sure?", message, choices); + alertdialog::start(appInstanceId, "Are you sure?", message, choices); } void View::onNewFilePressed() { LOG_I(TAG, "Creating new file"); state->setPendingAction(State::ActionCreateFile); - inputdialog::start("New File", "Enter filename:", ""); + inputdialog::start(appInstanceId, "New File", "Enter filename:", ""); } void View::onNewFolderPressed() { LOG_I(TAG, "Creating new folder"); state->setPendingAction(State::ActionCreateFolder); - inputdialog::start("New Folder", "Enter folder name:", ""); + inputdialog::start(appInstanceId, "New Folder", "Enter folder name:", ""); } void View::showActions() { @@ -445,7 +458,7 @@ void View::onEjectPressed() { Device* msc_dev = nullptr; if (device_get_first_active_by_type(&USB_HOST_MSC_TYPE, &msc_dev) != ERROR_NONE || !usb_msc_eject(msc_dev, mount_path.c_str())) { LOG_W(TAG, "usb_msc_eject: %s not found", mount_path.c_str()); - alertdialog::start("Eject failed", "Could not eject \"" + file::getLastPathSegment(mount_path) + "\"."); + alertdialog::start(appInstanceId, "Eject failed", "Could not eject \"" + file::getLastPathSegment(mount_path) + "\"."); } if (msc_dev) { @@ -528,11 +541,15 @@ void View::update(size_t start_index) { lvgl_unlock(); } -void View::init(const AppContext& appContext, lv_obj_t* parent) { +void View::init(uint32_t appInstanceId, lv_obj_t* parent) { + this->appInstanceId = appInstanceId; + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* toolbar = lvgl::toolbar_create(parent, appContext); + auto* toolbar = lvgl_toolbar_create(parent, "Files"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressedCallback, this); navigate_up_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_UP, &onNavigateUpPressedCallback, this); new_file_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_FILE, &onNewFilePressedCallback, this); new_folder_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DIRECTORY, &onNewFolderPressedCallback, this); @@ -574,26 +591,23 @@ void View::onNavigate() { } } -void View::onResult(LaunchId launchId, Result result, std::unique_ptr bundle) { - if (result != Result::Ok || bundle == nullptr) { - return; - } - - if ( - launchId == installAppLaunchId && - result == Result::Ok && - alertdialog::getResultIndex(*bundle) == 0 - ) { - install(installAppPath); +void View::onResult(uint32_t launchId, int32_t result) { + if (launchId == installDialogId && result == 0) { + app_install(installAppPath.c_str()); return; } std::string filepath = state->getSelectedChildPath(); LOG_I(TAG, "Result for %s", filepath.c_str()); + // Text-entry result (rename/new file/new folder); empty for Cancel, or for a dialog that + // doesn't produce text (delete/paste confirmations) - those switch cases below only look at + // `result`, not this. + std::string resultText = (result == 0) ? inputdialog::getLastText() : std::string(); + switch (state->getPendingAction()) { case State::ActionDelete: { - if (alertdialog::getResultIndex(*bundle) == 0) { + if (result == 0) { if (file::isDirectory(filepath)) { if (!file::deleteRecursively(filepath)) { LOG_W(TAG, "Failed to delete %s", filepath.c_str()); @@ -611,7 +625,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr bu break; } case State::ActionRename: { - auto new_name = inputdialog::getResult(*bundle); + std::string new_name = resultText; if (!new_name.empty() && new_name != state->getSelectedChildEntry()) { std::string rename_to = file::getChildPath(state->getCurrentPath(), new_name); { @@ -620,7 +634,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr bu if (stat(rename_to.c_str(), &st) == 0) { LOG_W(TAG, "Rename: destination already exists: \"%s\"", rename_to.c_str()); state->setPendingAction(State::ActionNone); - alertdialog::start("Rename failed", "\"" + new_name + "\" already exists."); + alertdialog::start(appInstanceId, "Rename failed", "\"" + new_name + "\" already exists."); break; } if (rename(filepath.c_str(), rename_to.c_str()) == 0) { @@ -636,7 +650,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr bu break; } case State::ActionCreateFile: { - auto filename = inputdialog::getResult(*bundle); + std::string filename = resultText; if (!filename.empty()) { std::string new_file_path = file::getChildPath(state->getCurrentPath(), filename); @@ -664,7 +678,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr bu break; } case State::ActionCreateFolder: { - auto foldername = inputdialog::getResult(*bundle); + std::string foldername = resultText; if (!foldername.empty()) { std::string new_folder_path = file::getChildPath(state->getCurrentPath(), foldername); @@ -690,7 +704,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr bu break; } case State::ActionPaste: { - if (alertdialog::getResultIndex(*bundle) == 0) { + if (result == 0) { auto clipboard = state->getClipboard(); if (clipboard.has_value()) { std::string dst = state->getPendingPasteDst(); @@ -712,6 +726,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr bu LOG_W(TAG, "Overwrite: destination \"%s\" changed since confirmation, aborting", dst.c_str()); state->setPendingAction(State::ActionNone); alertdialog::start( + appInstanceId, "Overwrite aborted", "\"" + file::getLastPathSegment(dst) + "\" changed while the dialog was open. Please try again." ); @@ -729,6 +744,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr bu LOG_E(TAG, "Overwrite: failed to remove existing destination: \"%s\"", dst.c_str()); state->setPendingAction(State::ActionNone); alertdialog::start( + appInstanceId, "Overwrite failed", "Could not remove \"" + file::getLastPathSegment(dst) + "\" before overwriting." ); @@ -793,7 +809,7 @@ void View::onPastePressed() { state->setPendingPasteDstStat(dst_stat); state->setPendingAction(State::ActionPaste); const std::vector choices = {"Overwrite", "Cancel"}; - alertdialog::start("File exists", "Overwrite \"" + entry_name + "\"?", choices); + alertdialog::start(appInstanceId, "File exists", "Overwrite \"" + entry_name + "\"?", choices); return; } @@ -834,11 +850,12 @@ void View::doPaste(const std::string& src, bool is_cut, const std::string& dst) } } else if (src_delete_failed) { state->setPendingAction(State::ActionNone); // prevent re-trigger on dialog dismiss - alertdialog::start("Move incomplete", "\"" + filename + "\" was copied but the original could not be removed.\nPlease delete it manually."); + alertdialog::start(appInstanceId, "Move incomplete", "\"" + filename + "\" was copied but the original could not be removed.\nPlease delete it manually."); } else { LOG_E(TAG, "Failed to %s \"%s\" to \"%s\"", is_cut ? "move" : "copy", src.c_str(), dst.c_str()); state->setPendingAction(State::ActionNone); // prevent re-trigger on dialog dismiss alertdialog::start( + appInstanceId, std::string("Failed to ") + (is_cut ? "move" : "copy"), "\"" + filename + "\" could not be " + (is_cut ? "moved." : "copied.") ); @@ -848,7 +865,7 @@ void View::doPaste(const std::string& src, bool is_cut, const std::string& dst) update(); } -void View::deinit(const AppContext& appContext) { +void View::deinit() { lv_obj_remove_event_cb(dir_entry_list, dirEntryListScrollBeginCallback); } diff --git a/Tactility/Source/app/fileselection/FileSelection.cpp b/Tactility/Source/app/fileselection/FileSelection.cpp index acf2dcf2f..6f231e427 100644 --- a/Tactility/Source/app/fileselection/FileSelection.cpp +++ b/Tactility/Source/app/fileselection/FileSelection.cpp @@ -1,78 +1,116 @@ #include "Tactility/app/fileselection/FileSelectionPrivate.h" #include "Tactility/app/fileselection/View.h" #include "Tactility/app/fileselection/State.h" -#include "Tactility/app/AppContext.h" -#include -#include +#include +#include +#include + +#include #include +#include namespace tt::app::fileselection { +extern const ::AppManifest manifest; + constexpr auto* TAG = "FileSelection"; -extern const AppManifest manifest; +namespace { -std::string getResultPath(const Bundle& bundle) { - std::string result; - if (bundle.optString("path", result)) { - return result; - } else { - return ""; - } -} +struct Context { + uint32_t appInstanceId; + Mode mode; + std::shared_ptr state; + std::unique_ptr view; + // The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this + // is a plain (non-atomic) field safely shared between the LVGL thread (writer, before + // emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it). + int32_t result = 1; // Cancelled - safety-net default if closed without picking a file +}; -Mode getMode(const Bundle& bundle) { - int32_t mode = static_cast(Mode::ExistingOrNew); - bundle.optInt32("mode", mode); - return static_cast(mode); -} -void setMode(Bundle& bundle, Mode mode) { - auto mode_int = static_cast(mode); - bundle.putInt32("mode", mode_int); +// The last picked path. Static rather than per-instance: simple, and in practice only one +// FileSelection dialog is ever open at a time. Written on the LVGL thread (View's select-button +// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastPath() after +// receiving that event - safe without a lock for the same reason Context::result is (see +// AlertDialog.cpp). +std::string lastPath; + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + ctx->view->init(parent, ctx->mode); } -class FileSelection : public App { - std::unique_ptr view; - std::shared_ptr state; +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + // argv layout: [0]="existing" or "existing_or_new". -public: - FileSelection() { - state = std::make_shared(); - view = std::make_unique(state, [this](const std::string& path) { - auto bundle = std::make_unique(); - bundle->putString("path", path); - setResult(Result::Ok, std::move(bundle)); - stop(manifest.appId); - }); - } + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.mode = (argc > 0 && std::string(argv[0]) == "existing_or_new") ? Mode::ExistingOrNew : Mode::Existing; + ctx.state = std::make_shared(); + ctx.view = std::make_unique(appInstanceId, ctx.state, [&ctx, appInstanceId](const std::string& path) { + // Runs on the LVGL task (View::onSelectButtonPressed) - must NOT call app_manager_stop() + // here: that bound-waits (thread_join) for this app's own thread to finish, which needs + // the LVGL lock (window_manager_remove()) - but this callback runs ON the LVGL task, + // which would deadlock against itself. The caller reaps this instance via + // app_manager_stop() after it receives the APP_EVENT_RESULT instead. + lastPath = path; + ctx.result = 0; + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(appInstanceId, &closeEvent); + }); - void onShow(AppContext& appContext, lv_obj_t* parent) override { - auto mode = getMode(*appContext.getParameters()); - view->init(parent, mode); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); // no-op: modal children never supersede anything + break; + } } -}; -extern const AppManifest manifest = { - .appId = "FileSelection", - .appName = "File Selection", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return ctx.result; +} -LaunchId startForExistingFile() { - auto bundle = std::make_shared(); - setMode(*bundle, Mode::Existing); - return start(manifest.appId, bundle); +} // namespace + +std::string getLastPath() { + return lastPath; +} + +uint32_t startForExistingFile(uint32_t callerAppInstanceId) { + const char* argv[] = { "existing" }; + uint32_t instanceId = 0; + app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId); + return instanceId; } -LaunchId startForExistingOrNewFile() { - auto bundle = std::make_shared(); - setMode(*bundle, Mode::ExistingOrNew); - return start(manifest.appId, bundle); +uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId) { + const char* argv[] = { "existing_or_new" }; + uint32_t instanceId = 0; + app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId); + return instanceId; } +extern const ::AppManifest manifest = { + .id = "FileSelection", + .name = "File Selection", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + } // namespace diff --git a/Tactility/Source/app/fileselection/View.cpp b/Tactility/Source/app/fileselection/View.cpp index c51d30363..b0aa85be9 100644 --- a/Tactility/Source/app/fileselection/View.cpp +++ b/Tactility/Source/app/fileselection/View.cpp @@ -5,6 +5,8 @@ #include #include +#include + #include #include @@ -15,7 +17,6 @@ #include #ifdef ESP_PLATFORM -#include #endif namespace tt::app::fileselection { @@ -38,6 +39,16 @@ static void onNavigateUpPressedCallback(lv_event_t* event) { // endregion +void View::onBackPressedCallback(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(view->appInstanceId, &closeEvent); +} + void View::onTapFile(const std::string& path, const std::string& filename) { std::string file_path = path + "/" + filename; @@ -183,6 +194,8 @@ void View::init(lv_obj_t* parent, Mode mode) { lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); auto* toolbar = lvgl_toolbar_create(parent, "Select File"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, &onBackPressedCallback, this); navigate_up_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_UP, &onNavigateUpPressedCallback, this); auto* wrapper = lv_obj_create(parent); diff --git a/Tactility/Source/app/gpssettings/GpsSettings.cpp b/Tactility/Source/app/gpssettings/GpsSettings.cpp index 98e725c38..538381364 100644 --- a/Tactility/Source/app/gpssettings/GpsSettings.cpp +++ b/Tactility/Source/app/gpssettings/GpsSettings.cpp @@ -1,11 +1,16 @@ #include #include +#include #include #include -#include #include -#include + +#include +#include +#include + +#include #include #include @@ -20,287 +25,305 @@ #include namespace tt::app::addgps { -extern AppManifest manifest; +extern const ::AppManifest manifest; } namespace tt::app::gpssettings { -extern const AppManifest manifest; +extern const ::AppManifest manifest; -class GpsSettingsApp final : public App { +namespace { - struct DeviceRow { - Device* device; - lv_obj_t* button; - lv_obj_t* buttonLabel; - bool hasConfiguration = false; - size_t configurationIndex = 0; - }; +struct DeviceRow { + Device* device; + lv_obj_t* button; + lv_obj_t* buttonLabel; + bool hasConfiguration = false; + size_t configurationIndex = 0; +}; - std::unique_ptr timer; +struct Context { + uint32_t appInstanceId; lv_obj_t* deviceListWrapper = nullptr; std::vector deviceRows; - std::atomic isShown = false; + std::unique_ptr timer; + + // Set when a delete confirmation is pending; read/cleared on this app's own thread when + // the dialog's result arrives. bool hasPendingDelete = false; Device* pendingDeleteDevice = nullptr; size_t pendingDeleteIndex = 0; +}; - static void onAddGpsCallback(lv_event_t* event) { - auto* app = (GpsSettingsApp*)lv_event_get_user_data(event); - app->onAddGps(); - } - void onAddGps() { - app::start(addgps::manifest.appId); - } +void rebuildDeviceList(Context* ctx); +void updateDeviceStates(Context* ctx); +void createWidgets(lv_obj_t* parent, void* userData); - static void onDeviceButtonCallback(lv_event_t* event) { - auto* button = lv_event_get_target_obj(event); - auto* device = static_cast(lv_obj_get_user_data(button)); - - bool running = device_is_ready(device); - // device_start()/device_stop() are potentially blocking calls, so use a dispatcher to not block the UI - getMainDispatcher().dispatch([device, running] { - if (running) { - device_stop(device); - } else { - device_start(device); - } - }); - } +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - // Finds the persisted configuration backing `device` (matched by its parent UART's name) - // and returns its index into gps_settings_for_each_configuration()'s ordering - the handle - // gps_settings_remove_configuration_at() needs to delete exactly this entry, even if another - // entry happens to have identical field values. - // Devicetree-declared GPS_TYPE devices have no such configuration and never match. - static bool findConfigurationIndexForDevice(Device* device, size_t& outIndex) { - auto* parent = device_get_parent(device); - if (parent == nullptr) { - return false; - } +void onAddGpsPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Fire-and-forget top-level launch, matching the original (its result never fed back into + // this app; rebuildDeviceList() runs fresh whenever this app is resumed regardless). + (void)ctx; + uint32_t instanceId = 0; + app_manager_start(addgps::manifest.id, &instanceId); +} - struct Context { - const char* uartName; - size_t* outIndex; - bool found; - } context = { parent->name, &outIndex, false }; - - gps_settings_for_each_configuration(&context, [](const GpsConfiguration* configuration, size_t index, void* untyped_context) { - auto* ctx = static_cast(untyped_context); - if (!ctx->found && strcmp(configuration->uart_name, ctx->uartName) == 0) { - *ctx->outIndex = index; - ctx->found = true; - } - }); +void onDeviceButtonPressed(lv_event_t* event) { + auto* button = lv_event_get_target_obj(event); + auto* device = static_cast(lv_obj_get_user_data(button)); - return context.found; - } + bool running = device_is_ready(device); + // device_start()/device_stop() are potentially blocking calls, so use a dispatcher to not block the UI + getMainDispatcher().dispatch([device, running] { + if (running) { + device_stop(device); + } else { + device_start(device); + } + }); +} - static void onDeleteButtonCallback(lv_event_t* event) { - auto* app = static_cast(lv_event_get_user_data(event)); - auto* button = lv_event_get_target_obj(event); - auto* device = static_cast(lv_obj_get_user_data(button)); - app->onDeleteDevice(device); +// Finds the persisted configuration backing `device` (matched by its parent UART's name) +// and returns its index into gps_settings_for_each_configuration()'s ordering - the handle +// gps_settings_remove_configuration_at() needs to delete exactly this entry, even if another +// entry happens to have identical field values. +// Devicetree-declared GPS_TYPE devices have no such configuration and never match. +bool findConfigurationIndexForDevice(Device* device, size_t& outIndex) { + auto* parent = device_get_parent(device); + if (parent == nullptr) { + return false; } - void onDeleteDevice(Device* device) { - for (auto& row : deviceRows) { - if (row.device == device && row.hasConfiguration) { - pendingDeleteDevice = device; - pendingDeleteIndex = row.configurationIndex; - hasPendingDelete = true; - alertdialog::start("Confirmation", std::string("Do you want to delete ") + device->name + "?", std::vector { "Yes", "No" }); - return; - } + struct FindContext { + const char* uartName; + size_t* outIndex; + bool found; + } findContext = { parent->name, &outIndex, false }; + + gps_settings_for_each_configuration(&findContext, [](const GpsConfiguration* configuration, size_t index, void* untyped_context) { + auto* ctx = static_cast(untyped_context); + if (!ctx->found && strcmp(configuration->uart_name, ctx->uartName) == 0) { + *ctx->outIndex = index; + ctx->found = true; } - } + }); - void createDeviceRow(Device* device) { - auto* wrapper = lv_obj_create(deviceListWrapper); - lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_border_width(wrapper, 0, 0); - lv_obj_set_style_pad_all(wrapper, 0, 0); - - auto* name_label = lv_label_create(wrapper); - char model_name[64]; - if (gps_get_model_name(device, model_name, sizeof(model_name)) == ERROR_NONE) { - lv_label_set_text(name_label, model_name); - } else { - lv_label_set_text(name_label, device->name); - } + return findContext.found; +} - auto* actions_wrapper = lv_obj_create(wrapper); - lv_obj_set_size(actions_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_flex_flow(actions_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_style_border_width(actions_wrapper, 0, 0); - lv_obj_set_style_pad_all(actions_wrapper, 0, 0); - lv_obj_set_style_pad_column(actions_wrapper, 4, 0); - - auto* button = lv_button_create(actions_wrapper); - lv_obj_add_event_cb(button, onDeviceButtonCallback, LV_EVENT_SHORT_CLICKED, this); - lv_obj_set_user_data(button, device); - auto* button_label = lv_label_create(button); - lv_label_set_text(button_label, "Start"); - - DeviceRow row { .device = device, .button = button, .buttonLabel = button_label }; - - // Only devices backed by a persisted configuration (not devicetree-declared ones) can be deleted. - size_t configurationIndex; - if ((device->flags & DEVICE_FLAG_DYNAMIC) && findConfigurationIndexForDevice(device, configurationIndex)) { - auto* delete_button = lv_button_create(actions_wrapper); - lv_obj_add_event_cb(delete_button, onDeleteButtonCallback, LV_EVENT_SHORT_CLICKED, this); - lv_obj_set_user_data(delete_button, device); - auto* delete_label = lv_label_create(delete_button); - lv_label_set_text(delete_label, LVGL_ICON_SHARED_DELETE); - - row.hasConfiguration = true; - row.configurationIndex = configurationIndex; +void onDeleteButtonPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* button = lv_event_get_target_obj(event); + auto* device = static_cast(lv_obj_get_user_data(button)); + + for (auto& row : ctx->deviceRows) { + if (row.device == device && row.hasConfiguration) { + ctx->pendingDeleteDevice = device; + ctx->pendingDeleteIndex = row.configurationIndex; + ctx->hasPendingDelete = true; + alertdialog::start(ctx->appInstanceId, "Confirmation", std::string("Do you want to delete ") + device->name + "?", std::vector { "Yes", "No" }); + return; } - - deviceRows.push_back(row); } +} - // Rebuilds the device list. Only needs to run when the set of devices could've changed - // (on show, and after returning from AddGpsApp) - button state itself is refreshed by the timer. - void rebuildDeviceList() { - lv_obj_clean(deviceListWrapper); - deviceRows.clear(); +void createDeviceRow(Context* ctx, Device* device) { + auto* wrapper = lv_obj_create(ctx->deviceListWrapper); + lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_border_width(wrapper, 0, 0); + lv_obj_set_style_pad_all(wrapper, 0, 0); + + auto* name_label = lv_label_create(wrapper); + char model_name[64]; + if (gps_get_model_name(device, model_name, sizeof(model_name)) == ERROR_NONE) { + lv_label_set_text(name_label, model_name); + } else { + lv_label_set_text(name_label, device->name); + } - device_for_each_of_type(&GPS_TYPE, this, [](Device* device, void* context) { - static_cast(context)->createDeviceRow(device); - return true; - }); + auto* actions_wrapper = lv_obj_create(wrapper); + lv_obj_set_size(actions_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(actions_wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_style_border_width(actions_wrapper, 0, 0); + lv_obj_set_style_pad_all(actions_wrapper, 0, 0); + lv_obj_set_style_pad_column(actions_wrapper, 4, 0); + + auto* button = lv_button_create(actions_wrapper); + lv_obj_add_event_cb(button, onDeviceButtonPressed, LV_EVENT_SHORT_CLICKED, ctx); + lv_obj_set_user_data(button, device); + auto* button_label = lv_label_create(button); + lv_label_set_text(button_label, "Start"); + + DeviceRow row { .device = device, .button = button, .buttonLabel = button_label }; + + // Only devices backed by a persisted configuration (not devicetree-declared ones) can be deleted. + size_t configurationIndex; + if ((device->flags & DEVICE_FLAG_DYNAMIC) && findConfigurationIndexForDevice(device, configurationIndex)) { + auto* delete_button = lv_button_create(actions_wrapper); + lv_obj_add_event_cb(delete_button, onDeleteButtonPressed, LV_EVENT_SHORT_CLICKED, ctx); + lv_obj_set_user_data(delete_button, device); + auto* delete_label = lv_label_create(delete_button); + lv_label_set_text(delete_label, LVGL_ICON_SHARED_DELETE); + + row.hasConfiguration = true; + row.configurationIndex = configurationIndex; } - void updateDeviceStates() { - lvgl_lock(); - for (const auto& row : deviceRows) { - const char* text = "Start"; - bool enabled = true; - - if (device_is_ready(row.device)) { - switch (gps_get_state(row.device)) { - case GPS_STATE_PENDING_ON: - text = "Starting..."; - enabled = false; - break; - case GPS_STATE_PENDING_OFF: - text = "Stopping..."; - enabled = false; - break; - default: - text = "Stop"; - enabled = true; - break; - } - } - lv_label_set_text(row.buttonLabel, text); - if (enabled) { - lv_obj_remove_state(row.button, LV_STATE_DISABLED); - } else { - lv_obj_add_state(row.button, LV_STATE_DISABLED); + ctx->deviceRows.push_back(row); +} + +// Rebuilds the device list. Only needs to run when the set of devices could've changed (on +// creation, and after returning from AddGps) - button state itself is refreshed by the timer. +void rebuildDeviceList(Context* ctx) { + lv_obj_clean(ctx->deviceListWrapper); + ctx->deviceRows.clear(); + + device_for_each_of_type(&GPS_TYPE, ctx, [](Device* device, void* context) { + createDeviceRow(static_cast(context), device); + return true; + }); +} + +void updateDeviceStates(Context* ctx) { + lvgl_lock(); + for (const auto& row : ctx->deviceRows) { + const char* text = "Start"; + bool enabled = true; + + if (device_is_ready(row.device)) { + switch (gps_get_state(row.device)) { + case GPS_STATE_PENDING_ON: + text = "Starting..."; + enabled = false; + break; + case GPS_STATE_PENDING_OFF: + text = "Stopping..."; + enabled = false; + break; + default: + text = "Stop"; + enabled = true; + break; } } - lvgl_unlock(); + lv_label_set_text(row.buttonLabel, text); + if (enabled) { + lv_obj_remove_state(row.button, LV_STATE_DISABLED); + } else { + lv_obj_add_state(row.button, LV_STATE_DISABLED); + } } + lvgl_unlock(); +} -public: +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - GpsSettingsApp() { - // Runs while the screen is shown - there's no push notification for GPS device state - // changes, so this is the only way this screen finds out about them. - timer = std::make_unique(Timer::Type::Periodic, seconds_to_ticks(1), [this] { - updateDeviceStates(); - }); - } + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + uint8_t margin = (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) ? 2 : 8; - uint8_t margin = (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) ? 2 : 8; + auto* toolbar = lvgl_toolbar_create(parent, "GPS"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddGpsPressed, ctx); + lv_obj_set_style_margin_bottom(toolbar, margin, LV_STATE_DEFAULT); - auto* toolbar = lvgl::toolbar_create(parent, app); - lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddGpsCallback, this); - lv_obj_set_style_margin_bottom(toolbar, margin, LV_STATE_DEFAULT); + ctx->deviceListWrapper = lv_obj_create(parent); + lv_obj_set_size(ctx->deviceListWrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(ctx->deviceListWrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_grow(ctx->deviceListWrapper, 1); + lv_obj_set_style_border_width(ctx->deviceListWrapper, 0, 0); + lv_obj_set_style_pad_hor(ctx->deviceListWrapper, margin, 0); + lv_obj_set_style_pad_top(ctx->deviceListWrapper, 0, 0); + lv_obj_set_style_pad_bottom(ctx->deviceListWrapper, margin, 0); + lv_obj_set_style_pad_row(ctx->deviceListWrapper, margin, 0); - deviceListWrapper = lv_obj_create(parent); - lv_obj_set_size(deviceListWrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(deviceListWrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_grow(deviceListWrapper, 1); - lv_obj_set_style_border_width(deviceListWrapper, 0, 0); - lv_obj_set_style_pad_hor(deviceListWrapper, margin, 0); - lv_obj_set_style_pad_top(deviceListWrapper, 0, 0); - lv_obj_set_style_pad_bottom(deviceListWrapper, margin, 0); - lv_obj_set_style_pad_row(deviceListWrapper, margin, 0); + rebuildDeviceList(ctx); + updateDeviceStates(ctx); +} - rebuildDeviceList(); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; - timer->start(); - updateDeviceStates(); + // Runs for this app instance's whole lifetime - there's no push notification for GPS + // device state changes, so this is the only way this screen finds out about them. + ctx.timer = std::make_unique(Timer::Type::Periodic, seconds_to_ticks(1), [&ctx] { + updateDeviceStates(&ctx); + }); - // Only after deviceListWrapper is fully built: onResult() (Loader thread) checks - // this before touching it, since it can run before or after this onShow() call. - isShown = true; - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onHide(AppContext& app) override { - isShown = false; - timer->stop(); - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + ctx.timer->start(); - void onResult(AppContext&, LaunchId, Result result, std::unique_ptr bundle) override { - if (!hasPendingDelete) { - return; + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; } - hasPendingDelete = false; - - if (result != Result::Ok || bundle == nullptr || alertdialog::getResultIndex(*bundle) != 0) { // 0 = Yes - return; - } - - // This runs on the Loader thread, concurrently with the periodic timer callback - // (updateDeviceStates(), timer daemon thread) and possibly with onShow() (GUI - // thread). Take the same lock updateDeviceStates() uses and hold it across the - // free below, so the timer can never observe pendingDeleteDevice as a dangling - // pointer in deviceRows. - lvgl_lock(); - // Drop the stale row unconditionally (cheap vector op, no LVGL calls) - this is - // what keeps the timer safe regardless of whether onShow() has run yet this cycle. - std::erase_if(deviceRows, [this](const DeviceRow& row) { - return row.device == pendingDeleteDevice; - }); - lvgl_unlock(); - - // gps_settings_remove_configuration_at() frees the underlying Device synchronously - - // do this only after the dangling pointer is already out of deviceRows. - gps_settings_remove_configuration_at(pendingDeleteIndex); - pendingDeleteDevice = nullptr; - - // Only safe to touch deviceListWrapper if onShow() already built it for this show - // cycle - it may not have run yet, in which case it'll rebuild fresh (post-deletion, - // deviceRows already correct) when it does. - lvgl_lock(); - if (isShown) { - rebuildDeviceList(); + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + if (ctx.hasPendingDelete) { + ctx.hasPendingDelete = false; + if (event.result.result == 0) { // 0 = Yes + lvgl_lock(); + std::erase_if(ctx.deviceRows, [&ctx](const DeviceRow& row) { + return row.device == ctx.pendingDeleteDevice; + }); + lvgl_unlock(); + + gps_settings_remove_configuration_at(ctx.pendingDeleteIndex); + ctx.pendingDeleteDevice = nullptr; + + lvgl_lock(); + rebuildDeviceList(&ctx); + lvgl_unlock(); + } + } + app_manager_stop(event.result.launch_id); + break; + default: + break; } - lvgl_unlock(); } -}; -extern const AppManifest manifest = { - .appId = "GpsSettings", - .appName = "GPS", - .appIcon = LVGL_ICON_SHARED_NAVIGATION, - .appCategory = Category::Settings, - .createApp = create -}; + ctx.timer->stop(); + window_manager_remove(window); + app_event_unsubscribe(&sub); -void start() { - app::start(manifest.appId); + return 0; } } // namespace + +extern const ::AppManifest manifest = { + .id = "GpsSettings", + .name = "GPS", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } +}; + +} // namespace diff --git a/Tactility/Source/app/grovesettings/GroveSettings.cpp b/Tactility/Source/app/grovesettings/GroveSettings.cpp index cd52e77d6..445fd1c5c 100644 --- a/Tactility/Source/app/grovesettings/GroveSettings.cpp +++ b/Tactility/Source/app/grovesettings/GroveSettings.cpp @@ -2,78 +2,132 @@ #include -#include #include #include -#include -#include +#include +#include +#include + +#include + +#include namespace tt::app::grovesettings { -class GroveSettingsApp final : public App { +extern const ::AppManifest manifest; + +namespace { +struct Context { + uint32_t appInstanceId; std::vector<::Device*> devices; +}; - void collectDevices() { - devices.clear(); - device_for_each_of_type(&GROVE_TYPE, &devices, [](auto* device, auto* context) { - auto* vec = static_cast*>(context); - vec->push_back(device); - return true; - }); - } - static void onModeChanged(lv_event_t* e) { - auto* device = static_cast<::Device*>(lv_event_get_user_data(e)); - auto* dropdown = static_cast(lv_event_get_target(e)); - auto mode = static_cast(lv_dropdown_get_selected(dropdown)); - grove_set_mode(device, mode); - } +void collectDevices(Context* ctx) { + ctx->devices.clear(); + device_for_each_of_type(&GROVE_TYPE, &ctx->devices, [](auto* device, auto* context) { + auto* vec = static_cast*>(context); + vec->push_back(device); + return true; + }); +} + +void onModeChanged(lv_event_t* e) { + auto* device = static_cast<::Device*>(lv_event_get_user_data(e)); + auto* dropdown = static_cast(lv_event_get_target(e)); + auto mode = static_cast(lv_dropdown_get_selected(dropdown)); + grove_set_mode(device, mode); +} -public: - void onShow(AppContext& app, lv_obj_t* parent) override { - collectDevices(); +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + collectDevices(ctx); - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - lvgl::toolbar_create(parent, app); + auto* toolbar = lvgl_toolbar_create(parent, "Grove"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); - for (auto* device : devices) { - auto* row = lv_obj_create(main_wrapper); - lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + for (auto* device : ctx->devices) { + auto* row = lv_obj_create(main_wrapper); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); - auto* label = lv_label_create(row); - lv_label_set_text(label, device->name); - lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0); + auto* label = lv_label_create(row); + lv_label_set_text(label, device->name); + lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0); - auto* dropdown = lv_dropdown_create(row); - lv_dropdown_set_options(dropdown, "Disabled\nUART\nI2C"); - lv_obj_align(dropdown, LV_ALIGN_RIGHT_MID, 0, 0); + auto* dropdown = lv_dropdown_create(row); + lv_dropdown_set_options(dropdown, "Disabled\nUART\nI2C"); + lv_obj_align(dropdown, LV_ALIGN_RIGHT_MID, 0, 0); - GroveMode current = GROVE_MODE_DISABLED; - grove_get_mode(device, ¤t); - lv_dropdown_set_selected(dropdown, static_cast(current)); + GroveMode current = GROVE_MODE_DISABLED; + grove_get_mode(device, ¤t); + lv_dropdown_set_selected(dropdown, static_cast(current)); - lv_obj_add_event_cb(dropdown, onModeChanged, LV_EVENT_VALUE_CHANGED, device); + lv_obj_add_event_cb(dropdown, onModeChanged, LV_EVENT_VALUE_CHANGED, device); + } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "GroveSettings", - .appName = "Grove", - .appIcon = LVGL_ICON_SHARED_CABLE, - .appCategory = Category::Settings, - .createApp = create + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "GroveSettings", + .name = "Grove", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } diff --git a/Tactility/Source/app/i2cscanner/I2cScanner.cpp b/Tactility/Source/app/i2cscanner/I2cScanner.cpp index a08dbcb73..612fe7f79 100644 --- a/Tactility/Source/app/i2cscanner/I2cScanner.cpp +++ b/Tactility/Source/app/i2cscanner/I2cScanner.cpp @@ -4,28 +4,37 @@ #include #include #include -#include -#include -#include + +#include +#include +#include + +#include #include #include +#include #include +#include +#include #include -#include +#include namespace tt::app::i2cscanner { -extern const AppManifest manifest; +extern const ::AppManifest manifest; -class I2cScannerApp final : public App { +namespace { - static constexpr auto* TAG = "I2cScanner"; +constexpr auto* TAG = "I2cScanner"; - static constexpr auto* START_SCAN_TEXT = "Scan"; - static constexpr auto* STOP_SCAN_TEXT = "Stop scan"; +constexpr auto* START_SCAN_TEXT = "Scan"; +constexpr auto* STOP_SCAN_TEXT = "Stop scan"; + +struct Context { + uint32_t appInstanceId; // Core RecursiveMutex mutex; @@ -38,363 +47,362 @@ class I2cScannerApp final : public App { lv_obj_t* scanButtonLabelWidget = nullptr; lv_obj_t* portDropdownWidget = nullptr; lv_obj_t* scanListWidget = nullptr; - - static void setLastBusIndex(int32_t index); - static int32_t getLastBusIndex(); - - void selectBus(int32_t selected); - - static void onSelectBusCallback(lv_event_t* event); - static void onPressScanCallback(lv_event_t* event); - - void onSelectBus(lv_event_t* event); - void onPressScan(lv_event_t* event); - void onScanTimer(); - - bool shouldStopScanTimer(); - bool getPort(struct Device** outPort); - bool addAddressToList(uint8_t address); - bool hasScanThread(); - void startScanning(); - void stopScanning(); - - void updateViews(); - void updateViewsSafely(); - - void onScanTimerFinished(); - -public: - - void onShow(AppContext& app, lv_obj_t* parent) override; - void onHide(AppContext& app) override; }; -/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */ -std::shared_ptr optApp() { - auto appContext = getCurrentAppContext(); - if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) { - return std::static_pointer_cast(appContext->getApp()); - } else { - return nullptr; - } -} #define PREFERENCES_BUS_INDEX_KEY "bus" -void I2cScannerApp::setLastBusIndex(int32_t index) { +void setLastBusIndex(int32_t index) { auto prefs = Preferences("i2c_scanner"); prefs.putInt32(PREFERENCES_BUS_INDEX_KEY, index); } -int32_t I2cScannerApp::getLastBusIndex() { +int32_t getLastBusIndex() { auto prefs = Preferences("i2c_scanner"); int32_t index = 0; prefs.optInt32(PREFERENCES_BUS_INDEX_KEY, index); return index; } -// region Lifecycle - -void I2cScannerApp::onShow(AppContext& app, lv_obj_t* parent) { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - lvgl::toolbar_create(parent, app); - - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); - - auto* wrapper = lv_obj_create(main_wrapper); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_height(wrapper, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(wrapper, 0, 0); - lv_obj_set_style_border_width(wrapper, 0, 0); - - auto* scan_button = lv_button_create(wrapper); - lv_obj_set_width(scan_button, LV_PCT(48)); - lv_obj_align(scan_button, LV_ALIGN_TOP_LEFT, 0, 1); // Shift 1 pixel to align with selection box - lv_obj_add_event_cb(scan_button, onPressScanCallback, LV_EVENT_SHORT_CLICKED, this); - auto* scan_button_label = lv_label_create(scan_button); - lv_obj_align(scan_button_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(scan_button_label, START_SCAN_TEXT); - scanButtonLabelWidget = scan_button_label; - - auto* port_dropdown = lv_dropdown_create(wrapper); - std::string dropdown_items = getPortNamesForDropdown(); - lv_dropdown_set_options(port_dropdown, dropdown_items.c_str()); - lv_obj_set_width(port_dropdown, LV_PCT(48)); - lv_obj_align(port_dropdown, LV_ALIGN_TOP_RIGHT, 0, 0); - lv_obj_add_event_cb(port_dropdown, onSelectBusCallback, LV_EVENT_VALUE_CHANGED, this); - auto selected_bus = getLastBusIndex(); - lv_dropdown_set_selected(port_dropdown, selected_bus); - portDropdownWidget = port_dropdown; - - auto* scan_list = lv_list_create(main_wrapper); - lv_obj_set_style_margin_top(scan_list, 8, 0); - lv_obj_set_width(scan_list, LV_PCT(100)); - lv_obj_set_height(scan_list, LV_SIZE_CONTENT); - lv_obj_add_flag(scan_list, LV_OBJ_FLAG_HIDDEN); - scanListWidget = scan_list; - - struct Device* dummy; - if (getActivePortAtIndex(selected_bus, &dummy)) { - selectBus(selected_bus); - } else if (getActivePortAtIndex(0, &dummy)) { - lv_dropdown_set_selected(port_dropdown, 0); - selectBus(0); +bool getPort(Context* ctx, struct Device** outPort) { + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + *outPort = ctx->portDevice; + ctx->mutex.unlock(); + return true; + } else { + LOG_W(TAG, "Mutex acquisition timeout (%s)", "getPort"); + return false; } } -void I2cScannerApp::onHide(AppContext& app) { - bool isRunning = false; - if (mutex.lock(250 / portTICK_PERIOD_MS)) { - auto* timer = scanTimer.get(); - if (timer != nullptr) { - isRunning = timer->isRunning(); - } - mutex.unlock(); +bool addAddressToList(Context* ctx, uint8_t address) { + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + ctx->scannedAddresses.push_back(address); + ctx->mutex.unlock(); + return true; } else { - return; - } - - if (isRunning) { - stopScanning(); + LOG_W(TAG, "Mutex acquisition timeout (%s)", "addAddressToList"); + return false; } } -// endregion Lifecycle - -// region Callbacks - -void I2cScannerApp::onSelectBusCallback(lv_event_t* event) { - auto* app = (I2cScannerApp*)lv_event_get_user_data(event); - if (app != nullptr) { - app->onSelectBus(event); +bool shouldStopScanTimer(Context* ctx) { + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + bool is_scanning = ctx->scanState == ScanStateScanning; + ctx->mutex.unlock(); + return !is_scanning; + } else { + return true; } } -void I2cScannerApp::onPressScanCallback(lv_event_t* event) { - auto* app = (I2cScannerApp*)lv_event_get_user_data(event); - if (app != nullptr) { - app->onPressScan(event); - } -} +void updateViews(Context* ctx) { + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + if (ctx->scanState == ScanStateScanning) { + lv_label_set_text(ctx->scanButtonLabelWidget, STOP_SCAN_TEXT); + lv_obj_remove_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE); + } else { + lv_label_set_text(ctx->scanButtonLabelWidget, START_SCAN_TEXT); + lv_obj_add_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE); + } -// endregion Callbacks + lv_obj_clean(ctx->scanListWidget); + if (ctx->scanState == ScanStateStopped) { + lv_obj_remove_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN); -bool I2cScannerApp::getPort(struct Device** outPort) { - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - *outPort = this->portDevice; - mutex.unlock(); - return true; + if (!ctx->scannedAddresses.empty()) { + for (auto address: ctx->scannedAddresses) { + std::string address_text = getAddressText(address); + lv_list_add_text(ctx->scanListWidget, address_text.c_str()); + } + } else { + lv_list_add_text(ctx->scanListWidget, "No devices found"); + } + } else { + lv_obj_add_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN); + } + + ctx->mutex.unlock(); } else { - LOG_W(TAG, "Mutex acquisition timeout (%s)", "getPort"); - return false; + LOG_W(TAG, "Mutex acquisition timeout (%s)", "updateViews"); } } -bool I2cScannerApp::addAddressToList(uint8_t address) { - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - scannedAddresses.push_back(address); - mutex.unlock(); - return true; - } else { - LOG_W(TAG, "Mutex acquisition timeout (%s)", "addAddressToList"); - return false; - } +void updateViewsSafely(Context* ctx) { + lvgl_lock(); + updateViews(ctx); + lvgl_unlock(); } -bool I2cScannerApp::shouldStopScanTimer() { - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - bool is_scanning = scanState == ScanStateScanning; - mutex.unlock(); - return !is_scanning; +void onScanTimerFinished(Context* ctx) { + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + if (ctx->scanState == ScanStateScanning) { + ctx->scanState = ScanStateStopped; + } + ctx->mutex.unlock(); + + updateViewsSafely(ctx); } else { - return true; + LOG_W(TAG, "Mutex acquisition timeout (%s)", "onScanTimerFinished"); } } -void I2cScannerApp::onScanTimer() { +void onScanTimer(Context* ctx) { LOG_I(TAG, "Scan thread started"); Device* safe_port; - if (!getPort(&safe_port)) { + if (!getPort(ctx, &safe_port)) { LOG_E(TAG, "Failed to get I2C port"); - onScanTimerFinished(); + onScanTimerFinished(ctx); return; } if (!device_is_ready(safe_port)) { LOG_E(TAG, "I2C port not started"); - onScanTimerFinished(); + onScanTimerFinished(ctx); return; } for (uint8_t address = 1; address < 128; ++address) { if (i2c_controller_has_device_at_address(safe_port, address, 10 / portTICK_PERIOD_MS) == ERROR_NONE) { LOG_I(TAG, "Found device at address 0x%02X", address); - if (!shouldStopScanTimer()) { - addAddressToList(address); + if (!shouldStopScanTimer(ctx)) { + addAddressToList(ctx, address); } else { break; } } - if (shouldStopScanTimer()) { + if (shouldStopScanTimer(ctx)) { break; } } LOG_I(TAG, "Scan thread finalizing"); - onScanTimerFinished(); + onScanTimerFinished(ctx); LOG_I(TAG, "Scan timer done"); } -bool I2cScannerApp::hasScanThread() { +bool hasScanThread(Context* ctx) { bool has_thread; - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - has_thread = scanTimer != nullptr; - mutex.unlock(); + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + has_thread = ctx->scanTimer != nullptr; + ctx->mutex.unlock(); return has_thread; } else { // Unsafe way LOG_W(TAG, "Mutex acquisition timeout (%s)", "hasScanTimer"); - return scanTimer != nullptr; + return ctx->scanTimer != nullptr; } } -void I2cScannerApp::startScanning() { - if (hasScanThread()) { - stopScanning(); +void stopScanning(Context* ctx) { + if (ctx->mutex.lock(250 / portTICK_PERIOD_MS)) { + assert(ctx->scanTimer != nullptr); + ctx->scanState = ScanStateStopped; + ctx->mutex.unlock(); + } else { + LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); } +} - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - scannedAddresses.clear(); +void startScanning(Context* ctx) { + if (hasScanThread(ctx)) { + stopScanning(ctx); + } + + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + ctx->scannedAddresses.clear(); - lv_obj_add_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN); - lv_obj_clean(scanListWidget); + lv_obj_add_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN); + lv_obj_clean(ctx->scanListWidget); - scanState = ScanStateScanning; - scanTimer = std::make_unique(Timer::Type::Once, 10, [this]{ - onScanTimer(); + ctx->scanState = ScanStateScanning; + ctx->scanTimer = std::make_unique(Timer::Type::Once, 10, [ctx]{ + onScanTimer(ctx); }); - scanTimer->start(); - mutex.unlock(); + ctx->scanTimer->start(); + ctx->mutex.unlock(); } else { LOG_W(TAG, "Mutex acquisition timeout (%s)", "startScanning"); } } -void I2cScannerApp::stopScanning() { - if (mutex.lock(250 / portTICK_PERIOD_MS)) { - assert(scanTimer != nullptr); - scanState = ScanStateStopped; - mutex.unlock(); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - } -} -void I2cScannerApp::onSelectBus(lv_event_t* event) { - auto* dropdown = static_cast(lv_event_get_target(event)); - uint32_t selected = lv_dropdown_get_selected(dropdown); - selectBus(selected); -} - -void I2cScannerApp::selectBus(int32_t selected) { +void selectBus(Context* ctx, int32_t selected) { struct Device* found_device; if (!getActivePortAtIndex(selected, &found_device)) { return; } - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - scannedAddresses.clear(); - portDevice = found_device; - scanState = ScanStateInitial; - mutex.unlock(); + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + ctx->scannedAddresses.clear(); + ctx->portDevice = found_device; + ctx->scanState = ScanStateInitial; + ctx->mutex.unlock(); } LOG_I(TAG, "Selected %d", (int)selected); setLastBusIndex(selected); - startScanning(); + startScanning(ctx); - updateViews(); + updateViews(ctx); } -void I2cScannerApp::onPressScan(lv_event_t* event) { - if (scanState == ScanStateScanning) { - stopScanning(); +// region Callbacks + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void onSelectBus(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* dropdown = static_cast(lv_event_get_target(event)); + uint32_t selected = lv_dropdown_get_selected(dropdown); + selectBus(ctx, selected); +} + +void onPressScan(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (ctx->scanState == ScanStateScanning) { + stopScanning(ctx); } else { - startScanning(); + startScanning(ctx); } - updateViews(); + updateViews(ctx); } -void I2cScannerApp::updateViews() { - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - if (scanState == ScanStateScanning) { - lv_label_set_text(scanButtonLabelWidget, STOP_SCAN_TEXT); - lv_obj_remove_flag(portDropdownWidget, LV_OBJ_FLAG_CLICKABLE); - } else { - lv_label_set_text(scanButtonLabelWidget, START_SCAN_TEXT); - lv_obj_add_flag(portDropdownWidget, LV_OBJ_FLAG_CLICKABLE); - } +// endregion Callbacks - lv_obj_clean(scanListWidget); - if (scanState == ScanStateStopped) { - lv_obj_remove_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - if (!scannedAddresses.empty()) { - for (auto address: scannedAddresses) { - std::string address_text = getAddressText(address); - lv_list_add_text(scanListWidget, address_text.c_str()); - } - } else { - lv_list_add_text(scanListWidget, "No devices found"); - } - } else { - lv_obj_add_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN); - } + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - mutex.unlock(); - } else { - LOG_W(TAG, "Mutex acquisition timeout (%s)", "updateViews"); + auto* toolbar = lvgl_toolbar_create(parent, "I2C Scanner"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + auto* wrapper = lv_obj_create(main_wrapper); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_height(wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(wrapper, 0, 0); + lv_obj_set_style_border_width(wrapper, 0, 0); + + auto* scan_button = lv_button_create(wrapper); + lv_obj_set_width(scan_button, LV_PCT(48)); + lv_obj_align(scan_button, LV_ALIGN_TOP_LEFT, 0, 1); // Shift 1 pixel to align with selection box + lv_obj_add_event_cb(scan_button, onPressScan, LV_EVENT_SHORT_CLICKED, ctx); + auto* scan_button_label = lv_label_create(scan_button); + lv_obj_align(scan_button_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(scan_button_label, START_SCAN_TEXT); + ctx->scanButtonLabelWidget = scan_button_label; + + auto* port_dropdown = lv_dropdown_create(wrapper); + std::string dropdown_items = getPortNamesForDropdown(); + lv_dropdown_set_options(port_dropdown, dropdown_items.c_str()); + lv_obj_set_width(port_dropdown, LV_PCT(48)); + lv_obj_align(port_dropdown, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_add_event_cb(port_dropdown, onSelectBus, LV_EVENT_VALUE_CHANGED, ctx); + auto selected_bus = getLastBusIndex(); + lv_dropdown_set_selected(port_dropdown, selected_bus); + ctx->portDropdownWidget = port_dropdown; + + auto* scan_list = lv_list_create(main_wrapper); + lv_obj_set_style_margin_top(scan_list, 8, 0); + lv_obj_set_width(scan_list, LV_PCT(100)); + lv_obj_set_height(scan_list, LV_SIZE_CONTENT); + lv_obj_add_flag(scan_list, LV_OBJ_FLAG_HIDDEN); + ctx->scanListWidget = scan_list; + + struct Device* dummy; + if (getActivePortAtIndex(selected_bus, &dummy)) { + selectBus(ctx, selected_bus); + } else if (getActivePortAtIndex(0, &dummy)) { + lv_dropdown_set_selected(port_dropdown, 0); + selectBus(ctx, 0); } } -void I2cScannerApp::updateViewsSafely() { - lvgl_lock(); - updateViews(); - lvgl_unlock(); +// Mirrors the old model's onHide(): stop any in-flight scan before this app's task exits +// (APP_EVENT_CLOSE). +void stopScanningIfRunning(Context* ctx) { + bool isRunning = false; + if (ctx->mutex.lock(250 / portTICK_PERIOD_MS)) { + auto* timer = ctx->scanTimer.get(); + if (timer != nullptr) { + isRunning = timer->isRunning(); + } + ctx->mutex.unlock(); + } else { + return; + } + + if (isRunning) { + stopScanning(ctx); + } } -void I2cScannerApp::onScanTimerFinished() { - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - if (scanState == ScanStateScanning) { - scanState = ScanStateStopped; - } - mutex.unlock(); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx; + ctx.appInstanceId = appInstanceId; - updateViewsSafely(); - } else { - LOG_W(TAG, "Mutex acquisition timeout (%s)", "onScanTimerFinished"); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + stopScanningIfRunning(&ctx); + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; } -extern const AppManifest manifest = { - .appId = "I2cScanner", - .appName = "I2C Scanner", - .appIcon = LVGL_ICON_SHARED_SEARCH, - .appCategory = Category::System, - .createApp = create +} // namespace + +extern const ::AppManifest manifest = { + .id = "I2cScanner", + .name = "I2C Scanner", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; -LaunchId start() { - return app::start(manifest.appId); +uint32_t start() { + uint32_t instanceId = 0; + app_manager_start(manifest.id, &instanceId); + return instanceId; } } // namespace diff --git a/Tactility/Source/app/imageviewer/ImageViewer.cpp b/Tactility/Source/app/imageviewer/ImageViewer.cpp index 1f991cd2d..b3d3f9ff7 100644 --- a/Tactility/Source/app/imageviewer/ImageViewer.cpp +++ b/Tactility/Source/app/imageviewer/ImageViewer.cpp @@ -1,76 +1,136 @@ #include #include -#include -#include #include +#include #include +#include +#include +#include + +#include + +#include #include +#include + namespace tt::app::imageviewer { -extern const AppManifest manifest; +extern const ::AppManifest manifest; constexpr auto* TAG = "ImageViewer"; -constexpr auto* IMAGE_VIEWER_FILE_ARGUMENT = "file"; - -class ImageViewerApp final : public App { - - void onShow(AppContext& app, lv_obj_t* parent) override { - auto wrapper = lv_obj_create(parent); - lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_border_width(wrapper, 0, 0); - lv_obj_set_style_pad_all(wrapper, 0, 0); - lv_obj_set_style_pad_gap(wrapper, 0, 0); - - auto toolbar = lvgl::toolbar_create(wrapper, app); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - - auto* image_wrapper = lv_obj_create(wrapper); - lv_obj_align_to(image_wrapper, toolbar, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 0); - lv_obj_set_width(image_wrapper, LV_PCT(100)); - auto parent_height = lv_obj_get_height(wrapper); - auto toolbar_height = lv_obj_get_height(toolbar); - lv_obj_set_height(image_wrapper, parent_height - toolbar_height); - lv_obj_set_flex_flow(image_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(image_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(image_wrapper, 0, 0); - lv_obj_set_style_pad_gap(image_wrapper, 0, 0); - lvgl::obj_set_style_bg_invisible(image_wrapper); - - auto* image = lv_image_create(image_wrapper); - lv_obj_align(image, LV_ALIGN_CENTER, 0, 0); - - auto* file_label = lv_label_create(wrapper); - lv_obj_align_to(file_label, wrapper, LV_ALIGN_BOTTOM_LEFT, 0, 0); - - std::shared_ptr bundle = app.getParameters(); - check(bundle != nullptr, "Parameters not set"); - std::string file_argument; - if (bundle->optString(IMAGE_VIEWER_FILE_ARGUMENT, file_argument)) { - std::string prefixed_path = lvgl::PATH_PREFIX + file_argument; - LOG_I(TAG, "Opening %s", prefixed_path.c_str()); - lv_img_set_src(image, prefixed_path.c_str()); - auto path = string::getLastPathSegment(file_argument); - lv_label_set_text(file_label, path.c_str()); - } else { - lv_label_set_text(file_label, "File not found"); + +namespace { + +struct Context { + uint32_t appInstanceId; + std::string filePath; +}; + + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_border_width(wrapper, 0, 0); + lv_obj_set_style_pad_all(wrapper, 0, 0); + lv_obj_set_style_pad_gap(wrapper, 0, 0); + + auto* toolbar = lvgl_toolbar_create(wrapper, "Image Viewer"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + auto* image_wrapper = lv_obj_create(wrapper); + lv_obj_align_to(image_wrapper, toolbar, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 0); + lv_obj_set_width(image_wrapper, LV_PCT(100)); + auto parent_height = lv_obj_get_height(wrapper); + auto toolbar_height = lv_obj_get_height(toolbar); + lv_obj_set_height(image_wrapper, parent_height - toolbar_height); + lv_obj_set_flex_flow(image_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(image_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(image_wrapper, 0, 0); + lv_obj_set_style_pad_gap(image_wrapper, 0, 0); + lvgl::obj_set_style_bg_invisible(image_wrapper); + + auto* image = lv_image_create(image_wrapper); + lv_obj_align(image, LV_ALIGN_CENTER, 0, 0); + + auto* file_label = lv_label_create(wrapper); + lv_obj_align_to(file_label, wrapper, LV_ALIGN_BOTTOM_LEFT, 0, 0); + + if (!ctx->filePath.empty()) { + std::string prefixed_path = lvgl::PATH_PREFIX + ctx->filePath; + LOG_I(TAG, "Opening %s", prefixed_path.c_str()); + lv_img_set_src(image, prefixed_path.c_str()); + auto path = string::getLastPathSegment(ctx->filePath); + lv_label_set_text(file_label, path.c_str()); + } else { + lv_label_set_text(file_label, "File not found"); + } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + check(argc > 0, "Parameters not set"); + + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.filePath = argv[0]; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "ImageViewer", - .appName = "Image Viewer", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} -LaunchId start(const std::string& file) { - auto parameters = std::make_shared(); - parameters->putString(IMAGE_VIEWER_FILE_ARGUMENT, file); - return app::start(manifest.appId, parameters); +} // namespace + +void start(const std::string& file) { + const char* argv[] = { file.c_str() }; + uint32_t instanceId = 0; + app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId); } +extern const ::AppManifest manifest = { + .id = "ImageViewer", + .name = "Image Viewer", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + } // namespace diff --git a/Tactility/Source/app/inputdialog/InputDialog.cpp b/Tactility/Source/app/inputdialog/InputDialog.cpp index 8590abf29..a0f223853 100644 --- a/Tactility/Source/app/inputdialog/InputDialog.cpp +++ b/Tactility/Source/app/inputdialog/InputDialog.cpp @@ -1,128 +1,158 @@ #include +#include +#include +#include + +#include + #include -#include -#include #include #include namespace tt::app::inputdialog { -constexpr auto* PARAMETER_BUNDLE_KEY_TITLE = "title"; -constexpr auto* PARAMETER_BUNDLE_KEY_MESSAGE = "message"; -constexpr auto* PARAMETER_BUNDLE_KEY_PREFILLED = "prefilled"; -constexpr auto* RESULT_BUNDLE_KEY_RESULT = "result"; +constexpr auto* TAG = "InputDialog"; -constexpr auto* DEFAULT_TITLE = "Input"; +extern const ::AppManifest manifest; -constexpr auto* TAG = "InputDialog"; +namespace { -extern const AppManifest manifest; -class InputDialogApp; +struct Context { + uint32_t appInstanceId; + // Set once in appMain() from its own argc/argv parameters, read by createWidgets() - see + // AlertDialog.cpp's Context::argc/argv for why this is safe without a lock. + int argc = 0; + char** argv = nullptr; + // The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this + // is a plain (non-atomic) field safely shared between the LVGL thread (writer, before + // emitting APP_EVENT_CLOSE) and this dialog's own thread (reader, after waking from it). + int32_t result = 1; // Cancelled - safety-net default if closed without pressing a button +}; -LaunchId start(const std::string& title, const std::string& message, const std::string& prefilled) { - auto bundle = std::make_shared(); - bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title); - bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message); - bundle->putString(PARAMETER_BUNDLE_KEY_PREFILLED, prefilled); - return app::start(manifest.appId, bundle); -} +struct ButtonContext { + Context* ctx; + /** Non-null for OK (read at press time), NULL for Cancel. */ + lv_obj_t* textarea; +}; + +// The last text entered via OK. Static rather than per-instance: simple, and in practice only +// one InputDialog is ever open at a time. Written on the LVGL thread (onButtonPressed(), before +// emitting APP_EVENT_CLOSE); read by the parent via getLastText() after receiving that event - +// safe without a lock for the same reason Context::result is (see AlertDialog.cpp). +std::string lastText; -std::string getResult(const Bundle& bundle) { - std::string result; - bundle.optString(RESULT_BUNDLE_KEY_RESULT, result); - return result; +void onButtonDeleted(lv_event_t* e) { + delete static_cast(lv_event_get_user_data(e)); } -static std::string getTitleParameter(const std::shared_ptr& bundle) { - std::string result; - if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) { - return result; +void onButtonPressed(lv_event_t* e) { + auto* btnCtx = static_cast(lv_event_get_user_data(e)); + if (btnCtx->textarea != nullptr) { + LOG_I(TAG, "OK pressed"); + lastText = lv_textarea_get_text(btnCtx->textarea); + btnCtx->ctx->result = 0; } else { - return DEFAULT_TITLE; + LOG_I(TAG, "Cancel pressed"); + btnCtx->ctx->result = 1; } + // Async, non-blocking - see AlertDialog.cpp's onButtonPressed() for why this must not + // call app_manager_stop() directly (would deadlock against the LVGL lock). + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(btnCtx->ctx->appInstanceId, &event); } -class InputDialogApp final : public App { +void createButton(Context* ctx, lv_obj_t* parent, const std::string& text, lv_obj_t* textarea) { + lv_obj_t* button = lv_button_create(parent); + lv_obj_t* button_label = lv_label_create(button); + lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(button_label, text.c_str()); + auto* btnCtx = new ButtonContext { ctx, textarea }; + lv_obj_add_event_cb(button, onButtonPressed, LV_EVENT_SHORT_CLICKED, btnCtx); + lv_obj_add_event_cb(button, onButtonDeleted, LV_EVENT_DELETE, btnCtx); +} - static void createButton(lv_obj_t* parent, const std::string& text, void* callbackContext) { - lv_obj_t* button = lv_button_create(parent); - lv_obj_t* button_label = lv_label_create(button); - lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(button_label, text.c_str()); - lv_obj_add_event_cb(button, onButtonClickedCallback, LV_EVENT_SHORT_CLICKED, callbackContext); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + // argv layout: [0]=title, [1]=message, [2]=prefilled. + char** argv = ctx->argv; + + auto* toolbar = lvgl_toolbar_create(parent, argv[0]); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + auto* message_label = lv_label_create(parent); + lv_obj_align(message_label, LV_ALIGN_CENTER, 0, -20); + lv_obj_set_width(message_label, LV_PCT(80)); + lv_label_set_text(message_label, argv[1]); + lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP); + + auto* textarea = lv_textarea_create(parent); + lv_obj_align_to(textarea, message_label, LV_ALIGN_OUT_BOTTOM_MID, 0, 4); + lv_textarea_set_one_line(textarea, true); + if (argv[2][0] != '\0') { + lv_textarea_set_text(textarea, argv[2]); } - static void onButtonClickedCallback(lv_event_t* e) { - auto app = std::static_pointer_cast(getCurrentApp()); - assert(app != nullptr); - app->onButtonClicked(e); - } + auto* button_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(button_wrapper, 0, 0); + lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_border_width(button_wrapper, 0, 0); + lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4); - void onButtonClicked(lv_event_t* e) { - auto user_data = lv_event_get_user_data(e); - int index = (user_data != 0) ? 0 : 1; - LOG_I(TAG, "Selected item at index %d", index); - if (index == 0) { - auto bundle = std::make_unique(); - const char* text = lv_textarea_get_text((lv_obj_t*)user_data); - bundle->putString(RESULT_BUNDLE_KEY_RESULT, text); - setResult(Result::Ok, std::move(bundle)); - } else { - setResult(Result::Cancelled); + createButton(ctx, button_wrapper, "OK", textarea); + createButton(ctx, button_wrapper, "Cancel", nullptr); +} - } - stop(manifest.appId); - } +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx { appInstanceId }; + ctx.argc = argc; + ctx.argv = argv; -public: + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onShow(AppContext& app, lv_obj_t* parent) override { - auto parameters = app.getParameters(); - check(parameters != nullptr, "Parameters missing"); + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - std::string title = getTitleParameter(app.getParameters()); - auto* toolbar = lvgl_toolbar_create(parent, title.c_str()); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); // no-op: modal children never supersede anything + break; + } + } - auto* message_label = lv_label_create(parent); - lv_obj_align(message_label, LV_ALIGN_CENTER, 0, -20); - lv_obj_set_width(message_label, LV_PCT(80)); + window_manager_remove(window); + app_event_unsubscribe(&sub); - std::string message; - if (parameters->optString(PARAMETER_BUNDLE_KEY_MESSAGE, message)) { - lv_label_set_text(message_label, message.c_str()); - lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP); - } + return ctx.result; +} - auto* textarea = lv_textarea_create(parent); - lv_obj_align_to(textarea, message_label, LV_ALIGN_OUT_BOTTOM_MID, 0, 4); - lv_textarea_set_one_line(textarea, true); - std::string prefilled; - if (parameters->optString(PARAMETER_BUNDLE_KEY_PREFILLED, prefilled)) { - lv_textarea_set_text(textarea, prefilled.c_str()); - } +} // namespace - auto* button_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(button_wrapper, 0, 0); - lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_border_width(button_wrapper, 0, 0); - lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4); +uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::string& prefilled) { + const char* argv[] = { title.c_str(), message.c_str(), prefilled.c_str() }; + uint32_t instanceId = 0; + app_manager_start_for_result(manifest.id, callerAppInstanceId, 3, argv, &instanceId); + return instanceId; +} - createButton(button_wrapper, "OK", textarea); - createButton(button_wrapper, "Cancel", nullptr); - } -}; +std::string getLastText() { + return lastText; +} -extern const AppManifest manifest = { - .appId = "InputDialog", - .appName = "Input Dialog", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create +extern const ::AppManifest manifest = { + .id = "InputDialog", + .name = "Input Dialog", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } diff --git a/Tactility/Source/app/kerneldisplay/KernelDisplay.cpp b/Tactility/Source/app/kerneldisplay/KernelDisplay.cpp index ff6eca909..efde0b130 100644 --- a/Tactility/Source/app/kerneldisplay/KernelDisplay.cpp +++ b/Tactility/Source/app/kerneldisplay/KernelDisplay.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -10,10 +9,16 @@ #ifdef ESP_PLATFORM #include #endif -#include -#include #include +#include +#include +#include + +#include + +#include + #include #ifdef ESP_PLATFORM @@ -22,9 +27,23 @@ namespace tt::app::kerneldisplay { +extern const ::AppManifest manifest; + constexpr auto* TAG = "KernelDisplay"; -static Device* getBacklightDevice() { +namespace { + +struct Context { + uint32_t appInstanceId; + settings::display::DisplaySettings displaySettings; + bool displaySettingsUpdated = false; + lv_obj_t* timeoutSwitch = nullptr; + lv_obj_t* timeoutDropdown = nullptr; + lv_obj_t* screensaverDropdown = nullptr; +}; + + +Device* getBacklightDevice() { Device* display; check(device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE); // Boards not yet migrated to the kernel display driver register a placeholder device (so the @@ -39,253 +58,292 @@ static Device* getBacklightDevice() { return backlight; } -class KernelDisplayApp final : public App { - - settings::display::DisplaySettings displaySettings; - bool displaySettingsUpdated = false; - lv_obj_t* timeoutSwitch = nullptr; - lv_obj_t* timeoutDropdown = nullptr; - lv_obj_t* screensaverDropdown = nullptr; +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - static void onBacklightSliderEvent(lv_event_t* event) { - auto* slider = static_cast(lv_event_get_target(event)); - auto* app = static_cast(lv_event_get_user_data(event)); - auto* backlight = getBacklightDevice(); - assert(backlight != nullptr); +void onBacklightSliderEvent(lv_event_t* event) { + auto* slider = static_cast(lv_event_get_target(event)); + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* backlight = getBacklightDevice(); + assert(backlight != nullptr); - int32_t slider_value = lv_slider_get_value(slider); - app->displaySettings.backlightDuty = static_cast(slider_value); - app->displaySettingsUpdated = true; - backlight_set_brightness(backlight, app->displaySettings.backlightDuty); - } + int32_t slider_value = lv_slider_get_value(slider); + ctx->displaySettings.backlightDuty = static_cast(slider_value); + ctx->displaySettingsUpdated = true; + backlight_set_brightness(backlight, ctx->displaySettings.backlightDuty); +} - static void onOrientationSet(lv_event_t* event) { - auto* app = static_cast(lv_event_get_user_data(event)); - auto* dropdown = static_cast(lv_event_get_target(event)); - uint32_t selected_index = lv_dropdown_get_selected(dropdown); - LOG_I(TAG, "Selected %u", (unsigned)selected_index); - auto selected_orientation = static_cast(selected_index); - if (selected_orientation != app->displaySettings.orientation) { - app->displaySettings.orientation = selected_orientation; - app->displaySettingsUpdated = true; - lv_display_set_rotation(lv_display_get_default(), settings::display::toLvglDisplayRotation(selected_orientation)); - } +void onOrientationSet(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* dropdown = static_cast(lv_event_get_target(event)); + uint32_t selected_index = lv_dropdown_get_selected(dropdown); + LOG_I(TAG, "Selected %u", (unsigned)selected_index); + auto selected_orientation = static_cast(selected_index); + if (selected_orientation != ctx->displaySettings.orientation) { + ctx->displaySettings.orientation = selected_orientation; + ctx->displaySettingsUpdated = true; + lv_display_set_rotation(lv_display_get_default(), settings::display::toLvglDisplayRotation(selected_orientation)); } +} - static void onTimeoutSwitch(lv_event_t* event) { - auto* app = static_cast(lv_event_get_user_data(event)); - auto* sw = static_cast(lv_event_get_target(event)); - bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); - app->displaySettings.backlightTimeoutEnabled = enabled; - app->displaySettingsUpdated = true; - if (app->timeoutDropdown) { - if (enabled) { - lv_obj_clear_state(app->timeoutDropdown, LV_STATE_DISABLED); - if (app->screensaverDropdown) { - lv_obj_clear_state(app->screensaverDropdown, LV_STATE_DISABLED); - } - } else { - lv_obj_add_state(app->timeoutDropdown, LV_STATE_DISABLED); - if (app->screensaverDropdown) { - lv_obj_add_state(app->screensaverDropdown, LV_STATE_DISABLED); - } +void onTimeoutSwitch(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* sw = static_cast(lv_event_get_target(event)); + bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED); + ctx->displaySettings.backlightTimeoutEnabled = enabled; + ctx->displaySettingsUpdated = true; + if (ctx->timeoutDropdown) { + if (enabled) { + lv_obj_clear_state(ctx->timeoutDropdown, LV_STATE_DISABLED); + if (ctx->screensaverDropdown) { + lv_obj_clear_state(ctx->screensaverDropdown, LV_STATE_DISABLED); + } + } else { + lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED); + if (ctx->screensaverDropdown) { + lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED); } } } +} - static void onTimeoutChanged(lv_event_t* event) { - auto* app = static_cast(lv_event_get_user_data(event)); - auto* dropdown = static_cast(lv_event_get_target(event)); - uint32_t idx = lv_dropdown_get_selected(dropdown); - // Map dropdown index to ms: 0=15s,1=30s,2=1m,3=2m,4=5m,5=Never - static const uint32_t values_ms[] = {15000, 30000, 60000, 120000, 300000, 0}; - if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) { - app->displaySettings.backlightTimeoutMs = values_ms[idx]; - app->displaySettingsUpdated = true; - } +void onTimeoutChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* dropdown = static_cast(lv_event_get_target(event)); + uint32_t idx = lv_dropdown_get_selected(dropdown); + // Map dropdown index to ms: 0=15s,1=30s,2=1m,3=2m,4=5m,5=Never + static const uint32_t values_ms[] = {15000, 30000, 60000, 120000, 300000, 0}; + if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) { + ctx->displaySettings.backlightTimeoutMs = values_ms[idx]; + ctx->displaySettingsUpdated = true; } +} - static void onScreensaverChanged(lv_event_t* event) { - auto* app = static_cast(lv_event_get_user_data(event)); - auto* dropdown = static_cast(lv_event_get_target(event)); - uint32_t idx = lv_dropdown_get_selected(dropdown); - // Validate index bounds before casting to enum - if (idx >= static_cast(settings::display::ScreensaverType::Count)) { - return; - } - auto selected_type = static_cast(idx); - if (selected_type != app->displaySettings.screensaverType) { - app->displaySettings.screensaverType = selected_type; - app->displaySettingsUpdated = true; - } +void onScreensaverChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* dropdown = static_cast(lv_event_get_target(event)); + uint32_t idx = lv_dropdown_get_selected(dropdown); + // Validate index bounds before casting to enum + if (idx >= static_cast(settings::display::ScreensaverType::Count)) { + return; } + auto selected_type = static_cast(idx); + if (selected_type != ctx->displaySettings.screensaverType) { + ctx->displaySettings.screensaverType = selected_type; + ctx->displaySettingsUpdated = true; + } +} -public: +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - void onShow(AppContext& app, lv_obj_t* parent) override { - displaySettings = settings::display::loadOrGetDefault(); - auto ui_density = lvgl_get_ui_density(); + ctx->displaySettings = settings::display::loadOrGetDefault(); + auto ui_density = lvgl_get_ui_density(); - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* backlight = getBacklightDevice(); + auto* backlight = getBacklightDevice(); - lvgl::toolbar_create(parent, app); + auto* toolbar = lvgl_toolbar_create(parent, "Display"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); - // Backlight slider - // Note: no gamma slider here - unlike HalDisplayApp (app/display/Display.cpp), the kernel - // DisplayApi has no gamma curve control yet. + // Backlight slider + // Note: no gamma slider here - unlike HalDisplayApp (app/display/Display.cpp), the kernel + // DisplayApi has no gamma curve control yet. - if (backlight != nullptr) { - bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1; - if (!is_on_off_brightness) { - auto* brightness_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(brightness_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_hor(brightness_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(brightness_wrapper, 0, LV_STATE_DEFAULT); - if (ui_density != LVGL_UI_DENSITY_COMPACT) { - lv_obj_set_style_pad_ver(brightness_wrapper, 4, LV_STATE_DEFAULT); - } + if (backlight != nullptr) { + bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1; + if (!is_on_off_brightness) { + auto* brightness_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(brightness_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(brightness_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(brightness_wrapper, 0, LV_STATE_DEFAULT); + if (ui_density != LVGL_UI_DENSITY_COMPACT) { + lv_obj_set_style_pad_ver(brightness_wrapper, 4, LV_STATE_DEFAULT); + } - auto* brightness_label = lv_label_create(brightness_wrapper); - lv_label_set_text(brightness_label, "Brightness"); - lv_obj_align(brightness_label, LV_ALIGN_LEFT_MID, 0, 0); + auto* brightness_label = lv_label_create(brightness_wrapper); + lv_label_set_text(brightness_label, "Brightness"); + lv_obj_align(brightness_label, LV_ALIGN_LEFT_MID, 0, 0); - auto* brightness_slider = lv_slider_create(brightness_wrapper); - lv_obj_set_width(brightness_slider, LV_PCT(50)); - lv_obj_align(brightness_slider, LV_ALIGN_RIGHT_MID, 0, 0); - lv_slider_set_range(brightness_slider, backlight_get_min_brightness(backlight), backlight_get_max_brightness(backlight)); - lv_obj_add_event_cb(brightness_slider, onBacklightSliderEvent, LV_EVENT_VALUE_CHANGED, this); + auto* brightness_slider = lv_slider_create(brightness_wrapper); + lv_obj_set_width(brightness_slider, LV_PCT(50)); + lv_obj_align(brightness_slider, LV_ALIGN_RIGHT_MID, 0, 0); + lv_slider_set_range(brightness_slider, backlight_get_min_brightness(backlight), backlight_get_max_brightness(backlight)); + lv_obj_add_event_cb(brightness_slider, onBacklightSliderEvent, LV_EVENT_VALUE_CHANGED, ctx); - lv_slider_set_value(brightness_slider, displaySettings.backlightDuty, LV_ANIM_OFF); - } + lv_slider_set_value(brightness_slider, ctx->displaySettings.backlightDuty, LV_ANIM_OFF); } + } - // Orientation - - auto* orientation_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(orientation_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(orientation_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(orientation_wrapper, 0, LV_STATE_DEFAULT); - - auto* orientation_label = lv_label_create(orientation_wrapper); - lv_label_set_text(orientation_label, "Orientation"); - lv_obj_align(orientation_label, LV_ALIGN_LEFT_MID, 0, 0); - - auto* orientation_dropdown = lv_dropdown_create(orientation_wrapper); - // Note: order correlates with settings::display::Orientation item order - lv_dropdown_set_options(orientation_dropdown, "Landscape\nPortrait Right\nLandscape Flipped\nPortrait Left"); - lv_obj_align(orientation_dropdown, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(orientation_dropdown, onOrientationSet, LV_EVENT_VALUE_CHANGED, this); - // Set the dropdown to match current orientation enum - lv_dropdown_set_selected(orientation_dropdown, static_cast(displaySettings.orientation)); - - // Screen timeout - // Note: DisplayIdleService doesn't act on these settings for kernel-driver displays yet - // (it only looks up the deprecated tt::hal::display::DisplayDevice), so these currently - // just get saved without taking effect. Kept for parity/forward-compatibility. - - if (backlight != nullptr) { - auto* timeout_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(timeout_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(timeout_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(timeout_wrapper, 0, LV_STATE_DEFAULT); - - auto* timeout_label = lv_label_create(timeout_wrapper); - lv_label_set_text(timeout_label, "Auto screen off"); - lv_obj_align(timeout_label, LV_ALIGN_LEFT_MID, 0, 0); - - timeoutSwitch = lv_switch_create(timeout_wrapper); - if (displaySettings.backlightTimeoutEnabled) { - lv_obj_add_state(timeoutSwitch, LV_STATE_CHECKED); - } - lv_obj_align(timeoutSwitch, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(timeoutSwitch, onTimeoutSwitch, LV_EVENT_VALUE_CHANGED, this); - - auto* timeout_select_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT); - - auto* timeout_value_label = lv_label_create(timeout_select_wrapper); - lv_label_set_text(timeout_value_label, "Timeout"); - lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0); - - timeoutDropdown = lv_dropdown_create(timeout_select_wrapper); - lv_dropdown_set_options(timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever"); - lv_obj_align(timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, this); - // Initialize dropdown selection from settings - uint32_t ms = displaySettings.backlightTimeoutMs; - uint32_t idx = 2; // default 1 minute - if (ms == 15000) idx = 0; - else if (ms == 30000) - idx = 1; - else if (ms == 60000) - idx = 2; - else if (ms == 120000) - idx = 3; - else if (ms == 300000) - idx = 4; - else if (ms == 0) - idx = 5; - lv_dropdown_set_selected(timeoutDropdown, idx); - if (!displaySettings.backlightTimeoutEnabled) { - lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED); - } + // Orientation + + auto* orientation_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(orientation_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(orientation_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(orientation_wrapper, 0, LV_STATE_DEFAULT); + + auto* orientation_label = lv_label_create(orientation_wrapper); + lv_label_set_text(orientation_label, "Orientation"); + lv_obj_align(orientation_label, LV_ALIGN_LEFT_MID, 0, 0); + + auto* orientation_dropdown = lv_dropdown_create(orientation_wrapper); + // Note: order correlates with settings::display::Orientation item order + lv_dropdown_set_options(orientation_dropdown, "Landscape\nPortrait Right\nLandscape Flipped\nPortrait Left"); + lv_obj_align(orientation_dropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(orientation_dropdown, onOrientationSet, LV_EVENT_VALUE_CHANGED, ctx); + // Set the dropdown to match current orientation enum + lv_dropdown_set_selected(orientation_dropdown, static_cast(ctx->displaySettings.orientation)); + + // Screen timeout + // Note: DisplayIdleService doesn't act on these settings for kernel-driver displays yet + // (it only looks up the deprecated tt::hal::display::DisplayDevice), so these currently + // just get saved without taking effect. Kept for parity/forward-compatibility. + + if (backlight != nullptr) { + auto* timeout_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(timeout_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(timeout_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(timeout_wrapper, 0, LV_STATE_DEFAULT); + + auto* timeout_label = lv_label_create(timeout_wrapper); + lv_label_set_text(timeout_label, "Auto screen off"); + lv_obj_align(timeout_label, LV_ALIGN_LEFT_MID, 0, 0); + + ctx->timeoutSwitch = lv_switch_create(timeout_wrapper); + if (ctx->displaySettings.backlightTimeoutEnabled) { + lv_obj_add_state(ctx->timeoutSwitch, LV_STATE_CHECKED); + } + lv_obj_align(ctx->timeoutSwitch, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->timeoutSwitch, onTimeoutSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + auto* timeout_select_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT); + + auto* timeout_value_label = lv_label_create(timeout_select_wrapper); + lv_label_set_text(timeout_value_label, "Timeout"); + lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0); + + ctx->timeoutDropdown = lv_dropdown_create(timeout_select_wrapper); + lv_dropdown_set_options(ctx->timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever"); + lv_obj_align(ctx->timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, ctx); + // Initialize dropdown selection from settings + uint32_t ms = ctx->displaySettings.backlightTimeoutMs; + uint32_t idx = 2; // default 1 minute + if (ms == 15000) idx = 0; + else if (ms == 30000) + idx = 1; + else if (ms == 60000) + idx = 2; + else if (ms == 120000) + idx = 3; + else if (ms == 300000) + idx = 4; + else if (ms == 0) + idx = 5; + lv_dropdown_set_selected(ctx->timeoutDropdown, idx); + if (!ctx->displaySettings.backlightTimeoutEnabled) { + lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED); + } - // Screensaver type - auto* screensaver_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT); - - auto* screensaver_label = lv_label_create(screensaver_wrapper); - lv_label_set_text(screensaver_label, "Screensaver"); - lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0); - - screensaverDropdown = lv_dropdown_create(screensaver_wrapper); - // Note: order correlates with settings::display::ScreensaverType enum order - lv_dropdown_set_options(screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain\nStackChan"); - lv_obj_align(screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, this); - lv_dropdown_set_selected(screensaverDropdown, static_cast(displaySettings.screensaverType)); - if (!displaySettings.backlightTimeoutEnabled) { - lv_obj_add_state(screensaverDropdown, LV_STATE_DISABLED); - } + // Screensaver type + auto* screensaver_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT); + + auto* screensaver_label = lv_label_create(screensaver_wrapper); + lv_label_set_text(screensaver_label, "Screensaver"); + lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0); + + ctx->screensaverDropdown = lv_dropdown_create(screensaver_wrapper); + // Note: order correlates with settings::display::ScreensaverType enum order + lv_dropdown_set_options(ctx->screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain\nStackChan"); + lv_obj_align(ctx->screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, ctx); + lv_dropdown_set_selected(ctx->screensaverDropdown, static_cast(ctx->displaySettings.screensaverType)); + if (!ctx->displaySettings.backlightTimeoutEnabled) { + lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED); } } +} - void onHide(AppContext& app) override { - if (displaySettingsUpdated) { - // Dispatch it, so file IO doesn't block the UI - const settings::display::DisplaySettings settings_to_save = displaySettings; - getMainDispatcher().dispatch([settings_to_save] { - settings::display::save(settings_to_save); +// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is +// giving up its thread for a save/resume cycle, or closing for good) whenever they changed. +void persistIfUpdated(Context& ctx) { + if (ctx.displaySettingsUpdated) { + // Dispatch it, so file IO doesn't block the UI + const settings::display::DisplaySettings settings_to_save = ctx.displaySettings; + getMainDispatcher().dispatch([settings_to_save] { + settings::display::save(settings_to_save); #ifdef ESP_PLATFORM - // Notify DisplayIdle service to reload settings - auto displayIdle = service::displayidle::findService(); - if (displayIdle) { - displayIdle->reloadSettings(); - } + // Notify DisplayIdle service to reload settings + auto displayIdle = service::displayidle::findService(); + if (displayIdle) { + displayIdle->reloadSettings(); + } #endif - }); + }); + } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + persistIfUpdated(ctx); + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "Display", - .appName = "Display", - .appIcon = LVGL_ICON_SHARED_DISPLAY_SETTINGS, - .appCategory = Category::Settings, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} } // namespace + +extern const ::AppManifest manifest = { + .id = "Display", + .name = "Display", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } +}; + +} // namespace tt::app::kerneldisplay diff --git a/Tactility/Source/app/keyboard/KeyboardSettings.cpp b/Tactility/Source/app/keyboard/KeyboardSettings.cpp index 111341e16..71f52a16f 100644 --- a/Tactility/Source/app/keyboard/KeyboardSettings.cpp +++ b/Tactility/Source/app/keyboard/KeyboardSettings.cpp @@ -3,16 +3,23 @@ #include #include -#include -#include +#include +#include +#include + +#include + #include #include #include +#include namespace tt::app::keyboardsettings { +extern const ::AppManifest manifest; + constexpr auto* TAG = "KeyboardSettings"; // Shared timeout values: 15s, 30s, 1m, 2m, 5m, Never (0) @@ -35,157 +42,209 @@ static void applyKeyboardBacklight(bool enabled, uint8_t brightness) { } } -class KeyboardSettingsApp final : public App { +namespace { +struct Context { + uint32_t appInstanceId; settings::keyboard::KeyboardSettings kbSettings; bool updated = false; lv_obj_t* switchBacklight = nullptr; lv_obj_t* sliderBrightness = nullptr; lv_obj_t* switchTimeoutEnable = nullptr; lv_obj_t* timeoutDropdown = nullptr; +}; - static void onBacklightSwitch(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - bool enabled = lv_obj_has_state(app->switchBacklight, LV_STATE_CHECKED); - app->kbSettings.backlightEnabled = enabled; - app->updated = true; - if (app->sliderBrightness) { - if (enabled) lv_obj_clear_state(app->sliderBrightness, LV_STATE_DISABLED); - else lv_obj_add_state(app->sliderBrightness, LV_STATE_DISABLED); - } - applyKeyboardBacklight(enabled, app->kbSettings.backlightBrightness); + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void onBacklightSwitch(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + bool enabled = lv_obj_has_state(ctx->switchBacklight, LV_STATE_CHECKED); + ctx->kbSettings.backlightEnabled = enabled; + ctx->updated = true; + if (ctx->sliderBrightness) { + if (enabled) lv_obj_clear_state(ctx->sliderBrightness, LV_STATE_DISABLED); + else lv_obj_add_state(ctx->sliderBrightness, LV_STATE_DISABLED); } + applyKeyboardBacklight(enabled, ctx->kbSettings.backlightBrightness); +} - static void onBrightnessChanged(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - int32_t v = lv_slider_get_value(app->sliderBrightness); - app->kbSettings.backlightBrightness = static_cast(v); - app->updated = true; - if (app->kbSettings.backlightEnabled) { - applyKeyboardBacklight(true, app->kbSettings.backlightBrightness); - } +void onBrightnessChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + int32_t v = lv_slider_get_value(ctx->sliderBrightness); + ctx->kbSettings.backlightBrightness = static_cast(v); + ctx->updated = true; + if (ctx->kbSettings.backlightEnabled) { + applyKeyboardBacklight(true, ctx->kbSettings.backlightBrightness); } +} - static void onTimeoutEnableSwitch(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - bool enabled = lv_obj_has_state(app->switchTimeoutEnable, LV_STATE_CHECKED); - app->kbSettings.backlightTimeoutEnabled = enabled; - app->updated = true; - if (app->timeoutDropdown) { - if (enabled) { - lv_obj_clear_state(app->timeoutDropdown, LV_STATE_DISABLED); - } else { - lv_obj_add_state(app->timeoutDropdown, LV_STATE_DISABLED); - } +void onTimeoutEnableSwitch(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + bool enabled = lv_obj_has_state(ctx->switchTimeoutEnable, LV_STATE_CHECKED); + ctx->kbSettings.backlightTimeoutEnabled = enabled; + ctx->updated = true; + if (ctx->timeoutDropdown) { + if (enabled) { + lv_obj_clear_state(ctx->timeoutDropdown, LV_STATE_DISABLED); + } else { + lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED); } } +} - static void onTimeoutChanged(lv_event_t* event) { - auto* app = static_cast(lv_event_get_user_data(event)); - auto* dropdown = static_cast(lv_event_get_target(event)); - uint32_t idx = lv_dropdown_get_selected(dropdown); - if (idx < (sizeof(TIMEOUT_VALUES_MS) / sizeof(TIMEOUT_VALUES_MS[0]))) { - app->kbSettings.backlightTimeoutMs = TIMEOUT_VALUES_MS[idx]; - app->updated = true; - } +void onTimeoutChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* dropdown = static_cast(lv_event_get_target(event)); + uint32_t idx = lv_dropdown_get_selected(dropdown); + if (idx < (sizeof(TIMEOUT_VALUES_MS) / sizeof(TIMEOUT_VALUES_MS[0]))) { + ctx->kbSettings.backlightTimeoutMs = TIMEOUT_VALUES_MS[idx]; + ctx->updated = true; + } +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + ctx->kbSettings = settings::keyboard::loadOrGetDefault(); + ctx->updated = false; + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Keyboard"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + // Keyboard backlight toggle + auto* bl_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(bl_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(bl_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(bl_wrapper, 0, LV_STATE_DEFAULT); + + auto* bl_label = lv_label_create(bl_wrapper); + lv_label_set_text(bl_label, "Keyboard backlight"); + lv_obj_align(bl_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->switchBacklight = lv_switch_create(bl_wrapper); + if (ctx->kbSettings.backlightEnabled) lv_obj_add_state(ctx->switchBacklight, LV_STATE_CHECKED); + lv_obj_align(ctx->switchBacklight, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->switchBacklight, onBacklightSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + // Brightness slider + auto* br_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(br_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(br_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(br_wrapper, 0, LV_STATE_DEFAULT); + + auto* br_label = lv_label_create(br_wrapper); + lv_label_set_text(br_label, "Brightness"); + lv_obj_align(br_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->sliderBrightness = lv_slider_create(br_wrapper); + lv_obj_set_width(ctx->sliderBrightness, LV_PCT(50)); + lv_obj_align(ctx->sliderBrightness, LV_ALIGN_RIGHT_MID, 0, 0); + lv_slider_set_range(ctx->sliderBrightness, 0, 255); + lv_slider_set_value(ctx->sliderBrightness, ctx->kbSettings.backlightBrightness, LV_ANIM_OFF); + if (!ctx->kbSettings.backlightEnabled) lv_obj_add_state(ctx->sliderBrightness, LV_STATE_DISABLED); + lv_obj_add_event_cb(ctx->sliderBrightness, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, ctx); + + // Backlight timeout enable + auto* to_enable_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(to_enable_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT); + + auto* to_enable_label = lv_label_create(to_enable_wrapper); + lv_label_set_text(to_enable_label, "Auto backlight off"); + lv_obj_align(to_enable_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->switchTimeoutEnable = lv_switch_create(to_enable_wrapper); + if (ctx->kbSettings.backlightTimeoutEnabled) lv_obj_add_state(ctx->switchTimeoutEnable, LV_STATE_CHECKED); + lv_obj_align(ctx->switchTimeoutEnable, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->switchTimeoutEnable, onTimeoutEnableSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + auto* timeout_select_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT); + + auto* timeout_value_label = lv_label_create(timeout_select_wrapper); + lv_label_set_text(timeout_value_label, "Timeout"); + lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0); + + // Backlight timeout value (seconds) + ctx->timeoutDropdown = lv_dropdown_create(timeout_select_wrapper); + lv_dropdown_set_options(ctx->timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever"); + lv_obj_align(ctx->timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, ctx); + // Initialize dropdown selection from settings + lv_dropdown_set_selected(ctx->timeoutDropdown, timeoutMsToIndex(ctx->kbSettings.backlightTimeoutMs)); + if (!ctx->kbSettings.backlightTimeoutEnabled) { + lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED); } +} -public: - void onShow(AppContext& app, lv_obj_t* parent) override { - kbSettings = settings::keyboard::loadOrGetDefault(); - updated = false; - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - lvgl::toolbar_create(parent, app); - - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); - - // Keyboard backlight toggle - auto* bl_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(bl_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(bl_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(bl_wrapper, 0, LV_STATE_DEFAULT); - - auto* bl_label = lv_label_create(bl_wrapper); - lv_label_set_text(bl_label, "Keyboard backlight"); - lv_obj_align(bl_label, LV_ALIGN_LEFT_MID, 0, 0); - switchBacklight = lv_switch_create(bl_wrapper); - if (kbSettings.backlightEnabled) lv_obj_add_state(switchBacklight, LV_STATE_CHECKED); - lv_obj_align(switchBacklight, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(switchBacklight, onBacklightSwitch, LV_EVENT_VALUE_CHANGED, this); - - // Brightness slider - auto* br_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(br_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(br_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(br_wrapper, 0, LV_STATE_DEFAULT); - - auto* br_label = lv_label_create(br_wrapper); - lv_label_set_text(br_label, "Brightness"); - lv_obj_align(br_label, LV_ALIGN_LEFT_MID, 0, 0); - sliderBrightness = lv_slider_create(br_wrapper); - lv_obj_set_width(sliderBrightness, LV_PCT(50)); - lv_obj_align(sliderBrightness, LV_ALIGN_RIGHT_MID, 0, 0); - lv_slider_set_range(sliderBrightness, 0, 255); - lv_slider_set_value(sliderBrightness, kbSettings.backlightBrightness, LV_ANIM_OFF); - if (!kbSettings.backlightEnabled) lv_obj_add_state(sliderBrightness, LV_STATE_DISABLED); - lv_obj_add_event_cb(sliderBrightness, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, this); - - // Backlight timeout enable - auto* to_enable_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(to_enable_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT); - - auto* to_enable_label = lv_label_create(to_enable_wrapper); - lv_label_set_text(to_enable_label, "Auto backlight off"); - lv_obj_align(to_enable_label, LV_ALIGN_LEFT_MID, 0, 0); - switchTimeoutEnable = lv_switch_create(to_enable_wrapper); - if (kbSettings.backlightTimeoutEnabled) lv_obj_add_state(switchTimeoutEnable, LV_STATE_CHECKED); - lv_obj_align(switchTimeoutEnable, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(switchTimeoutEnable, onTimeoutEnableSwitch, LV_EVENT_VALUE_CHANGED, this); - - auto* timeout_select_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT); - - auto* timeout_value_label = lv_label_create(timeout_select_wrapper); - lv_label_set_text(timeout_value_label, "Timeout"); - lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0); - - // Backlight timeout value (seconds) - timeoutDropdown = lv_dropdown_create(timeout_select_wrapper); - lv_dropdown_set_options(timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever"); - lv_obj_align(timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, this); - // Initialize dropdown selection from settings - lv_dropdown_set_selected(timeoutDropdown, timeoutMsToIndex(kbSettings.backlightTimeoutMs)); - if (!kbSettings.backlightTimeoutEnabled) { - lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED); - } +// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is +// giving up its thread for a save/resume cycle, or closing for good) whenever they changed. +void persistIfUpdated(Context& ctx) { + if (ctx.updated) { + const auto copy = ctx.kbSettings; + getMainDispatcher().dispatch([copy]{ settings::keyboard::save(copy); }); + ctx.updated = false; } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onHide(AppContext& app) override { - if (updated) { - const auto copy = kbSettings; - getMainDispatcher().dispatch([copy]{ settings::keyboard::save(copy); }); - updated = false; + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + persistIfUpdated(ctx); + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "KeyboardSettings", - .appName = "Keyboard", - .appIcon = LVGL_ICON_SHARED_KEYBOARD_ALT, - .appCategory = Category::Settings, - .createApp = create + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "KeyboardSettings", + .name = "Keyboard", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 877997f15..c72548958 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -1,27 +1,31 @@ -#include - -#include -#include -#include -#include -#include -#include +#include +#include +#include #include -#include +#include #include #include #include + +#include + #include #include #include +#include +#include +#include + namespace tt::app::launcher { constexpr auto* TAG = "Launcher"; -static uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) { +namespace { + +uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) { if (density == LVGL_UI_DENSITY_COMPACT) { return 0; } else { @@ -29,200 +33,234 @@ static uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) { } } -static int32_t computeButtonMargin(int32_t available_span, int32_t total_button_size) { +int32_t computeButtonMargin(int32_t available_span, int32_t total_button_size) { const int32_t usable = std::max(0, available_span - (3 * total_button_size)); return std::min(usable / 16, total_button_size / 2); } -class LauncherApp final : public App { +void onAppPressed(lv_event_t* e) { + auto* appId = static_cast(lv_event_get_user_data(e)); + uint32_t instance_id = 0; + app_manager_start(appId, &instance_id); +} - static lv_obj_t* createAppButton(lv_obj_t* parent, UiDensity uiDensity, const char* imageFile, const char* appId, int32_t itemMargin, bool isLandscape) { - const auto button_size = lvgl_get_launcher_icon_font_height(); - const auto button_padding = getButtonPadding(uiDensity, button_size); - auto* apps_button = lv_button_create(parent); +lv_obj_t* createAppButton(lv_obj_t* parent, UiDensity uiDensity, const char* imageFile, const char* appId, int32_t itemMargin, bool isLandscape) { + const auto button_size = lvgl_get_launcher_icon_font_height(); + const auto button_padding = getButtonPadding(uiDensity, button_size); + auto* apps_button = lv_button_create(parent); - lv_obj_set_style_pad_all(apps_button, static_cast(button_padding), LV_STATE_DEFAULT); - if (isLandscape) { - lv_obj_set_style_margin_hor(apps_button, itemMargin, LV_STATE_DEFAULT); - } else { - lv_obj_set_style_margin_ver(apps_button, itemMargin, LV_STATE_DEFAULT); - } + lv_obj_set_style_pad_all(apps_button, static_cast(button_padding), LV_STATE_DEFAULT); + if (isLandscape) { + lv_obj_set_style_margin_hor(apps_button, itemMargin, LV_STATE_DEFAULT); + } else { + lv_obj_set_style_margin_ver(apps_button, itemMargin, LV_STATE_DEFAULT); + } - lv_obj_set_style_shadow_width(apps_button, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(apps_button, 0, LV_STATE_DEFAULT); + lv_obj_set_style_shadow_width(apps_button, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(apps_button, 0, LV_STATE_DEFAULT); - // create the image first - auto* button_image = lv_image_create(apps_button); - lv_obj_set_style_text_font(button_image, lvgl_get_launcher_icon_font(), LV_STATE_DEFAULT); - lv_image_set_src(button_image, imageFile); - lv_obj_set_style_text_color(button_image, lv_theme_get_color_primary(button_image), LV_STATE_DEFAULT); - lv_obj_set_style_image_recolor(button_image, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT); - lv_obj_set_style_image_recolor_opa(button_image, LV_OPA_COVER, LV_STATE_DEFAULT); + // create the image first + auto* button_image = lv_image_create(apps_button); + lv_obj_set_style_text_font(button_image, lvgl_get_launcher_icon_font(), LV_STATE_DEFAULT); + lv_image_set_src(button_image, imageFile); + lv_obj_set_style_text_color(button_image, lv_theme_get_color_primary(button_image), LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor(button_image, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor_opa(button_image, LV_OPA_COVER, LV_STATE_DEFAULT); - // Ensure it's square (Material Symbols are slightly wider than tall) - lv_obj_set_size(button_image, button_size, button_size); + // Ensure it's square (Material Symbols are slightly wider than tall) + lv_obj_set_size(button_image, button_size, button_size); - lv_obj_add_event_cb(apps_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)appId); + lv_obj_add_event_cb(apps_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)appId); - return apps_button; - } + return apps_button; +} - static void onAppPressed(lv_event_t* e) { - auto* appId = static_cast(lv_event_get_user_data(e)); - start(appId); - } +bool shouldShowPowerButton() { + bool show_power_button = false; + device_for_each_of_type(&POWER_SUPPLY_TYPE, &show_power_button, [](Device* device, void* context) { + if (device_is_ready(device) && power_supply_supports_power_off(device)) { + *static_cast(context) = true; + return false; // stop iterating + } else { + return true; // continue iterating + } + }); + return show_power_button; +} - static bool shouldShowPowerButton() { - bool show_power_button = false; - device_for_each_of_type(&POWER_SUPPLY_TYPE, &show_power_button, [](Device* device, void* context) { - if (device_is_ready(device) && power_supply_supports_power_off(device)) { - *static_cast(context) = true; - return false; // stop iterating - } else { - return true; // continue iterating - } - }); - return show_power_button; +void onButtonsWrapperResized(lv_event_t* e); + +// The screen object outlives this window's own widgets (lvgl-window-manager deletes and +// recreates only the topmost window's widget on every app switch, not the screen itself), so +// the LV_EVENT_SIZE_CHANGED callback registered on it must be removed once buttons_wrapper is +// destroyed, to avoid a dangling user-data pointer the next time the display rotates while a +// different window is topmost. +void onButtonsWrapperDeleted(lv_event_t* e) { + auto* buttons_wrapper = lv_event_get_target_obj(e); + auto* screen = lv_obj_get_screen(buttons_wrapper); + lv_obj_remove_event_cb_with_user_data(screen, onButtonsWrapperResized, buttons_wrapper); +} + +// Re-applies the flex direction and per-button margins when the display orientation changes +// while the launcher is the visible window (these are decided once at createWidgets() based on +// the resolution at that time, so a later rotation needs this to catch up). +void onButtonsWrapperResized(lv_event_t* e) { + auto* buttons_wrapper = static_cast(lv_event_get_user_data(e)); + const auto* display = lv_obj_get_display(buttons_wrapper); + + const auto button_size = lvgl_get_launcher_icon_font_height(); + const auto button_padding = getButtonPadding(lvgl_get_ui_density(), button_size); + const auto total_button_size = button_size + (button_padding * 2); + + const auto horizontal_px = lv_display_get_horizontal_resolution(display); + const auto vertical_px = lv_display_get_vertical_resolution(display); + const bool is_landscape_display = horizontal_px >= vertical_px; + const auto current_flow = lv_obj_get_style_flex_flow(buttons_wrapper, LV_PART_MAIN); + const bool was_landscape = current_flow == LV_FLEX_FLOW_ROW; + if (is_landscape_display == was_landscape) { + return; } - // The screen object outlives the launcher's views (it's recreated by GuiService::redraw() - // via lv_obj_clean() on every app switch), so the LV_EVENT_SIZE_CHANGED callback registered - // on it must be removed once buttons_wrapper is destroyed, to avoid a dangling user-data - // pointer on the next rotation while a different app is visible. - static void onButtonsWrapperDeleted(lv_event_t* e) { - auto* buttons_wrapper = lv_event_get_target_obj(e); - auto* screen = lv_obj_get_screen(buttons_wrapper); - lv_obj_remove_event_cb_with_user_data(screen, onButtonsWrapperResized, buttons_wrapper); + lv_obj_set_flex_flow(buttons_wrapper, is_landscape_display ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN); + + const int32_t margin = is_landscape_display + ? computeButtonMargin(horizontal_px, total_button_size) + : computeButtonMargin(vertical_px, total_button_size); + + const uint32_t child_count = lv_obj_get_child_count(buttons_wrapper); + for (uint32_t i = 0; i < child_count; i++) { + auto* button = lv_obj_get_child(buttons_wrapper, i); + lv_obj_set_style_margin_hor(button, is_landscape_display ? margin : 0, LV_STATE_DEFAULT); + lv_obj_set_style_margin_ver(button, is_landscape_display ? 0 : margin, LV_STATE_DEFAULT); } +} - // Re-applies the flex direction and per-button margins when the display orientation - // changes while the launcher is the visible app (these are decided once at onShow() - // based on the resolution at that time, so a later rotation needs this to catch up). - static void onButtonsWrapperResized(lv_event_t* e) { - auto* buttons_wrapper = static_cast(lv_event_get_user_data(e)); - const auto* display = lv_obj_get_display(buttons_wrapper); - - const auto button_size = lvgl_get_launcher_icon_font_height(); - const auto button_padding = getButtonPadding(lvgl_get_ui_density(), button_size); - const auto total_button_size = button_size + (button_padding * 2); - - const auto horizontal_px = lv_display_get_horizontal_resolution(display); - const auto vertical_px = lv_display_get_vertical_resolution(display); - const bool is_landscape_display = horizontal_px >= vertical_px; - const auto current_flow = lv_obj_get_style_flex_flow(buttons_wrapper, LV_PART_MAIN); - const bool was_landscape = current_flow == LV_FLEX_FLOW_ROW; - if (is_landscape_display == was_landscape) { - return; - } +void createWidgets(lv_obj_t* parent, void*) { + auto* buttons_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(buttons_wrapper, is_landscape_display ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN); + auto ui_density = lvgl_get_ui_density(); + const auto button_size = lvgl_get_launcher_icon_font_height(); + const auto button_padding = getButtonPadding(ui_density, button_size); + const auto total_button_size = button_size + (button_padding * 2); - const int32_t margin = is_landscape_display - ? computeButtonMargin(horizontal_px, total_button_size) - : computeButtonMargin(vertical_px, total_button_size); + lv_obj_align(buttons_wrapper, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(buttons_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_border_width(buttons_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_grow(buttons_wrapper, 1); - const uint32_t child_count = lv_obj_get_child_count(buttons_wrapper); - for (uint32_t i = 0; i < child_count; i++) { - auto* button = lv_obj_get_child(buttons_wrapper, i); - lv_obj_set_style_margin_hor(button, is_landscape_display ? margin : 0, LV_STATE_DEFAULT); - lv_obj_set_style_margin_ver(button, is_landscape_display ? 0 : margin, LV_STATE_DEFAULT); - } + // Fix for button selection + lv_obj_set_style_pad_all(buttons_wrapper, 6, LV_STATE_DEFAULT); + + const auto* display = lv_obj_get_display(parent); + const auto horizontal_px = lv_display_get_horizontal_resolution(display); + const auto vertical_px = lv_display_get_vertical_resolution(display); + const bool is_landscape_display = horizontal_px >= vertical_px; + if (is_landscape_display) { + lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_ROW); + } else { + lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_COLUMN); } -public: - - void onCreate(AppContext& app) override { - settings::BootSettings boot_properties; - if ( - // Auto-start due to built-in requirement - strcmp(CONFIG_TT_AUTO_START_APP_ID, "") != 0 && - findAppManifestById(CONFIG_TT_AUTO_START_APP_ID) != nullptr - ) { - LOG_I(TAG, "Starting %s", 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 - ) { - LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str()); - start(boot_properties.autoStartAppId); - } else { - // No auto-start, consider running system setup - if (!setup::isCompleted()) { - setup::start(); - } - } + const int32_t margin = is_landscape_display + ? computeButtonMargin(lv_display_get_horizontal_resolution(display), total_button_size) + : computeButtonMargin(lv_display_get_vertical_resolution(display), total_button_size); + + createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_APPS, "AppList", margin, is_landscape_display); + createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_FOLDER, "Files", margin, is_landscape_display); + createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_SETTINGS, "Settings", margin, is_landscape_display); + + // The launcher's container is several levels below the screen, and LVGL only sends + // LV_EVENT_SIZE_CHANGED to the screen object itself on a resolution change - so the + // handler is attached there, with buttons_wrapper passed through as user data. + lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper); + lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr); + + // Some devices (e.g. T-Lora Pager) have no other way to power off, so the + // button stays in the launcher; the confirmation flow lives in the PowerOff app. + if (shouldShowPowerButton()) { + auto* power_button = lv_button_create(parent); + lv_obj_set_style_pad_all(power_button, 8, 0); + lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10); + lv_obj_add_event_cb(power_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)"PowerOff"); + lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN); + + auto* power_label = lv_label_create(power_button); + lv_label_set_text(power_label, LV_SYMBOL_POWER); + lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT); } +} - void onShow(AppContext& app, lv_obj_t* parent) override { - auto* buttons_wrapper = lv_obj_create(parent); +void runAutoStart() { + settings::BootSettings boot_properties; + if ( + // Auto-start due to built-in requirement + strcmp(CONFIG_TT_AUTO_START_APP_ID, "") != 0 && + app_manager_find_manifest(CONFIG_TT_AUTO_START_APP_ID) != nullptr + ) { + LOG_I(TAG, "Starting %s", CONFIG_TT_AUTO_START_APP_ID); + uint32_t app_launch_id; + app_manager_start(CONFIG_TT_AUTO_START_APP_ID, &app_launch_id); + } else if ( + // Auto-start due to user configuration + settings::loadBootSettings(boot_properties) && + !boot_properties.autoStartAppId.empty() && + app_manager_find_manifest(boot_properties.autoStartAppId.c_str()) != nullptr + ) { + LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str()); + uint32_t app_launch_id; + app_manager_start(boot_properties.autoStartAppId.c_str(), &app_launch_id); + } else { + // No auto-start, consider running system setup + if (!setup::isCompleted()) { + setup::start(); + } + } +} - auto ui_density = lvgl_get_ui_density(); - const auto button_size = lvgl_get_launcher_icon_font_height(); - const auto button_padding = getButtonPadding(ui_density, button_size); - const auto total_button_size = button_size + (button_padding * 2); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + runAutoStart(); - lv_obj_align(buttons_wrapper, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_size(buttons_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_border_width(buttons_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_grow(buttons_wrapper, 1); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - // Fix for button selection - lv_obj_set_style_pad_all(buttons_wrapper, 6, LV_STATE_DEFAULT); + WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr); - const auto* display = lv_obj_get_display(parent); - const auto horizontal_px = lv_display_get_horizontal_resolution(display); - const auto vertical_px = lv_display_get_vertical_resolution(display); - const bool is_landscape_display = horizontal_px >= vertical_px; - if (is_landscape_display) { - lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_ROW); - } else { - lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_COLUMN); + // The launcher is meant to stay resident (it's the home screen) - it only gives up its + // thread when app-module's scheduler asks it to (e.g. another new-model app is started). + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; } - - const int32_t margin = is_landscape_display - ? computeButtonMargin(lv_display_get_horizontal_resolution(display), total_button_size) - : computeButtonMargin(lv_display_get_vertical_resolution(display), total_button_size); - - createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_APPS, "AppList", margin, is_landscape_display); - createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_FOLDER, "Files", margin, is_landscape_display); - createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_SETTINGS, "Settings", margin, is_landscape_display); - - // The launcher's container is several levels below the screen, and LVGL only sends - // LV_EVENT_SIZE_CHANGED to the screen object itself on a resolution change - so the - // handler is attached there, with buttons_wrapper passed through as user data. - lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper); - lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr); - - // Some devices (e.g. T-Lora Pager) have no other way to power off, so the - // button stays in the launcher; the confirmation flow lives in the PowerOff app. - if (shouldShowPowerButton()) { - auto* power_button = lv_button_create(parent); - lv_obj_set_style_pad_all(power_button, 8, 0); - lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10); - lv_obj_add_event_cb(power_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)"PowerOff"); - lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN); - - auto* power_label = lv_label_create(power_button); - lv_label_set_text(power_label, LV_SYMBOL_POWER); - lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT); + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); + break; } } -}; -extern const AppManifest manifest = { - .appId = "Launcher", - .appName = "Launcher", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create + window_manager_remove(window); + app_event_unsubscribe(&sub); + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "Launcher", + .name = "Launcher", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; -LaunchId start() { - return app::start(manifest.appId); +// Kept for Tactility/Private/Tactility/app/launcher/Launcher.h's existing declaration (still +// used by the old, unconverted CrashDiagnostics app to return to the launcher after a crash). +uint32_t start() { + uint32_t instance_id = 0; + app_manager_start(manifest.id, &instance_id); + return instance_id; } } // namespace diff --git a/Tactility/Source/app/localesettings/LocaleSettings.cpp b/Tactility/Source/app/localesettings/LocaleSettings.cpp index 50db7e239..3e5373ed0 100644 --- a/Tactility/Source/app/localesettings/LocaleSettings.cpp +++ b/Tactility/Source/app/localesettings/LocaleSettings.cpp @@ -3,12 +3,16 @@ #include #include #include -#include -#include #include #include -#include +#include +#include +#include + +#include + +#include #include #include @@ -23,112 +27,159 @@ constexpr auto* TEXT_RESOURCE_PATH = "/system/app/LocaleSettings/i18n"; constexpr auto* TEXT_RESOURCE_PATH = "system/app/LocaleSettings/i18n"; #endif -extern const AppManifest manifest; +extern const ::AppManifest manifest; + +namespace { -class LocaleSettingsApp final : public App { +struct Context { + uint32_t appInstanceId; tt::i18n::TextResources textResources = tt::i18n::TextResources(TEXT_RESOURCE_PATH); RecursiveMutex mutex; lv_obj_t* languageDropdown = nullptr; bool settingsUpdated = false; std::map languageMap; +}; - std::string getLanguageOptions() const { - std::vector items; - for (int i = 0; i < static_cast(settings::Language::count); i++) { - switch (static_cast(i)) { - case settings::Language::en_GB: - items.push_back(textResources[i18n::Text::EN_GB]); - break; - case settings::Language::en_US: - items.push_back(textResources[i18n::Text::EN_US]); - break; - case settings::Language::fr_FR: - items.push_back(textResources[i18n::Text::FR_FR]); - break; - case settings::Language::nl_BE: - items.push_back(textResources[i18n::Text::NL_BE]); - break; - case settings::Language::nl_NL: - items.push_back(textResources[i18n::Text::NL_NL]); - break; - case settings::Language::count: - break; - } - } - return string::join(items, "\n"); - } - - void updateViews() { - textResources.load(); - std::string language_options = getLanguageOptions(); - lv_dropdown_set_options(languageDropdown, language_options.c_str()); - lv_dropdown_set_selected(languageDropdown, static_cast(settings::getLanguage())); +std::string getLanguageOptions(Context* ctx) { + std::vector items; + for (int i = 0; i < static_cast(settings::Language::count); i++) { + switch (static_cast(i)) { + case settings::Language::en_GB: + items.push_back(ctx->textResources[i18n::Text::EN_GB]); + break; + case settings::Language::en_US: + items.push_back(ctx->textResources[i18n::Text::EN_US]); + break; + case settings::Language::fr_FR: + items.push_back(ctx->textResources[i18n::Text::FR_FR]); + break; + case settings::Language::nl_BE: + items.push_back(ctx->textResources[i18n::Text::NL_BE]); + break; + case settings::Language::nl_NL: + items.push_back(ctx->textResources[i18n::Text::NL_NL]); + break; + case settings::Language::count: + break; + } } + return string::join(items, "\n"); +} - static void onLanguageSet(lv_event_t* event) { - auto* dropdown = static_cast(lv_event_get_target(event)); - auto index = lv_dropdown_get_selected(dropdown); - auto language = static_cast(index); - settings::setLanguage(language); - - auto* self = static_cast(lv_event_get_user_data(event)); - self->updateViews(); - } +void updateViews(Context* ctx) { + ctx->textResources.load(); - static void onRegionChanged(lv_event_t* event) { - auto* self = static_cast(lv_event_get_user_data(event)); - self->settingsUpdated = true; - } + std::string language_options = getLanguageOptions(ctx); + lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str()); + lv_dropdown_set_selected(ctx->languageDropdown, static_cast(settings::getLanguage())); +} -public: +void onLanguageSet(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* dropdown = static_cast(lv_event_get_target(event)); + auto index = lv_dropdown_get_selected(dropdown); + auto language = static_cast(index); + settings::setLanguage(language); - void onShow(AppContext& app, lv_obj_t* parent) override { - textResources.load(); + updateViews(ctx); +} - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); +// Preserved from the pre-conversion code as-is: declared but never wired to any widget there +// either, so this has always been dead code (kept verbatim rather than dropped, since removing +// it would be a functional judgment call outside the scope of this lifecycle-only conversion). +[[maybe_unused]] void onRegionChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->settingsUpdated = true; +} - lvgl::toolbar_create(parent, app); +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + ctx->textResources.load(); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Region & Language"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + // Language + + auto* language_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_width(language_wrapper, LV_PCT(100)); + lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(language_wrapper, 8, 0); + lv_obj_set_style_border_width(language_wrapper, 0, 0); + + auto* languageLabel = lv_label_create(language_wrapper); + lv_label_set_text(languageLabel, ctx->textResources[i18n::Text::LANGUAGE].c_str()); + lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 4, 0); + + ctx->languageDropdown = lv_dropdown_create(language_wrapper); + lv_obj_set_width(ctx->languageDropdown, 150); + lv_obj_align(ctx->languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + std::string language_options = getLanguageOptions(ctx); + lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str()); + lv_dropdown_set_selected(ctx->languageDropdown, static_cast(settings::getLanguage())); + lv_obj_add_event_cb(ctx->languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, ctx); +} - // Language +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx; + ctx.appInstanceId = appInstanceId; - auto* language_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_width(language_wrapper, LV_PCT(100)); - lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(language_wrapper, 8, 0); - lv_obj_set_style_border_width(language_wrapper, 0, 0); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - auto* languageLabel = lv_label_create(language_wrapper); - lv_label_set_text(languageLabel, textResources[i18n::Text::LANGUAGE].c_str()); - lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 4, 0); + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - languageDropdown = lv_dropdown_create(language_wrapper); - lv_obj_set_width(languageDropdown, 150); - lv_obj_align(languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0); - std::string language_options = getLanguageOptions(); - lv_dropdown_set_options(languageDropdown, language_options.c_str()); - lv_dropdown_set_selected(languageDropdown, static_cast(settings::getLanguage())); - lv_obj_add_event_cb(languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, this); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } } -}; -extern const AppManifest manifest = { - .appId = "LocaleSettings", - .appName = "Region & Language", - .appIcon = LVGL_ICON_SHARED_LANGUAGE, - .appCategory = Category::Settings, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); -LaunchId start() { - return app::start(manifest.appId); + return 0; } } // namespace + +extern const ::AppManifest manifest = { + .id = "LocaleSettings", + .name = "Region & Language", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } +}; + +} // namespace tt::app::localesettings diff --git a/Tactility/Source/app/notes/Notes.cpp b/Tactility/Source/app/notes/Notes.cpp index 09fad15b9..5d8a80e49 100644 --- a/Tactility/Source/app/notes/Notes.cpp +++ b/Tactility/Source/app/notes/Notes.cpp @@ -1,228 +1,258 @@ -#include "lvgl/lvgl.h" - -#include +#include #include -#include #include -#include +#include +#include +#include + +#include + #include +#include +#include #include namespace tt::app::notes { constexpr auto* TAG = "Notes"; -constexpr auto* NOTES_FILE_ARGUMENT = "file"; -class NotesApp final : public App { +extern const ::AppManifest manifest; + +namespace { - lv_obj_t* uiCurrentFileName; - lv_obj_t* uiDropDownMenu; - lv_obj_t* uiNoteText; +struct Context { + uint32_t appInstanceId; + + lv_obj_t* uiCurrentFileName = nullptr; + lv_obj_t* uiDropDownMenu = nullptr; + lv_obj_t* uiNoteText = nullptr; std::string filePath; std::string saveBuffer; - LaunchId loadFileLaunchId = 0; - LaunchId saveFileLaunchId = 0; - -#pragma region Main_Events_Functions - - void appNotesEventCb(lv_event_t* e) { - lv_event_code_t code = lv_event_get_code(e); - lv_obj_t* obj = lv_event_get_target_obj(e); - - if (code == LV_EVENT_VALUE_CHANGED) { - if (obj == uiDropDownMenu) { - switch (lv_dropdown_get_selected(obj)) { - case 0: // New - resetFileContent(); - break; - case 1: // Save - if (!filePath.empty()) { - lvgl_lock(); - saveBuffer = lv_textarea_get_text(uiNoteText); - lvgl_unlock(); - saveFile(filePath); - } - break; - case 2: // Save as... + uint32_t loadFileLaunchId = 0; + uint32_t saveFileLaunchId = 0; +}; + + +void resetFileContent(Context* ctx) { + lv_textarea_set_text(ctx->uiNoteText, ""); + ctx->filePath = ""; + ctx->saveBuffer = ""; + lv_label_set_text(ctx->uiCurrentFileName, "Untitled"); +} + +void openFile(Context* ctx, const std::string& path) { + // We might be reading from the SD card, which could share a SPI bus with other devices (display) + file::FileMutexGuard guard(path); + auto data = file::readString(path); + if (data != nullptr) { + lvgl_lock(); + lv_textarea_set_text(ctx->uiNoteText, reinterpret_cast(data.get())); + lv_label_set_text(ctx->uiCurrentFileName, path.c_str()); + lvgl_unlock(); + ctx->filePath = path; + LOG_I(TAG, "Loaded from %s", path.c_str()); + } +} + +bool saveFile(Context* ctx, const std::string& path) { + // We might be writing to SD card, which could share a SPI bus with other devices (display) + bool result = false; + { + file::FileMutexGuard guard(path); + if (file::writeString(path, ctx->saveBuffer.c_str())) { + LOG_I(TAG, "Saved to %s", path.c_str()); + ctx->filePath = path; + result = true; + } + } + return result; +} + +void appNotesEventCb(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* obj = lv_event_get_target_obj(e); + + if (code == LV_EVENT_VALUE_CHANGED) { + if (obj == ctx->uiDropDownMenu) { + switch (lv_dropdown_get_selected(obj)) { + case 0: // New + resetFileContent(ctx); + break; + case 1: // Save + if (!ctx->filePath.empty()) { lvgl_lock(); - saveBuffer = lv_textarea_get_text(uiNoteText); + ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText); lvgl_unlock(); - saveFileLaunchId = fileselection::startForExistingOrNewFile(); - LOG_I(TAG, "launched with id %u", saveFileLaunchId); - break; - case 3: // Load - loadFileLaunchId = fileselection::startForExistingFile(); - LOG_I(TAG, "launched with id %u", loadFileLaunchId); - break; - } - } else { - auto* cont = lv_event_get_current_target_obj(e); - if (obj == cont) return; - if (lv_obj_get_child(cont, 1)) { - saveFileLaunchId = fileselection::startForExistingOrNewFile(); - LOG_I(TAG, "launched with id %u", saveFileLaunchId); - } else { //Reset - resetFileContent(); - } + saveFile(ctx, ctx->filePath); + } + break; + case 2: // Save as... + lvgl_lock(); + ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText); + lvgl_unlock(); + ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId); + LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId); + break; + case 3: // Load + ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId); + LOG_I(TAG, "launched with id %u", ctx->loadFileLaunchId); + break; + } + } else { + auto* cont = lv_event_get_current_target_obj(e); + if (obj == cont) return; + if (lv_obj_get_child(cont, 1)) { + ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId); + LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId); + } else { //Reset + resetFileContent(ctx); } } } +} - void resetFileContent() { - lv_textarea_set_text(uiNoteText, ""); - filePath = ""; - saveBuffer = ""; - lv_label_set_text(uiCurrentFileName, "Untitled"); - } +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); -#pragma region Open_Events_Functions + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - void openFile(const std::string& path) { - // We might be reading from the SD card, which could share a SPI bus with other devices (display) - file::FileMutexGuard guard(path); - auto data = file::readString(path); - if (data != nullptr) { - lvgl_lock(); - lv_textarea_set_text(uiNoteText, reinterpret_cast(data.get())); - lv_label_set_text(uiCurrentFileName, path.c_str()); - lvgl_unlock(); - filePath = path; - LOG_I(TAG, "Loaded from %s", path.c_str()); - } + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Notes"); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + ctx->uiDropDownMenu = lv_dropdown_create(toolbar); + lv_dropdown_set_options(ctx->uiDropDownMenu, LV_SYMBOL_FILE " New File\n" LV_SYMBOL_SAVE " Save\n" LV_SYMBOL_SAVE " Save As...\n" LV_SYMBOL_DIRECTORY " Open File"); + lv_dropdown_set_text(ctx->uiDropDownMenu, "Menu"); + lv_dropdown_set_symbol(ctx->uiDropDownMenu, LV_SYMBOL_DOWN); + lv_dropdown_set_selected_highlight(ctx->uiDropDownMenu, false); + lv_obj_align(ctx->uiDropDownMenu, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->uiDropDownMenu, appNotesEventCb, LV_EVENT_VALUE_CHANGED, ctx); + + lv_obj_t* wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_height(wrapper, LV_PCT(100)); + lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(wrapper, 0, LV_PART_MAIN); + lv_obj_set_style_border_width(wrapper, 0, LV_PART_MAIN); + lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE); + + ctx->uiNoteText = lv_textarea_create(wrapper); + lv_obj_set_width(ctx->uiNoteText, LV_PCT(100)); + lv_obj_set_height(ctx->uiNoteText, LV_PCT(86)); + lv_textarea_set_password_mode(ctx->uiNoteText, false); + if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) { + lv_obj_set_style_bg_color(ctx->uiNoteText, lv_color_hex(0x262626), LV_PART_MAIN); } + lv_textarea_set_placeholder_text(ctx->uiNoteText, "Notes..."); - bool saveFile(const std::string& path) { - // We might be writing to SD card, which could share a SPI bus with other devices (display) - bool result = false; - { - file::FileMutexGuard guard(path); - if (file::writeString(path, saveBuffer.c_str())) { - LOG_I(TAG, "Saved to %s", path.c_str()); - filePath = path; - result = true; - } - } - return result; + lv_obj_t* footer = lv_obj_create(wrapper); + lv_obj_set_flex_flow(footer, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) { + lv_obj_set_style_bg_color(footer, lv_color_hex(0xEEEEEE), LV_PART_MAIN); + lv_obj_set_style_border_width(footer, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(footer, lv_theme_get_color_secondary(footer), LV_PART_MAIN); + lv_obj_set_style_border_side(footer, LV_BORDER_SIDE_TOP, LV_PART_MAIN); + } else { + lv_obj_set_style_bg_color(footer, lv_color_hex(0x262626), LV_PART_MAIN); + lv_obj_set_style_border_width(footer, 0, LV_PART_MAIN); } + lv_obj_set_width(footer, LV_PCT(100)); + lv_obj_set_height(footer, LV_PCT(14)); + lv_obj_set_style_pad_all(footer, 0, LV_PART_MAIN); + lv_obj_remove_flag(footer, LV_OBJ_FLAG_SCROLLABLE); -#pragma endregion Open_Events_Functions + ctx->uiCurrentFileName = lv_label_create(footer); + lv_label_set_long_mode(ctx->uiCurrentFileName, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR); + lv_obj_set_width(ctx->uiCurrentFileName, LV_SIZE_CONTENT); + lv_obj_set_height(ctx->uiCurrentFileName, LV_SIZE_CONTENT); + lv_label_set_text(ctx->uiCurrentFileName, "Untitled"); + lv_obj_align(ctx->uiCurrentFileName, LV_ALIGN_CENTER, 0, 0); - void onCreate(AppContext& appContext) override { - auto parameters = appContext.getParameters(); - std::string file_path; - if (parameters != nullptr && parameters->optString(NOTES_FILE_ARGUMENT, file_path)) { - if (!file_path.empty()) { - filePath = file_path; - } - } + if (!ctx->filePath.empty()) { + openFile(ctx, ctx->filePath); } - void onShow(AppContext& context, lv_obj_t* parent) override { - lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - lv_obj_t* toolbar = lvgl::toolbar_create(parent, context); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - - uiDropDownMenu = lv_dropdown_create(toolbar); - lv_dropdown_set_options(uiDropDownMenu, LV_SYMBOL_FILE " New File\n" LV_SYMBOL_SAVE " Save\n" LV_SYMBOL_SAVE " Save As...\n" LV_SYMBOL_DIRECTORY " Open File"); - lv_dropdown_set_text(uiDropDownMenu, "Menu"); - lv_dropdown_set_symbol(uiDropDownMenu, LV_SYMBOL_DOWN); - lv_dropdown_set_selected_highlight(uiDropDownMenu, false); - lv_obj_align(uiDropDownMenu, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(uiDropDownMenu, - [](lv_event_t* e) { - auto *self = static_cast(lv_event_get_user_data(e)); - self->appNotesEventCb(e); - }, - LV_EVENT_VALUE_CHANGED, - this - ); - - lv_obj_t* wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_height(wrapper, LV_PCT(100)); - lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN); - lv_obj_set_style_pad_row(wrapper, 0, LV_PART_MAIN); - lv_obj_set_style_border_width(wrapper, 0, LV_PART_MAIN); - lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE); - - uiNoteText = lv_textarea_create(wrapper); - lv_obj_set_width(uiNoteText, LV_PCT(100)); - lv_obj_set_height(uiNoteText, LV_PCT(86)); - lv_textarea_set_password_mode(uiNoteText, false); - if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) { - lv_obj_set_style_bg_color(uiNoteText, lv_color_hex(0x262626), LV_PART_MAIN); - } - lv_textarea_set_placeholder_text(uiNoteText, "Notes..."); - - lv_obj_t* footer = lv_obj_create(wrapper); - lv_obj_set_flex_flow(footer, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) { - lv_obj_set_style_bg_color(footer, lv_color_hex(0xEEEEEE), LV_PART_MAIN); - lv_obj_set_style_border_width(footer, 1, LV_PART_MAIN); - lv_obj_set_style_border_color(footer, lv_theme_get_color_secondary(footer), LV_PART_MAIN); - lv_obj_set_style_border_side(footer, LV_BORDER_SIDE_TOP, LV_PART_MAIN); - } else { - lv_obj_set_style_bg_color(footer, lv_color_hex(0x262626), LV_PART_MAIN); - lv_obj_set_style_border_width(footer, 0, LV_PART_MAIN); - } - lv_obj_set_width(footer, LV_PCT(100)); - lv_obj_set_height(footer, LV_PCT(14)); - lv_obj_set_style_pad_all(footer, 0, LV_PART_MAIN); - lv_obj_remove_flag(footer, LV_OBJ_FLAG_SCROLLABLE); - - uiCurrentFileName = lv_label_create(footer); - lv_label_set_long_mode(uiCurrentFileName, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR); - lv_obj_set_width(uiCurrentFileName, LV_SIZE_CONTENT); - lv_obj_set_height(uiCurrentFileName, LV_SIZE_CONTENT); - lv_label_set_text(uiCurrentFileName, "Untitled"); - lv_obj_align(uiCurrentFileName, LV_ALIGN_CENTER, 0, 0); - - if (!filePath.empty()) { - openFile(filePath); - } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + + Context ctx {}; + ctx.appInstanceId = appInstanceId; + if (argc > 0 && argv[0][0] != '\0') { + ctx.filePath = argv[0]; } - void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr resultData) override { - LOG_I(TAG, "Result for launch id %u", launchId); - if (launchId == loadFileLaunchId) { - loadFileLaunchId = 0; - if (result == Result::Ok && resultData != nullptr) { - auto path = fileselection::getResultPath(*resultData); - openFile(path); - } - } else if (launchId == saveFileLaunchId) { - saveFileLaunchId = 0; - if (result == Result::Ok && resultData != nullptr) { - auto path = fileselection::getResultPath(*resultData); - // Must re-open file, because UI was cleared after opening other app - if (saveFile(path)) { - openFile(path); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + LOG_I(TAG, "Result for launch id %u", event.result.launch_id); + if (event.result.launch_id == ctx.loadFileLaunchId) { + ctx.loadFileLaunchId = 0; + if (event.result.result == 0 /* Ok */) { + auto path = fileselection::getLastPath(); + if (!path.empty()) { + openFile(&ctx, path); + } + } + } else if (event.result.launch_id == ctx.saveFileLaunchId) { + ctx.saveFileLaunchId = 0; + if (event.result.result == 0 /* Ok */) { + auto path = fileselection::getLastPath(); + // Must re-open file, because the UI was cleared after opening the dialog. + if (!path.empty() && saveFile(&ctx, path)) { + openFile(&ctx, path); + } + } } - } + app_manager_stop(event.result.launch_id); + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "Notes", - .appName = "Notes", - .appIcon = LVGL_ICON_SHARED_EDIT_NOTE, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); -LaunchId start(const std::string& filePath) { - auto parameters = std::make_shared(); - parameters->putString(NOTES_FILE_ARGUMENT, filePath); - return app::start(manifest.appId, parameters); + return 0; } -} // namespace tt::app::notes \ No newline at end of file +} // namespace + +void start(const std::string& filePath) { + const char* argv[] = { filePath.c_str() }; + uint32_t instanceId = 0; + app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId); +} + +extern const ::AppManifest manifest = { + .id = "Notes", + .name = "Notes", + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } +}; + +} // namespace tt::app::notes diff --git a/Tactility/Source/app/power/Power.cpp b/Tactility/Source/app/power/Power.cpp index 03708a6e3..a007ac4ea 100644 --- a/Tactility/Source/app/power/Power.cpp +++ b/Tactility/Source/app/power/Power.cpp @@ -1,15 +1,18 @@ -#include #include -#include -#include #include +#include +#include +#include + +#include + #include #include #include #include -#include +#include #include @@ -17,28 +20,16 @@ namespace tt::app::power { #define TAG "power" -extern const AppManifest manifest; - -class PowerApp; - -/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */ -std::shared_ptr optApp() { - auto appContext = getCurrentAppContext(); - if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) { - return std::static_pointer_cast(appContext->getApp()); - } else { - return nullptr; - } -} +extern const ::AppManifest manifest; namespace { + constexpr PowerSupplyProperty DISPLAYED_PROPERTIES[] = { POWER_SUPPLY_PROP_IS_CHARGING, POWER_SUPPLY_PROP_VOLTAGE, POWER_SUPPLY_PROP_CAPACITY, POWER_SUPPLY_PROP_CURRENT, }; -} // namespace struct PropertyWidget { PowerSupplyProperty property; @@ -52,212 +43,240 @@ struct DeviceEntry { std::vector propertyWidgets; }; -class PowerApp : public App { +struct Context { + uint32_t appInstanceId; + std::unique_ptr timer; + std::vector entries; +}; - Timer update_timer = Timer(Timer::Type::Periodic, millis_to_ticks(1000),[]() { onTimer(); }); - std::vector entries; +bool collectDevice(::Device* device, void* context) { + auto* devices = static_cast*>(context); + devices->push_back(device); + return true; +} - static void onTimer() { - auto app = optApp(); - if (app != nullptr) { - app->updateUi(); - } +void setPropertyLabelText(lv_obj_t* label, PowerSupplyProperty property, const PowerSupplyPropertyValue& value) { + switch (property) { + case POWER_SUPPLY_PROP_IS_CHARGING: + lv_label_set_text_fmt(label, "Charging: %s", value.int_value ? "yes" : "no"); + break; + case POWER_SUPPLY_PROP_VOLTAGE: + lv_label_set_text_fmt(label, "Battery voltage: %d mV", value.int_value); + break; + case POWER_SUPPLY_PROP_CAPACITY: + lv_label_set_text_fmt(label, "Charge level: %d%%", value.int_value); + break; + case POWER_SUPPLY_PROP_CURRENT: + lv_label_set_text_fmt(label, "Current: %d mA", value.int_value); + break; } +} - static bool collectDevice(::Device* device, void* context) { - auto* devices = static_cast*>(context); - devices->push_back(device); - return true; +void updateUi(Context* ctx) { + if (ctx->entries.empty()) { + return; } - void onPowerEnabledChanged(lv_event_t* event) { - lv_event_code_t code = lv_event_get_code(event); - auto* enable_switch = static_cast(lv_event_get_target(event)); - if (code == LV_EVENT_VALUE_CHANGED) { - bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED); - auto* device = static_cast<::Device*>(lv_event_get_user_data(event)); + lvgl_lock(); - if (power_supply_is_allowed_to_charge(device) != is_on) { - power_supply_set_allowed_to_charge(device, is_on); - updateUi(); - } + for (auto& entry : ctx->entries) { + if (entry.enableSwitch != nullptr) { + lv_obj_set_state(entry.enableSwitch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(entry.device)); } - } - static void onPowerEnabledChangedCallback(lv_event_t* event) { - auto app = optApp(); - if (app != nullptr) { - app->onPowerEnabledChanged(event); + if (entry.quickChargeSwitch != nullptr) { + lv_obj_set_state(entry.quickChargeSwitch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(entry.device)); } - } - - void onQuickChargeChanged(lv_event_t* event) { - lv_event_code_t code = lv_event_get_code(event); - auto* qc_switch = static_cast(lv_event_get_target(event)); - if (code == LV_EVENT_VALUE_CHANGED) { - bool is_on = lv_obj_has_state(qc_switch, LV_STATE_CHECKED); - auto* device = static_cast<::Device*>(lv_event_get_user_data(event)); - if (power_supply_is_quick_charge_enabled(device) != is_on) { - power_supply_set_quick_charge_enabled(device, is_on); - updateUi(); + PowerSupplyPropertyValue value; + for (auto& widget : entry.propertyWidgets) { + if (power_supply_get_property(entry.device, widget.property, &value) == ERROR_NONE) { + setPropertyLabelText(widget.label, widget.property, value); } } } - static void onQuickChargeChangedCallback(lv_event_t* event) { - auto app = optApp(); - if (app != nullptr) { - app->onQuickChargeChanged(event); - } - } + lvgl_unlock(); +} - static void setPropertyLabelText(lv_obj_t* label, PowerSupplyProperty property, const PowerSupplyPropertyValue& value) { - switch (property) { - case POWER_SUPPLY_PROP_IS_CHARGING: - lv_label_set_text_fmt(label, "Charging: %s", value.int_value ? "yes" : "no"); - break; - case POWER_SUPPLY_PROP_VOLTAGE: - lv_label_set_text_fmt(label, "Battery voltage: %d mV", value.int_value); - break; - case POWER_SUPPLY_PROP_CAPACITY: - lv_label_set_text_fmt(label, "Charge level: %d%%", value.int_value); - break; - case POWER_SUPPLY_PROP_CURRENT: - lv_label_set_text_fmt(label, "Current: %d mA", value.int_value); - break; - } - } +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - void updateUi() { - if (entries.empty()) { - return; +void onPowerEnabledChanged(lv_event_t* event) { + lv_event_code_t code = lv_event_get_code(event); + auto* enable_switch = lv_event_get_target_obj(event); + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* device = static_cast<::Device*>(lv_obj_get_user_data(enable_switch)); + if (code == LV_EVENT_VALUE_CHANGED) { + bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED); + + if (power_supply_is_allowed_to_charge(device) != is_on) { + power_supply_set_allowed_to_charge(device, is_on); + updateUi(ctx); } + } +} - lvgl_lock(); - - for (auto& entry : entries) { - if (entry.enableSwitch != nullptr) { - lv_obj_set_state(entry.enableSwitch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(entry.device)); - } - - if (entry.quickChargeSwitch != nullptr) { - lv_obj_set_state(entry.quickChargeSwitch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(entry.device)); - } - - PowerSupplyPropertyValue value; - for (auto& widget : entry.propertyWidgets) { - if (power_supply_get_property(entry.device, widget.property, &value) == ERROR_NONE) { - setPropertyLabelText(widget.label, widget.property, value); - } - } +void onQuickChargeChanged(lv_event_t* event) { + lv_event_code_t code = lv_event_get_code(event); + auto* qc_switch = lv_event_get_target_obj(event); + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* device = static_cast<::Device*>(lv_obj_get_user_data(qc_switch)); + if (code == LV_EVENT_VALUE_CHANGED) { + bool is_on = lv_obj_has_state(qc_switch, LV_STATE_CHECKED); + + if (power_supply_is_quick_charge_enabled(device) != is_on) { + power_supply_set_quick_charge_enabled(device, is_on); + updateUi(ctx); } - - lvgl_unlock(); } +} -public: +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - void onCreate(AppContext& app) override {} + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + auto* toolbar = lvgl_toolbar_create(parent, "Power"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - lvgl::toolbar_create(parent, app); + std::vector<::Device*> devices; + device_for_each_of_type(&POWER_SUPPLY_TYPE, &devices, collectDevice); - std::vector<::Device*> devices; - device_for_each_of_type(&POWER_SUPPLY_TYPE, &devices, collectDevice); + if (devices.empty()) { + return; + } - if (devices.empty()) { - return; + lv_obj_t* wrapper = lv_obj_create(parent); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_style_border_width(wrapper, 0, 0); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + + ctx->entries.clear(); + ctx->entries.reserve(devices.size()); + + for (size_t i = 0; i < devices.size(); i++) { + ::Device* device = devices[i]; + + DeviceEntry entry; + entry.device = device; + + lv_obj_t* header = lv_label_create(wrapper); + lv_label_set_text_fmt(header, "%s:", device->name); + + if (power_supply_supports_charge_control(device)) { + lv_obj_t* switch_container = lv_obj_create(wrapper); + lv_obj_set_width(switch_container, LV_PCT(100)); + lv_obj_set_height(switch_container, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(switch_container, 0, 0); + lv_obj_set_style_pad_gap(switch_container, 0, 0); + lvgl::obj_set_style_bg_invisible(switch_container); + + lv_obj_t* label = lv_label_create(switch_container); + lv_label_set_text(label, "Charging enabled"); + lv_obj_set_align(label, LV_ALIGN_LEFT_MID); + + lv_obj_t* enable_switch = lv_switch_create(switch_container); + lv_obj_set_user_data(enable_switch, device); + lv_obj_add_event_cb(enable_switch, onPowerEnabledChanged, LV_EVENT_VALUE_CHANGED, ctx); + lv_obj_set_align(enable_switch, LV_ALIGN_RIGHT_MID); + lv_obj_set_state(enable_switch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(device)); + entry.enableSwitch = enable_switch; } - lv_obj_t* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_style_border_width(wrapper, 0, 0); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - - entries.clear(); - entries.reserve(devices.size()); - - for (size_t i = 0; i < devices.size(); i++) { - ::Device* device = devices[i]; - - DeviceEntry entry; - entry.device = device; - - lv_obj_t* header = lv_label_create(wrapper); - lv_label_set_text_fmt(header, "%s:", device->name); - - if (power_supply_supports_charge_control(device)) { - lv_obj_t* switch_container = lv_obj_create(wrapper); - lv_obj_set_width(switch_container, LV_PCT(100)); - lv_obj_set_height(switch_container, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(switch_container, 0, 0); - lv_obj_set_style_pad_gap(switch_container, 0, 0); - lvgl::obj_set_style_bg_invisible(switch_container); - - lv_obj_t* label = lv_label_create(switch_container); - lv_label_set_text(label, "Charging enabled"); - lv_obj_set_align(label, LV_ALIGN_LEFT_MID); - - lv_obj_t* enable_switch = lv_switch_create(switch_container); - lv_obj_add_event_cb(enable_switch, onPowerEnabledChangedCallback, LV_EVENT_VALUE_CHANGED, device); - lv_obj_set_align(enable_switch, LV_ALIGN_RIGHT_MID); - lv_obj_set_state(enable_switch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(device)); - entry.enableSwitch = enable_switch; - } - - if (power_supply_supports_quick_charge(device)) { - lv_obj_t* qc_container = lv_obj_create(wrapper); - lv_obj_set_width(qc_container, LV_PCT(100)); - lv_obj_set_height(qc_container, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(qc_container, 0, 0); - lv_obj_set_style_pad_gap(qc_container, 0, 0); - lvgl::obj_set_style_bg_invisible(qc_container); - - lv_obj_t* label = lv_label_create(qc_container); - lv_label_set_text(label, "Quick charge"); - lv_obj_set_align(label, LV_ALIGN_LEFT_MID); - - lv_obj_t* qc_switch = lv_switch_create(qc_container); - lv_obj_add_event_cb(qc_switch, onQuickChargeChangedCallback, LV_EVENT_VALUE_CHANGED, device); - lv_obj_set_align(qc_switch, LV_ALIGN_RIGHT_MID); - lv_obj_set_state(qc_switch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(device)); - entry.quickChargeSwitch = qc_switch; - } + if (power_supply_supports_quick_charge(device)) { + lv_obj_t* qc_container = lv_obj_create(wrapper); + lv_obj_set_width(qc_container, LV_PCT(100)); + lv_obj_set_height(qc_container, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(qc_container, 0, 0); + lv_obj_set_style_pad_gap(qc_container, 0, 0); + lvgl::obj_set_style_bg_invisible(qc_container); + + lv_obj_t* label = lv_label_create(qc_container); + lv_label_set_text(label, "Quick charge"); + lv_obj_set_align(label, LV_ALIGN_LEFT_MID); + + lv_obj_t* qc_switch = lv_switch_create(qc_container); + lv_obj_set_user_data(qc_switch, device); + lv_obj_add_event_cb(qc_switch, onQuickChargeChanged, LV_EVENT_VALUE_CHANGED, ctx); + lv_obj_set_align(qc_switch, LV_ALIGN_RIGHT_MID); + lv_obj_set_state(qc_switch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(device)); + entry.quickChargeSwitch = qc_switch; + } - PowerSupplyPropertyValue value; - for (auto property : DISPLAYED_PROPERTIES) { - if (power_supply_get_property(device, property, &value) == ERROR_NONE) { - lv_obj_t* label = lv_label_create(wrapper); - lv_obj_set_style_margin_left(label, 24, LV_STATE_DEFAULT); - setPropertyLabelText(label, property, value); - entry.propertyWidgets.push_back({ property, label }); - } + PowerSupplyPropertyValue value; + for (auto property : DISPLAYED_PROPERTIES) { + if (power_supply_get_property(device, property, &value) == ERROR_NONE) { + lv_obj_t* label = lv_label_create(wrapper); + lv_obj_set_style_margin_left(label, 24, LV_STATE_DEFAULT); + setPropertyLabelText(label, property, value); + entry.propertyWidgets.push_back({ property, label }); } - - entries.push_back(entry); } - update_timer.start(); + ctx->entries.push_back(entry); } +} - void onHide(AppContext& app) override { - update_timer.stop(); - entries.clear(); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + // Runs for this app instance's whole lifetime, mirroring GpsSettings/SystemInfo - there's no + // push notification for power-supply property changes, so this is the only way this screen + // finds out about them. + ctx.timer = std::make_unique(Timer::Type::Periodic, millis_to_ticks(1000), [&ctx] { + updateUi(&ctx); + }); + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + ctx.timer->start(); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } } -}; -extern const AppManifest manifest = { - .appId = "Power", - .appName = "Power", - .appIcon = LVGL_ICON_SHARED_ELECTRIC_BOLT, - .appCategory = Category::Settings, - .createApp = create + ctx.timer->stop(); + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "Power", + .name = "Power", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace diff --git a/Tactility/Source/app/poweroff/PowerOff.cpp b/Tactility/Source/app/poweroff/PowerOff.cpp index e6166e338..088aeb52f 100644 --- a/Tactility/Source/app/poweroff/PowerOff.cpp +++ b/Tactility/Source/app/poweroff/PowerOff.cpp @@ -1,12 +1,12 @@ #include "Tactility/Tactility.h" #include "tactility/drivers/display.h" +#include +#include +#include -#include -#include -#include +#include -#include #include #include #include @@ -14,121 +14,164 @@ namespace tt::app::poweroff { -extern const AppManifest manifest; +extern const ::AppManifest manifest; -class PowerOffApp final : public App { +namespace { - static void showPoweredOffScreen() { - auto* screen = lv_obj_create(nullptr); - lv_obj_set_style_bg_color(screen, lv_color_white(), 0); - lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); +struct Context { + uint32_t appInstanceId; +}; + + +void showPoweredOffScreen() { + auto* screen = lv_obj_create(nullptr); + lv_obj_set_style_bg_color(screen, lv_color_white(), 0); + lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - auto* title = lv_label_create(screen); - lv_label_set_text(title, "Tactility"); - lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0); - lv_obj_set_style_text_color(title, lv_color_black(), 0); + auto* title = lv_label_create(screen); + lv_label_set_text(title, "Tactility"); + lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + lv_obj_set_style_text_color(title, lv_color_black(), 0); - auto* subtitle = lv_label_create(screen); - lv_label_set_text(subtitle, "Powered off"); - lv_obj_set_style_text_color(subtitle, lv_color_black(), 0); + auto* subtitle = lv_label_create(screen); + lv_label_set_text(subtitle, "Powered off"); + lv_obj_set_style_text_color(subtitle, lv_color_black(), 0); - lv_screen_load(screen); + lv_screen_load(screen); +} + +bool anyDeviceSupportsPowerOff() { + bool any_supported = false; + device_for_each_of_type(&POWER_SUPPLY_TYPE, &any_supported, [](Device* device, void* context) { + if (device_is_ready(device) && power_supply_supports_power_off(device)) { + *static_cast(context) = true; + return false; + } + return true; + }); + return any_supported; +} + +void onYesPressed(lv_event_t* /*event*/) { + if (!anyDeviceSupportsPowerOff()) { + return; } - static bool anyDeviceSupportsPowerOff() { - bool any_supported = false; - device_for_each_of_type(&POWER_SUPPLY_TYPE, &any_supported, [](Device* device, void* context) { + Device* display; + error_t error = device_get_first_by_type(&DISPLAY_TYPE, &display); + // TODO: remove this logic path when all displays have been migrated to kernel display drivers + if (error != ERROR_NONE) { + // No display, power off now + device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) { if (device_is_ready(device) && power_supply_supports_power_off(device)) { - *static_cast(context) = true; - return false; + power_supply_power_off(device); } return true; }); - return any_supported; + return; } - static void onYesPressed(lv_event_t* /*event*/) { - if (!anyDeviceSupportsPowerOff()) { - return; - } - - Device* display; - error_t error = device_get_first_by_type(&DISPLAY_TYPE, &display); - // TODO: remove this logic path when all displays have been migrated to kernel display drivers - if (error != ERROR_NONE) { - // No display, power off now - device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) { - if (device_is_ready(device) && power_supply_supports_power_off(device)) { - power_supply_power_off(device); - } - return true; - }); - return; + bool is_slow_refresh = display_has_capability(display, DISPLAY_CAPABILITY_SLOW_REFRESH); + if (is_slow_refresh) { + auto* lvgl_display = lv_display_get_default(); + showPoweredOffScreen(); + if (lvgl_display != nullptr) { + lv_refr_now(lvgl_display); } + } - bool is_slow_refresh = display_has_capability(display, DISPLAY_CAPABILITY_SLOW_REFRESH); + getMainDispatcher().dispatch([is_slow_refresh] { + // Not necessary for LilyGO Paper S3, but other drivers with async rendering might need us to wait a bit. if (is_slow_refresh) { - auto* lvgl_display = lv_display_get_default(); - showPoweredOffScreen(); - if (lvgl_display != nullptr) { - lv_refr_now(lvgl_display); - } + vTaskDelay(pdMS_TO_TICKS(2000)); } - - getMainDispatcher().dispatch([is_slow_refresh] { - // Not necessary for LilyGO Paper S3, but other drivers with async rendering might need us to wait a bit. - if (is_slow_refresh) { - vTaskDelay(pdMS_TO_TICKS(2000)); + device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) { + if (device_is_ready(device) && power_supply_supports_power_off(device)) { + power_supply_power_off(device); } - device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) { - if (device_is_ready(device) && power_supply_supports_power_off(device)) { - power_supply_power_off(device); - } - return true; - }); + return true; }); + }); +} + +void onNoPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + auto* label = lv_label_create(parent); + lv_label_set_text(label, "Power off?"); + lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + + auto* button_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_size(button_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_border_width(button_wrapper, 0, 0); + lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + auto* yes_button = lv_button_create(button_wrapper); + auto* yes_label = lv_label_create(yes_button); + lv_label_set_text(yes_label, "Yes"); + lv_obj_add_event_cb(yes_button, onYesPressed, LV_EVENT_SHORT_CLICKED, nullptr); + + auto* no_button = lv_button_create(button_wrapper); + auto* no_label = lv_label_create(no_button); + lv_label_set_text(no_label, "No"); + lv_obj_add_event_cb(no_button, onNoPressed, LV_EVENT_SHORT_CLICKED, ctx); +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } } - static void onNoPressed(lv_event_t* /*event*/) { - stop(manifest.appId); - } - -public: - - void onShow(AppContext&, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + window_manager_remove(window); + app_event_unsubscribe(&sub); - auto* label = lv_label_create(parent); - lv_label_set_text(label, "Power off?"); - lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_LARGE), 0); + return 0; +} - auto* button_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_size(button_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_border_width(button_wrapper, 0, 0); - lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - auto* yes_button = lv_button_create(button_wrapper); - auto* yes_label = lv_label_create(yes_button); - lv_label_set_text(yes_label, "Yes"); - lv_obj_add_event_cb(yes_button, onYesPressed, LV_EVENT_SHORT_CLICKED, nullptr); - - auto* no_button = lv_button_create(button_wrapper); - auto* no_label = lv_label_create(no_button); - lv_label_set_text(no_label, "No"); - lv_obj_add_event_cb(no_button, onNoPressed, LV_EVENT_SHORT_CLICKED, nullptr); - } -}; +} // namespace -extern const AppManifest manifest = { - .appId = "PowerOff", - .appName = "Power Off", - .appIcon = LVGL_ICON_SHARED_POWER_SETTINGS_NEW, - .appCategory = Category::System, - .appFlags = AppManifest::Flags::HideStatusBar | AppManifest::Flags::Hidden, - .createApp = create +extern const ::AppManifest manifest = { + .id = "PowerOff", + .name = "Power Off", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } // namespace diff --git a/Tactility/Source/app/screenshot/Screenshot.cpp b/Tactility/Source/app/screenshot/Screenshot.cpp index 0d2d50b7d..5532580fd 100644 --- a/Tactility/Source/app/screenshot/Screenshot.cpp +++ b/Tactility/Source/app/screenshot/Screenshot.cpp @@ -4,100 +4,77 @@ #if TT_FEATURE_SCREENSHOT_ENABLED #include -#include -#include #include -#include #include #include #include +#include +#include +#include + +#include + #include +#include #include -#include +#include namespace tt::app::screenshot { constexpr auto* TAG = "Screenshot"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; -class ScreenshotApp final : public App { +namespace { +struct Context { + uint32_t appInstanceId; lv_obj_t* modeDropdown = nullptr; lv_obj_t* pathTextArea = nullptr; lv_obj_t* startStopButtonLabel = nullptr; lv_obj_t* timerWrapper = nullptr; lv_obj_t* delayTextArea = nullptr; std::unique_ptr updateTimer; - - void createTimerSettingsWidgets(lv_obj_t* parent); - void createModeSettingWidgets(lv_obj_t* parent); - void createFilePathWidgets(lv_obj_t* parent); - - void updateScreenshotMode(); - -public: - - ScreenshotApp(); - ~ScreenshotApp() override; - - void onShow(AppContext& app, lv_obj_t* parent) override; - void onStartPressed(); - void onModeSet(); - void onTimerTick(); }; -/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */ -std::shared_ptr optApp() { - auto appContext = getCurrentAppContext(); - if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) { - return std::static_pointer_cast(appContext->getApp()); - } else { - return nullptr; - } -} - -static void onStartPressedCallback(lv_event_t* event) { - auto app = optApp(); - if (app != nullptr) { - app->onStartPressed(); +void updateScreenshotMode(Context* ctx) { + auto service = service::screenshot::optScreenshotService(); + if (service == nullptr) { + LOG_E(TAG, "Service not found/running"); + return; } -} -static void onModeSetCallback(lv_event_t* event) { - auto app = optApp(); - if (app != nullptr) { - app->onModeSet(); + lv_obj_t* label = ctx->startStopButtonLabel; + if (service->isTaskStarted()) { + lv_label_set_text(label, "Stop"); + } else { + lv_label_set_text(label, "Start"); } -} - -ScreenshotApp::ScreenshotApp() { - updateTimer = std::make_unique(Timer::Type::Periodic, 500 / portTICK_PERIOD_MS, [this] { - onTimerTick(); - }); -} -ScreenshotApp::~ScreenshotApp() { - if (updateTimer->isRunning()) { - updateTimer->stop(); + uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown); + if (selected == 0) { // Timer + lv_obj_remove_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN); } } -void ScreenshotApp::onTimerTick() { - if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) { - updateScreenshotMode(); - lvgl_unlock(); - } +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); } -void ScreenshotApp::onModeSet() { - updateScreenshotMode(); -} +void onStartPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); -void ScreenshotApp::onStartPressed() { auto service = service::screenshot::optScreenshotService(); if (service == nullptr) { LOG_E(TAG, "Service not found/running"); @@ -108,11 +85,11 @@ void ScreenshotApp::onStartPressed() { LOG_I(TAG, "Stop screenshot"); service->stop(); } else { - uint32_t selected = lv_dropdown_get_selected(modeDropdown); - const char* path = lv_textarea_get_text(pathTextArea); + uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown); + const char* path = lv_textarea_get_text(ctx->pathTextArea); if (selected == 0) { LOG_I(TAG, "Start timed screenshots"); - const char* delay_text = lv_textarea_get_text(delayTextArea); + const char* delay_text = lv_textarea_get_text(ctx->delayTextArea); int delay = atoi(delay_text); if (delay > 0) { service->startTimed(path, delay, 1); @@ -125,33 +102,15 @@ void ScreenshotApp::onStartPressed() { } } - updateScreenshotMode(); + updateScreenshotMode(ctx); } -void ScreenshotApp::updateScreenshotMode() { - auto service = service::screenshot::optScreenshotService(); - if (service == nullptr) { - LOG_E(TAG, "Service not found/running"); - return; - } - - lv_obj_t* label = startStopButtonLabel; - if (service->isTaskStarted()) { - lv_label_set_text(label, "Stop"); - } else { - lv_label_set_text(label, "Start"); - } - - uint32_t selected = lv_dropdown_get_selected(modeDropdown); - if (selected == 0) { // Timer - lv_obj_remove_flag(timerWrapper, LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(timerWrapper, LV_OBJ_FLAG_HIDDEN); - } +void onModeSet(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + updateScreenshotMode(ctx); } - -void ScreenshotApp::createModeSettingWidgets(lv_obj_t* parent) { +void createModeSettingWidgets(Context* ctx, lv_obj_t* parent) { auto service = service::screenshot::optScreenshotService(); if (service == nullptr) { LOG_E(TAG, "Service not found/running"); @@ -167,23 +126,23 @@ void ScreenshotApp::createModeSettingWidgets(lv_obj_t* parent) { lv_label_set_text(mode_label, "Mode:"); lv_obj_align(mode_label, LV_ALIGN_LEFT_MID, 0, 0); - modeDropdown = lv_dropdown_create(mode_wrapper); - lv_dropdown_set_options(modeDropdown, "Timer\nApp start"); - lv_obj_align_to(modeDropdown, mode_label, LV_ALIGN_OUT_RIGHT_MID, 8, 0); - lv_obj_add_event_cb(modeDropdown, onModeSetCallback, LV_EVENT_VALUE_CHANGED, nullptr); + ctx->modeDropdown = lv_dropdown_create(mode_wrapper); + lv_dropdown_set_options(ctx->modeDropdown, "Timer\nApp start"); + lv_obj_align_to(ctx->modeDropdown, mode_label, LV_ALIGN_OUT_RIGHT_MID, 8, 0); + lv_obj_add_event_cb(ctx->modeDropdown, onModeSet, LV_EVENT_VALUE_CHANGED, ctx); service::screenshot::Mode mode = service->getMode(); if (mode == service::screenshot::Mode::Apps) { - lv_dropdown_set_selected(modeDropdown, 1); + lv_dropdown_set_selected(ctx->modeDropdown, 1); } auto* button = lv_button_create(mode_wrapper); lv_obj_align(button, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(button, &onStartPressedCallback, LV_EVENT_SHORT_CLICKED, nullptr); - startStopButtonLabel = lv_label_create(button); - lv_obj_align(startStopButtonLabel, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(button, onStartPressed, LV_EVENT_SHORT_CLICKED, ctx); + ctx->startStopButtonLabel = lv_label_create(button); + lv_obj_align(ctx->startStopButtonLabel, LV_ALIGN_CENTER, 0, 0); } -void ScreenshotApp::createFilePathWidgets(lv_obj_t* parent) { +void createFilePathWidgets(Context* ctx, lv_obj_t* parent) { auto* path_wrapper = lv_obj_create(parent); lv_obj_set_size(path_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_pad_all(path_wrapper, 0, 0); @@ -198,29 +157,29 @@ void ScreenshotApp::createFilePathWidgets(lv_obj_t* parent) { lv_label_set_text(path_label, "Path:"); lv_obj_align(path_label, LV_ALIGN_LEFT_MID, 0, 0); - pathTextArea = lv_textarea_create(path_wrapper); - lv_textarea_set_one_line(pathTextArea, true); - lv_obj_set_flex_grow(pathTextArea, 1); + ctx->pathTextArea = lv_textarea_create(path_wrapper); + lv_textarea_set_one_line(ctx->pathTextArea, true); + lv_obj_set_flex_grow(ctx->pathTextArea, 1); if (kernel::getPlatform() == kernel::PlatformEsp) { std::string sdcard_path; if (findFirstMountedSdCardPath(sdcard_path)) { std::string lvgl_mount_path = lvgl::PATH_PREFIX + sdcard_path + "/screenshots"; - lv_textarea_set_text(pathTextArea, lvgl_mount_path.c_str()); + lv_textarea_set_text(ctx->pathTextArea, lvgl_mount_path.c_str()); } else { - lv_textarea_set_text(pathTextArea, "Error: no SD card"); + lv_textarea_set_text(ctx->pathTextArea, "Error: no SD card"); } } else { // PC - lv_textarea_set_text(pathTextArea, lvgl::PATH_PREFIX); + lv_textarea_set_text(ctx->pathTextArea, lvgl::PATH_PREFIX); } } -void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) { - timerWrapper = lv_obj_create(parent); - lv_obj_set_size(timerWrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(timerWrapper, 0, 0); - lv_obj_set_style_border_width(timerWrapper, 0, 0); +void createTimerSettingsWidgets(Context* ctx, lv_obj_t* parent) { + ctx->timerWrapper = lv_obj_create(parent); + lv_obj_set_size(ctx->timerWrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ctx->timerWrapper, 0, 0); + lv_obj_set_style_border_width(ctx->timerWrapper, 0, 0); - auto* delay_wrapper = lv_obj_create(timerWrapper); + auto* delay_wrapper = lv_obj_create(ctx->timerWrapper); lv_obj_set_size(delay_wrapper, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_pad_all(delay_wrapper, 0, 0); lv_obj_set_style_border_width(delay_wrapper, 0, 0); @@ -234,11 +193,11 @@ void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) { lv_label_set_text(delay_label, "Delay:"); lv_obj_align(delay_label, LV_ALIGN_LEFT_MID, 0, 0); - delayTextArea = lv_textarea_create(delay_wrapper); - lv_textarea_set_one_line(delayTextArea, true); - lv_textarea_set_accepted_chars(delayTextArea, "0123456789"); - lv_textarea_set_text(delayTextArea, "10"); - lv_obj_set_flex_grow(delayTextArea, 1); + ctx->delayTextArea = lv_textarea_create(delay_wrapper); + lv_textarea_set_one_line(ctx->delayTextArea, true); + lv_textarea_set_accepted_chars(ctx->delayTextArea, "0123456789"); + lv_textarea_set_text(ctx->delayTextArea, "10"); + lv_obj_set_flex_grow(ctx->delayTextArea, 1); auto* delay_unit_label_wrapper = lv_obj_create(delay_wrapper); lv_obj_set_style_border_width(delay_unit_label_wrapper, 0, 0); @@ -249,15 +208,19 @@ void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) { lv_label_set_text(delay_unit_label, "seconds"); } -void ScreenshotApp::onShow(AppContext& appContext, lv_obj_t* parent) { - if (updateTimer->isRunning()) { - updateTimer->stop(); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + if (ctx->updateTimer->isRunning()) { + ctx->updateTimer->stop(); } lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* toolbar = lvgl::toolbar_create(parent, appContext); + auto* toolbar = lvgl_toolbar_create(parent, "Screenshot"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); auto* wrapper = lv_obj_create(parent); @@ -266,23 +229,66 @@ void ScreenshotApp::onShow(AppContext& appContext, lv_obj_t* parent) { lv_obj_set_style_border_width(wrapper, 0, 0); lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - createModeSettingWidgets(wrapper); - createFilePathWidgets(wrapper); - createTimerSettingsWidgets(wrapper); + createModeSettingWidgets(ctx, wrapper); + createFilePathWidgets(ctx, wrapper); + createTimerSettingsWidgets(ctx, wrapper); + + updateScreenshotMode(ctx); - updateScreenshotMode(); + if (!ctx->updateTimer->isRunning()) { + ctx->updateTimer->start(); + } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.updateTimer = std::make_unique(Timer::Type::Periodic, 500 / portTICK_PERIOD_MS, [&ctx] { + if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) { + updateScreenshotMode(&ctx); + lvgl_unlock(); + } + }); + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - if (!updateTimer->isRunning()) { - updateTimer->start(); + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } + } + + if (ctx.updateTimer->isRunning()) { + ctx.updateTimer->stop(); } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; } -extern const AppManifest manifest = { - .appId = "Screenshot", - .appName = "Screenshot", - .appIcon = LVGL_ICON_SHARED_IMAGE, - .appCategory = Category::System, - .createApp = create +} // namespace + +extern const ::AppManifest manifest = { + .id = "Screenshot", + .name = "Screenshot", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace diff --git a/Tactility/Source/app/selectiondialog/SelectionDialog.cpp b/Tactility/Source/app/selectiondialog/SelectionDialog.cpp index db28d4dce..ebd446e63 100644 --- a/Tactility/Source/app/selectiondialog/SelectionDialog.cpp +++ b/Tactility/Source/app/selectiondialog/SelectionDialog.cpp @@ -1,119 +1,160 @@ #include -#include -#include -#include +#include +#include +#include + +#include + #include #include +#include namespace tt::app::selectiondialog { -constexpr auto* PARAMETER_BUNDLE_KEY_TITLE = "title"; -constexpr auto* PARAMETER_BUNDLE_KEY_ITEMS = "items"; -constexpr auto* RESULT_BUNDLE_KEY_INDEX = "index"; - -constexpr auto* PARAMETER_ITEM_CONCATENATION_TOKEN = ";;"; +constexpr auto* TAG = "SelectionDialog"; constexpr auto* DEFAULT_TITLE = "Select..."; -constexpr auto* TAG = "SelectionDialog"; +extern const ::AppManifest manifest; + +namespace { -extern const AppManifest manifest; +struct Context { + uint32_t appInstanceId; + // Set once in appMain() from its own argc/argv parameters, read by createWidgets() - see + // AlertDialog.cpp's Context::argc/argv for why this is safe without a lock. + int argc = 0; + char** argv = nullptr; + // The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this + // is a plain (non-atomic) field safely shared between the LVGL thread (writer, before + // emitting APP_EVENT_CLOSE) and this dialog's own thread (reader, after waking from it). + int32_t result = 1; // Cancelled - safety-net default if closed without selecting an item +}; + +struct ItemContext { + Context* ctx; + int32_t index; +}; + +void onItemDeleted(lv_event_t* e) { + delete static_cast(lv_event_get_user_data(e)); +} + +void onItemSelected(lv_event_t* e) { + auto* itemCtx = static_cast(lv_event_get_user_data(e)); + LOG_I(TAG, "Selected item at index %d", (int)itemCtx->index); + itemCtx->ctx->result = itemCtx->index; + // Async, non-blocking - just wakes this dialog's own thread. Must NOT call + // app_manager_stop() here: that bound-waits (thread_join) for the dialog's thread to + // finish, which needs the LVGL lock (window_manager_remove()) - but this callback is + // running ON the LVGL task, which would deadlock against itself. The caller reaps this + // instance via app_manager_stop() after it receives the APP_EVENT_RESULT instead. + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(itemCtx->ctx->appInstanceId, &event); +} -LaunchId start(const std::string& title, const std::vector& items) { - std::string items_joined = string::join(items, PARAMETER_ITEM_CONCATENATION_TOKEN); - auto bundle = std::make_shared(); - bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title); - bundle->putString(PARAMETER_BUNDLE_KEY_ITEMS, items_joined); - return app::start(manifest.appId, bundle); +void createChoiceItem(Context* ctx, lv_obj_t* list, const std::string& title, int32_t index) { + lv_obj_t* btn = lv_list_add_button(list, nullptr, title.c_str()); + auto* itemCtx = new ItemContext { ctx, index }; + lv_obj_add_event_cb(btn, onItemSelected, LV_EVENT_SHORT_CLICKED, itemCtx); + lv_obj_add_event_cb(btn, onItemDeleted, LV_EVENT_DELETE, itemCtx); } -int32_t getResultIndex(const Bundle& bundle) { - int32_t index = -1; - bundle.optInt32(RESULT_BUNDLE_KEY_INDEX, index); - return index; +// Closes the dialog immediately with a fixed result, without ever showing a choice list - +// mirrors the original's 0-items (error) and 1-item (auto-select) shortcuts. +void closeWithResult(Context* ctx, int32_t result) { + ctx->result = result; + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &event); } -static std::string getTitleParameter(std::shared_ptr bundle) { - std::string result; - if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) { - return result; +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + // argv layout: [0]=title, [1..argc)=items. + int argc = ctx->argc; + char** argv = ctx->argv; + int itemCount = argc - 1; + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + const char* title = (argv[0][0] != '\0') ? argv[0] : DEFAULT_TITLE; + lvgl_toolbar_create(parent, title); + + auto* list = lv_list_create(parent); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); + + if (itemCount <= 0 || argv[1][0] == '\0') { + LOG_E(TAG, "No items provided"); + closeWithResult(ctx, -1); + } else if (itemCount == 1) { + LOG_W(TAG, "Auto-selecting single item"); + closeWithResult(ctx, 0); } else { - return DEFAULT_TITLE; + for (int32_t index = 0; index < itemCount; index++) { + createChoiceItem(ctx, list, argv[1 + index], index); + } } } -class SelectionDialogApp final : public App { +int32_t appMain(AppInstanceId appInstanceId, int argc, char* argv[]) { + Context ctx { appInstanceId }; + ctx.argc = argc; + ctx.argv = argv; - static void onListItemSelectedCallback(lv_event_t* e) { - auto app = std::static_pointer_cast(getCurrentApp()); - assert(app != nullptr); - app->onListItemSelected(e); - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onListItemSelected(lv_event_t* e) { - auto index = reinterpret_cast(lv_event_get_user_data(e)); - LOG_I(TAG, "Selected item at index %d", (int)index); - auto bundle = std::make_unique(); - bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, (int32_t)index); - setResult(Result::Ok, std::move(bundle)); - stop(manifest.appId); - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - static void createChoiceItem(void* parent, const std::string& title, size_t index) { - auto* list = static_cast(parent); - lv_obj_t* btn = lv_list_add_button(list, nullptr, title.c_str()); - lv_obj_add_event_cb(btn, onListItemSelectedCallback, LV_EVENT_SHORT_CLICKED, (void*)index); + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); // no-op: modal children never supersede anything + break; + } } -public: - - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - std::string title = getTitleParameter(app.getParameters()); - lvgl_toolbar_create(parent, title.c_str()); - - auto* list = lv_list_create(parent); - lv_obj_set_width(list, LV_PCT(100)); - lv_obj_set_flex_grow(list, 1); - - auto parameters = app.getParameters(); - check(parameters != nullptr, "Parameters missing"); - std::string items_concatenated; - if (parameters->optString(PARAMETER_BUNDLE_KEY_ITEMS, items_concatenated)) { - std::vector items = string::split(items_concatenated, PARAMETER_ITEM_CONCATENATION_TOKEN); - if (items.empty() || items.front().empty()) { - LOG_E(TAG, "No items provided"); - setResult(Result::Error); - stop(manifest.appId); - } else if (items.size() == 1) { - auto result_bundle = std::make_unique(); - result_bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, 0); - setResult(Result::Ok, std::move(result_bundle)); - stop(manifest.appId); - LOG_W(TAG, "Auto-selecting single item"); - } else { - size_t index = 0; - for (const auto& item: items) { - createChoiceItem(list, item, index++); - } - } - } else { - LOG_E(TAG, "No items provided"); - setResult(Result::Error); - stop(manifest.appId); - } + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return ctx.result; +} + +} // namespace + +namespace { + +// Builds argv = [title, items...] for app_manager_start_for_result(). +std::vector buildArgv(const std::string& title, const std::vector& items) { + std::vector argv { title.c_str() }; + for (const auto& item: items) { + argv.push_back(item.c_str()); } -}; + return argv; +} + +} // namespace + +AppInstanceId start(AppInstanceId callerAppInstanceId, const std::string& title, const std::vector& items) { + auto argv = buildArgv(title, items); + AppInstanceId instanceId = 0; + app_manager_start_for_result(manifest.id, callerAppInstanceId, static_cast(argv.size()), argv.data(), &instanceId); + return instanceId; +} extern const AppManifest manifest = { - .appId = "SelectionDialog", - .appName = "Selection Dialog", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create + .id = "SelectionDialog", + .name = "Selection Dialog", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } diff --git a/Tactility/Source/app/settings/Settings.cpp b/Tactility/Source/app/settings/Settings.cpp index 5a278505a..e1e69f866 100644 --- a/Tactility/Source/app/settings/Settings.cpp +++ b/Tactility/Source/app/settings/Settings.cpp @@ -1,61 +1,114 @@ -#include -#include -#include +#include +#include +#include + +#include #include #include +#include #include #include #include +#include +#include namespace tt::app::settings { -static void onAppPressed(lv_event_t* e) { - const auto* manifest = static_cast(lv_event_get_user_data(e)); - start(manifest->appId); +namespace { + +uint32_t settingsInstanceId = 0; + +void onAppPressed(lv_event_t* e) { + // Fire-and-forget top-level navigation, same as AppList's own app-launch buttons. + const auto* manifest = static_cast(lv_event_get_user_data(e)); + uint32_t instanceId = 0; + app_manager_start(manifest->id, &instanceId); +} + +void onBackPressed(lv_event_t*) { + // The global toolbar nav callback only knows how to stop old-model apps, so this + // new-model app overrides its own toolbar's nav action to close itself instead. Async, + // non-blocking - see AppList.cpp's onBackPressed() for why this must not call + // app_manager_stop() directly (would deadlock against the LVGL lock). + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(settingsInstanceId, &event); } -static void createWidget(const std::shared_ptr& manifest, void* parent) { - check(parent); - auto* list = static_cast(parent); - const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR; - auto* btn = lv_list_add_button(list, icon, manifest->appName.c_str()); +void createWidget(const ::AppManifest* manifest, lv_obj_t* list) { + check(list); + // The new AppManifest has no per-app icon - use a shared generic one for every entry, + // same fallback the old model used for apps that didn't provide one. + auto* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, manifest->name); lv_obj_t* image = lv_obj_get_child(btn, 0); lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN); - lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)manifest.get()); + lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(manifest)); } -class SettingsApp final : public App { +void collectManifest(const ::AppManifest* manifest, void* context) { + auto* manifests = static_cast*>(context); + manifests->push_back(manifest); +} + +void createWidgets(lv_obj_t* parent, void*) { + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + auto* toolbar = lvgl_toolbar_create(parent, "Settings"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr); - lvgl::toolbar_create(parent, app); + auto* list = lv_list_create(parent); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); - auto* list = lv_list_create(parent); - lv_obj_set_width(list, LV_PCT(100)); - lv_obj_set_flex_grow(list, 1); + std::vector manifests; + app_manager_for_each_manifest(collectManifest, &manifests); + std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) { + return strcmp(a->name, b->name) < 0; + }); - auto manifests = getAppManifests(); - std::ranges::sort(manifests, SortAppManifestByName); - for (const auto& manifest: manifests) { - if (manifest->appCategory == Category::Settings) { - createWidget(manifest, list); - } + for (const auto* manifest: manifests) { + if (manifest->category == APP_CATEGORY_SETTINGS && (manifest->flags & APP_MANIFEST_FLAG_HIDDEN) == 0) { + createWidget(manifest, list); } } -}; +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + settingsInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr); + + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); + break; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + return 0; +} + +} // namespace -extern const AppManifest manifest = { - .appId = "Settings", - .appName = "Settings", - .appIcon = LVGL_ICON_SHARED_SETTINGS, - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create +extern const ::AppManifest manifest = { + .id = "Settings", + .name = "Settings", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, }; } // namespace diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index 05d39265f..471283265 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -1,8 +1,3 @@ -#include -#include - -#include -#include #include #include #include @@ -10,6 +5,14 @@ #include #include +#include +#include +#include + +#include + +#include +#include #include #include @@ -25,7 +28,7 @@ namespace tt::app::setup { -extern const AppManifest manifest; +extern const ::AppManifest manifest; constexpr auto* PREFERENCES_NAMESPACE = "setup"; constexpr auto* PREFERENCES_KEY_COMPLETED = "completed"; @@ -37,193 +40,219 @@ bool isCompleted() { return completed; } -static void markCompleted() { +namespace { + +void markCompleted() { Preferences preferences(PREFERENCES_NAMESPACE); preferences.putBool(PREFERENCES_KEY_COMPLETED, true); } +enum class Phase { + Welcome, + StepIntro, + Done +}; + struct StepConfiguration { std::string title; std::string description; std::function run; }; -class SetupApp final : public App { - - enum class Phase { - Welcome, - StepIntro, - Done - }; +struct Context { + uint32_t appInstanceId; Phase phase = Phase::Welcome; size_t stepIndex = 0; std::vector steps; - bool isShown = false; + uint32_t pendingStepDialogId = 0; 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 renderCurrent(Context* ctx) { + switch (ctx->phase) { + case Phase::Welcome: { + lv_label_set_text(ctx->titleLabel, "Welcome"); + auto device_names = string::split(std::string(CONFIG_TT_DEVICE_NAME_SIMPLE), ","); + lv_label_set_text_fmt(ctx->descriptionLabel, "It's time to set up your %s!", device_names.front().c_str()); + lv_obj_add_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue"); + break; } - } - - void advanceTo(size_t index) { - if (index < steps.size()) { - stepIndex = index; - phase = Phase::StepIntro; - } else { - phase = Phase::Done; + case Phase::StepIntro: { + const auto& step = ctx->steps[ctx->stepIndex]; + lv_label_set_text(ctx->titleLabel, step.title.c_str()); + lv_label_set_text(ctx->descriptionLabel, step.description.c_str()); + lv_obj_remove_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(lv_obj_get_child(ctx->skipButton, 0), "Skip"); + lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue"); + break; } + case Phase::Done: + lv_label_set_text(ctx->titleLabel, "Setup Complete"); + lv_label_set_text(ctx->descriptionLabel, "You're all set."); + lv_obj_add_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish"); + break; + } +} - // 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 advanceTo(Context* ctx, size_t index) { + if (index < ctx->steps.size()) { + ctx->stepIndex = index; + ctx->phase = Phase::StepIntro; + } else { + ctx->phase = Phase::Done; } - void onSkipClicked() { - if (phase == Phase::StepIntro) { - advanceTo(stepIndex + 1); - } + lvgl_lock(); + renderCurrent(ctx); + lvgl_unlock(); +} + +void onSkipClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + if (ctx->phase == Phase::StepIntro) { + advanceTo(ctx, ctx->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; +void onContinueClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + switch (ctx->phase) { + case Phase::Welcome: + advanceTo(ctx, 0); + break; + case Phase::StepIntro: + ctx->steps[ctx->stepIndex].run(); + break; + case Phase::Done: { + markCompleted(); + // Async, non-blocking - must NOT call app_manager_stop()/app_manager_finish() + // directly here: this callback runs ON the LVGL task, and app-lifecycle + // transitions must happen on this app's own thread (woken via app_event_await()). + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); + break; } } +} -public: +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + ctx->titleLabel = lv_label_create(parent); + lv_obj_set_width(ctx->titleLabel, LV_PCT(80)); + lv_obj_set_style_text_align(ctx->titleLabel, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(ctx->titleLabel, LV_LABEL_LONG_WRAP); + auto* font = lvgl_get_text_font(FONT_SIZE_LARGE); + lv_obj_set_style_text_font(ctx->titleLabel, font, 0); + + ctx->descriptionLabel = lv_label_create(parent); + lv_obj_set_width(ctx->descriptionLabel, LV_PCT(80)); + lv_obj_set_style_text_align(ctx->descriptionLabel, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_WRAP); + lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0); + + int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE); + lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin); + + ctx->skipButton = lv_button_create(parent); + lv_obj_t* skip_label = lv_label_create(ctx->skipButton); + lv_label_set_text(skip_label, "Skip"); + lv_obj_center(skip_label); + lv_obj_align(ctx->skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12); + lv_obj_add_event_cb(ctx->skipButton, onSkipClicked, LV_EVENT_SHORT_CLICKED, ctx); + + ctx->continueButton = lv_button_create(parent); + lv_obj_t* continue_label = lv_label_create(ctx->continueButton); + lv_label_set_text(continue_label, "Continue"); + lv_obj_center(continue_label); + lv_obj_align(ctx->continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12); + lv_obj_add_event_cb(ctx->continueButton, onContinueClicked, LV_EVENT_SHORT_CLICKED, ctx); + + renderCurrent(ctx); +} - void onCreate(AppContext& app) override { - steps = { +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.steps = { #if defined(CONFIG_TT_TOUCH_CALIBRATION_REQUIRED) - { - .title = "Touch Calibration", - .description = "Let's calibrate the touch screen.", - .run = [] { touchcalibration::start(); } - }, + { + .title = "Touch Calibration", + .description = "Let's calibrate the touch screen.", + .run = [&ctx] { ctx.pendingStepDialogId = touchcalibration::start(ctx.appInstanceId); } + }, #endif - { - .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(); - } + { + .title = "Time Zone Setup", + .description = "Let's set the time zone.", + .run = [&ctx] { ctx.pendingStepDialogId = timezone::start(ctx.appInstanceId, true); } + }, + { + .title = "Wi-Fi Setup", + .description = "Let's connect to a Wi-Fi access point.", + .run = [&ctx] { + service::wifi::setEnabled(true); + ctx.pendingStepDialogId = wifimanage::start(ctx.appInstanceId); } - }; - } + } + }; - 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(); - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onHide(AppContext& app) override { - isShown = false; - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - void onResult(AppContext& app, LaunchId launchId, Result result, std::unique_ptr bundle) override { - lvgl_lock(); - advanceTo(stepIndex + 1); - lvgl_unlock(); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + if (event.result.launch_id == ctx.pendingStepDialogId) { + ctx.pendingStepDialogId = 0; + advanceTo(&ctx, ctx.stepIndex + 1); + } + app_manager_stop(event.result.launch_id); + break; + default: + break; + } } -}; -extern const AppManifest manifest = { - .appId = "Setup", - .appName = "Setup", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden | AppManifest::Flags::HideStatusBar, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} -LaunchId start() { - return app::start(manifest.appId); +} // namespace + +void start() { + uint32_t instanceId = 0; + app_manager_start(manifest.id, &instanceId); } +extern const ::AppManifest manifest = { + .id = "Setup", + .name = "Setup", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + } diff --git a/Tactility/Source/app/systeminfo/SystemInfo.cpp b/Tactility/Source/app/systeminfo/SystemInfo.cpp index 564a7f057..a582e8e05 100644 --- a/Tactility/Source/app/systeminfo/SystemInfo.cpp +++ b/Tactility/Source/app/systeminfo/SystemInfo.cpp @@ -1,20 +1,24 @@ #include "tactility/time.h" - #include #include #include #include -#include + +#include +#include +#include + +#include #include #include #include #include -#include #include #include +#include #ifdef ESP_PLATFORM #include @@ -26,7 +30,11 @@ namespace tt::app::systeminfo { constexpr auto* TAG = "SystemInfo"; -static size_t getHeapFree() { +extern const ::AppManifest manifest; + +namespace { + +size_t getHeapFree() { #ifdef ESP_PLATFORM return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); #else @@ -34,7 +42,7 @@ static size_t getHeapFree() { #endif } -static size_t getHeapTotal() { +size_t getHeapTotal() { #ifdef ESP_PLATFORM return heap_caps_get_total_size(MALLOC_CAP_INTERNAL); #else @@ -42,7 +50,7 @@ static size_t getHeapTotal() { #endif } -static size_t getSpiFree() { +size_t getSpiFree() { #ifdef ESP_PLATFORM return heap_caps_get_free_size(MALLOC_CAP_SPIRAM); #else @@ -50,7 +58,7 @@ static size_t getSpiFree() { #endif } -static size_t getSpiTotal() { +size_t getSpiTotal() { #ifdef ESP_PLATFORM return heap_caps_get_total_size(MALLOC_CAP_SPIRAM); #else @@ -65,7 +73,7 @@ enum class StorageUnit { Gigabytes }; -static StorageUnit getStorageUnit(uint64_t value) { +StorageUnit getStorageUnit(uint64_t value) { using enum StorageUnit; if (value / (1024 * 1024 * 1024) > 0) { return Gigabytes; @@ -78,7 +86,7 @@ static StorageUnit getStorageUnit(uint64_t value) { } } -static std::string getStorageUnitString(StorageUnit unit) { +std::string getStorageUnitString(StorageUnit unit) { using enum StorageUnit; switch (unit) { case Bytes: @@ -94,7 +102,7 @@ static std::string getStorageUnitString(StorageUnit unit) { } } -static std::string getStorageValue(StorageUnit unit, uint64_t bytes) { +std::string getStorageValue(StorageUnit unit, uint64_t bytes) { using enum StorageUnit; switch (unit) { case Bytes: @@ -115,7 +123,7 @@ struct MemoryBarWidgets { lv_obj_t* label = nullptr; }; -static MemoryBarWidgets createMemoryBar(lv_obj_t* parent, const char* label) { +MemoryBarWidgets createMemoryBar(lv_obj_t* parent, const char* label) { auto* container = lv_obj_create(parent); lv_obj_set_size(container, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_pad_all(container, 0, LV_STATE_DEFAULT); @@ -144,7 +152,7 @@ static MemoryBarWidgets createMemoryBar(lv_obj_t* parent, const char* label) { return {bar, bottom_label}; } -static void updateMemoryBar(const MemoryBarWidgets& widgets, uint64_t free, uint64_t total) { +void updateMemoryBar(const MemoryBarWidgets& widgets, uint64_t free, uint64_t total) { uint64_t used = total - free; // Scale down the uint64_t until it fits int32_t for the lv_bar @@ -174,7 +182,7 @@ static void updateMemoryBar(const MemoryBarWidgets& widgets, uint64_t free, uint #if configUSE_TRACE_FACILITY -static const char* getTaskState(const TaskStatus_t& task) { +const char* getTaskState(const TaskStatus_t& task) { switch (task.eCurrentState) { case eRunning: return "running"; @@ -192,17 +200,17 @@ static const char* getTaskState(const TaskStatus_t& task) { } } -static void clearContainer(lv_obj_t* container) { +void clearContainer(lv_obj_t* container) { lv_obj_clean(container); } -static void addRtosTask(lv_obj_t* parent, const TaskStatus_t& task) { +void addRtosTask(lv_obj_t* parent, const TaskStatus_t& task) { auto* label = lv_label_create(parent); const char* name = (task.pcTaskName == nullptr || task.pcTaskName[0] == 0) ? "(unnamed)" : task.pcTaskName; lv_label_set_text_fmt(label, "%s (%s)", name, getTaskState(task)); } -static void updateRtosTasks(lv_obj_t* parent) { +void updateRtosTasks(lv_obj_t* parent) { clearContainer(parent); UBaseType_t count = uxTaskGetNumberOfTasks(); @@ -224,7 +232,7 @@ static void updateRtosTasks(lv_obj_t* parent) { #endif -static lv_obj_t* createTab(lv_obj_t* tabview, const char* name) { +lv_obj_t* createTab(lv_obj_t* tabview, const char* name) { auto* tab = lv_tabview_add_tab(tabview, name); lv_obj_set_flex_flow(tab, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(tab, 0, LV_STATE_DEFAULT); @@ -232,36 +240,11 @@ static lv_obj_t* createTab(lv_obj_t* tabview, const char* name) { return tab; } -extern const AppManifest manifest; - -class SystemInfoApp; +struct Context { + uint32_t appInstanceId; -static std::shared_ptr optApp() { - auto appContext = getCurrentAppContext(); - if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) { - return std::static_pointer_cast(appContext->getApp()); - } - return nullptr; -} - -class SystemInfoApp final : public App { - Timer memoryTimer = Timer(Timer::Type::Periodic, millis_to_ticks(10000), [] { - auto app = optApp(); - if (app) { - lvgl_lock(); - app->updateMemory(); - lvgl_unlock(); - } - }); - - Timer tasksTimer = Timer(Timer::Type::Periodic, millis_to_ticks(15000), [] { - auto app = optApp(); - if (app) { - lvgl_lock(); - app->updateTasks(); - lvgl_unlock(); - } - }); + std::unique_ptr memoryTimer; + std::unique_ptr tasksTimer; MemoryBarWidgets internalMemBar; MemoryBarWidgets externalMemBar; @@ -275,146 +258,204 @@ class SystemInfoApp final : public App { bool hasExternalMem = false; bool hasDataStorage = false; bool hasSystemStorage = false; +}; - void updateMemory() { - updateMemoryBar(internalMemBar, getHeapFree(), getHeapTotal()); - if (hasExternalMem) { - updateMemoryBar(externalMemBar, getSpiFree(), getSpiTotal()); - } +void updateMemory(Context* ctx) { + updateMemoryBar(ctx->internalMemBar, getHeapFree(), getHeapTotal()); + + if (ctx->hasExternalMem) { + updateMemoryBar(ctx->externalMemBar, getSpiFree(), getSpiTotal()); } +} - void updateStorage() { +void updateStorage(Context* ctx) { #ifdef ESP_PLATFORM - uint64_t storage_total = 0; - uint64_t storage_free = 0; + uint64_t storage_total = 0; + uint64_t storage_free = 0; - if (hasDataStorage) { - if (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK) { - updateMemoryBar(dataStorageBar, storage_free, storage_total); - } + if (ctx->hasDataStorage) { + if (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK) { + updateMemoryBar(ctx->dataStorageBar, storage_free, storage_total); } + } - std::string sdcard_path; - if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) { - updateMemoryBar(sdcardStorageBar, storage_free, storage_total); - } + std::string sdcard_path; + if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) { + updateMemoryBar(ctx->sdcardStorageBar, storage_free, storage_total); + } - if (hasSystemStorage) { - if (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK) { - updateMemoryBar(systemStorageBar, storage_free, storage_total); - } + if (ctx->hasSystemStorage) { + if (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK) { + updateMemoryBar(ctx->systemStorageBar, storage_free, storage_total); } -#endif } +#endif +} - void updateTasks() { +void updateTasks(Context* ctx) { #if configUSE_TRACE_FACILITY - if (tasksContainer) { - updateRtosTasks(tasksContainer); // Tasks tab: show state - } -#endif + if (ctx->tasksContainer) { + updateRtosTasks(ctx->tasksContainer); // Tasks tab: show state } +#endif +} - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - lvgl::toolbar_create(parent, app); - - auto* wrapper = lv_obj_create(parent); - lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT); - - auto* tabview = lv_tabview_create(wrapper); - lv_tabview_set_tab_bar_position(tabview, LV_DIR_LEFT); - auto tab_bar_width = 6 * lvgl_get_text_font_height(FONT_SIZE_DEFAULT); - lv_tabview_set_tab_bar_size(tabview, tab_bar_width); - - // Create tabs - auto* memory_tab = createTab(tabview, "Memory"); - auto* storage_tab = createTab(tabview, "Storage"); - auto* tasks_tab = createTab(tabview, "Tasks"); - auto* about_tab = createTab(tabview, "About"); - - // Memory tab content - internalMemBar = createMemoryBar(memory_tab, "Internal"); - - hasExternalMem = getSpiTotal() > 0; - if (hasExternalMem) { - externalMemBar = createMemoryBar(memory_tab, "External"); - } +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "System Info"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT); + + auto* tabview = lv_tabview_create(wrapper); + lv_tabview_set_tab_bar_position(tabview, LV_DIR_LEFT); + auto tab_bar_width = 6 * lvgl_get_text_font_height(FONT_SIZE_DEFAULT); + lv_tabview_set_tab_bar_size(tabview, tab_bar_width); + + // Create tabs + auto* memory_tab = createTab(tabview, "Memory"); + auto* storage_tab = createTab(tabview, "Storage"); + auto* tasks_tab = createTab(tabview, "Tasks"); + auto* about_tab = createTab(tabview, "About"); + + // Memory tab content + ctx->internalMemBar = createMemoryBar(memory_tab, "Internal"); + + ctx->hasExternalMem = getSpiTotal() > 0; + if (ctx->hasExternalMem) { + ctx->externalMemBar = createMemoryBar(memory_tab, "External"); + } #ifdef ESP_PLATFORM - // Storage tab content - uint64_t storage_total = 0; - uint64_t storage_free = 0; + // Storage tab content + uint64_t storage_total = 0; + uint64_t storage_free = 0; - hasDataStorage = (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK); - if (hasDataStorage) { - dataStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_DATA); - } + ctx->hasDataStorage = (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK); + if (ctx->hasDataStorage) { + ctx->dataStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_DATA); + } - std::string sdcard_path; - if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) { - sdcardStorageBar = createMemoryBar(storage_tab, sdcard_path.c_str()); - } + std::string sdcard_path; + if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) { + ctx->sdcardStorageBar = createMemoryBar(storage_tab, sdcard_path.c_str()); + } - if (config::SHOW_SYSTEM_PARTITION) { - hasSystemStorage = (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK); - if (hasSystemStorage) { - systemStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_SYSTEM); - } + if (config::SHOW_SYSTEM_PARTITION) { + ctx->hasSystemStorage = (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK); + if (ctx->hasSystemStorage) { + ctx->systemStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_SYSTEM); } + } #endif #if configUSE_TRACE_FACILITY - // Tasks tab - container for dynamic updates - tasksContainer = lv_obj_create(tasks_tab); - lv_obj_set_size(tasksContainer, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(tasksContainer, 8, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(tasksContainer, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_flow(tasksContainer, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_bg_opa(tasksContainer, 0, LV_STATE_DEFAULT); + // Tasks tab - container for dynamic updates + ctx->tasksContainer = lv_obj_create(tasks_tab); + lv_obj_set_size(ctx->tasksContainer, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ctx->tasksContainer, 8, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->tasksContainer, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(ctx->tasksContainer, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_opa(ctx->tasksContainer, 0, LV_STATE_DEFAULT); #endif - // Build info - auto* tactility_version = lv_label_create(about_tab); - lv_label_set_text_fmt(tactility_version, "Tactility v%s", TT_VERSION); + // Build info + auto* tactility_version = lv_label_create(about_tab); + lv_label_set_text_fmt(tactility_version, "Tactility v%s", TT_VERSION); #ifdef ESP_PLATFORM - auto* esp_idf_version = lv_label_create(about_tab); - lv_label_set_text_fmt(esp_idf_version, "ESP-IDF v%d.%d.%d", ESP_IDF_VERSION_MAJOR, ESP_IDF_VERSION_MINOR, ESP_IDF_VERSION_PATCH); + auto* esp_idf_version = lv_label_create(about_tab); + lv_label_set_text_fmt(esp_idf_version, "ESP-IDF v%d.%d.%d", ESP_IDF_VERSION_MAJOR, ESP_IDF_VERSION_MINOR, ESP_IDF_VERSION_PATCH); #endif - auto* device_vendor = lv_label_create(about_tab); - lv_label_set_text_fmt(device_vendor, "Hardware vendor: %s", CONFIG_TT_DEVICE_VENDOR); - auto* device_device_name = lv_label_create(about_tab); - lv_label_set_text_fmt(device_device_name, "Hardware model: %s", CONFIG_TT_DEVICE_NAME_SIMPLE); + auto* device_vendor = lv_label_create(about_tab); + lv_label_set_text_fmt(device_vendor, "Hardware vendor: %s", CONFIG_TT_DEVICE_VENDOR); + auto* device_device_name = lv_label_create(about_tab); + lv_label_set_text_fmt(device_device_name, "Hardware model: %s", CONFIG_TT_DEVICE_NAME_SIMPLE); + + // Initial updates + updateMemory(ctx); + updateStorage(ctx); // Storage: one-time update on show (doesn't change frequently) + updateTasks(ctx); +} - // Initial updates - updateMemory(); - updateStorage(); // Storage: one-time update on show (doesn't change frequently) - updateTasks(); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; - // Start timers (only run while app is visible, stopped in onHide) - memoryTimer.start(); // Memory: every 10s - tasksTimer.start(); // Tasks/CPU: every 15s - } + // Run for this app instance's whole lifetime (mirrors GpsSettings) - both timers keep the + // displayed values fresh regardless of whether the app is currently topmost. + ctx.memoryTimer = std::make_unique(Timer::Type::Periodic, millis_to_ticks(10000), [&ctx] { + lvgl_lock(); + updateMemory(&ctx); + lvgl_unlock(); + }); + + ctx.tasksTimer = std::make_unique(Timer::Type::Periodic, millis_to_ticks(15000), [&ctx] { + lvgl_lock(); + updateTasks(&ctx); + lvgl_unlock(); + }); + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onHide(AppContext& app) override { - memoryTimer.stop(); - tasksTimer.stop(); + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + ctx.memoryTimer->start(); // Memory: every 10s + ctx.tasksTimer->start(); // Tasks/CPU: every 15s + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } } -}; -extern const AppManifest manifest = { - .appId = "SystemInfo", - .appName = "System Info", - .appIcon = LVGL_ICON_SHARED_AREA_CHART, - .appCategory = Category::System, - .createApp = create + ctx.memoryTimer->stop(); + ctx.tasksTimer->stop(); + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "SystemInfo", + .name = "System Info", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace diff --git a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp index c90d0cdb5..9df8bcbaf 100644 --- a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp +++ b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp @@ -1,175 +1,218 @@ -#include -#include +#include #include -#include -#include #include #include +#include +#include +#include + +#include + #include #include -#include +#include namespace tt::app::timedatesettings { constexpr auto* TAG = "TimeDate"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; -class TimeDateSettingsApp final : public App { +namespace { - RecursiveMutex mutex; +struct Context { + uint32_t appInstanceId; lv_obj_t* timeZoneLabel = nullptr; lv_obj_t* dateFormatDropdown = nullptr; - bool isShown = false; + uint32_t pendingTimeZoneDialogId = 0; +}; - static void onTimeFormatChanged(lv_event_t* event) { - auto* widget = lv_event_get_target_obj(event); - bool show_24 = lv_obj_has_state(widget, LV_STATE_CHECKED); - settings::setTimeFormat24Hour(show_24); - } - static void onTimeZonePressed(lv_event_t* event) { - timezone::start(true); - } +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - static void onDateFormatChanged(lv_event_t* event) { - auto* dropdown = static_cast(lv_event_get_target(event)); - auto index = lv_dropdown_get_selected(dropdown); - - const char* dateFormats[] = {"MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD", "YYYY/MM/DD"}; - std::string selected_format = dateFormats[index]; - - settings::SystemSettings sysSettings; - if (settings::loadSystemSettings(sysSettings)) { - sysSettings.dateFormat = selected_format; - settings::saveSystemSettings(sysSettings); - } - } +void onTimeFormatChanged(lv_event_t* event) { + auto* widget = lv_event_get_target_obj(event); + bool show_24 = lv_obj_has_state(widget, LV_STATE_CHECKED); + settings::setTimeFormat24Hour(show_24); +} -public: +void onTimeZonePressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->pendingTimeZoneDialogId = timezone::start(ctx->appInstanceId, true); +} - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); +void onDateFormatChanged(lv_event_t* event) { + auto* dropdown = static_cast(lv_event_get_target(event)); + auto index = lv_dropdown_get_selected(dropdown); - lvgl::toolbar_create(parent, app); + const char* dateFormats[] = {"MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD", "YYYY/MM/DD"}; + std::string selected_format = dateFormats[index]; - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); + settings::SystemSettings sysSettings; + if (settings::loadSystemSettings(sysSettings)) { + sysSettings.dateFormat = selected_format; + settings::saveSystemSettings(sysSettings); + } +} - // 24-hour format toggle +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - auto* time_format_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_width(time_format_wrapper, LV_PCT(100)); - lv_obj_set_height(time_format_wrapper, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(time_format_wrapper, 8, 0); - lv_obj_set_style_border_width(time_format_wrapper, 0, 0); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto* time_24h_label = lv_label_create(time_format_wrapper); - lv_label_set_text(time_24h_label, "24-hour format"); - lv_obj_align(time_24h_label, LV_ALIGN_LEFT_MID, 4, 0); + auto* toolbar = lvgl_toolbar_create(parent, "Time & Date"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - auto* time_24h_switch = lv_switch_create(time_format_wrapper); - lv_obj_align(time_24h_switch, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr); - if (settings::isTimeFormat24Hour()) { - lv_obj_add_state(time_24h_switch, LV_STATE_CHECKED); - } else { - lv_obj_remove_state(time_24h_switch, LV_STATE_CHECKED); - } + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); - // Date format dropdown - - auto* date_format_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_width(date_format_wrapper, LV_PCT(100)); - lv_obj_set_height(date_format_wrapper, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(date_format_wrapper, 8, 0); - lv_obj_set_style_border_width(date_format_wrapper, 0, 0); - - auto* date_format_label = lv_label_create(date_format_wrapper); - lv_label_set_text(date_format_label, "Date format"); - lv_obj_align(date_format_label, LV_ALIGN_LEFT_MID, 4, 0); - - dateFormatDropdown = lv_dropdown_create(date_format_wrapper); - lv_obj_set_width(dateFormatDropdown, 150); - lv_obj_align(dateFormatDropdown, LV_ALIGN_RIGHT_MID, 0, 0); - lv_dropdown_set_options(dateFormatDropdown, "MM/DD/YYYY\nDD/MM/YYYY\nYYYY-MM-DD\nYYYY/MM/DD"); - - settings::SystemSettings sysSettings; - if (settings::loadSystemSettings(sysSettings)) { - int index = 0; - if (sysSettings.dateFormat == "DD/MM/YYYY") index = 1; - else if (sysSettings.dateFormat == "YYYY-MM-DD") index = 2; - else if (sysSettings.dateFormat == "YYYY/MM/DD") index = 3; - lv_dropdown_set_selected(dateFormatDropdown, index); - } - lv_obj_add_event_cb(dateFormatDropdown, onDateFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr); + // 24-hour format toggle - // Timezone selector + auto* time_format_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_width(time_format_wrapper, LV_PCT(100)); + lv_obj_set_height(time_format_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(time_format_wrapper, 8, 0); + lv_obj_set_style_border_width(time_format_wrapper, 0, 0); - auto* timezone_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_width(timezone_wrapper, LV_PCT(100)); - lv_obj_set_height(timezone_wrapper, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(timezone_wrapper, 8, 0); - lv_obj_set_style_border_width(timezone_wrapper, 0, 0); + auto* time_24h_label = lv_label_create(time_format_wrapper); + lv_label_set_text(time_24h_label, "24-hour format"); + lv_obj_align(time_24h_label, LV_ALIGN_LEFT_MID, 4, 0); - auto* timezone_label = lv_label_create(timezone_wrapper); - lv_label_set_text(timezone_label, "Timezone"); - lv_obj_align(timezone_label, LV_ALIGN_LEFT_MID, 4, 0); + auto* time_24h_switch = lv_switch_create(time_format_wrapper); + lv_obj_align(time_24h_switch, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr); + if (settings::isTimeFormat24Hour()) { + lv_obj_add_state(time_24h_switch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(time_24h_switch, LV_STATE_CHECKED); + } - auto* timezone_button = lv_button_create(timezone_wrapper); - lv_obj_set_width(timezone_button, 150); - lv_obj_align(timezone_button, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(timezone_button, onTimeZonePressed, LV_EVENT_SHORT_CLICKED, nullptr); + // Date format dropdown + + auto* date_format_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_width(date_format_wrapper, LV_PCT(100)); + lv_obj_set_height(date_format_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(date_format_wrapper, 8, 0); + lv_obj_set_style_border_width(date_format_wrapper, 0, 0); + + auto* date_format_label = lv_label_create(date_format_wrapper); + lv_label_set_text(date_format_label, "Date format"); + lv_obj_align(date_format_label, LV_ALIGN_LEFT_MID, 4, 0); + + ctx->dateFormatDropdown = lv_dropdown_create(date_format_wrapper); + lv_obj_set_width(ctx->dateFormatDropdown, 150); + lv_obj_align(ctx->dateFormatDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_dropdown_set_options(ctx->dateFormatDropdown, "MM/DD/YYYY\nDD/MM/YYYY\nYYYY-MM-DD\nYYYY/MM/DD"); + + settings::SystemSettings sysSettings; + if (settings::loadSystemSettings(sysSettings)) { + int index = 0; + if (sysSettings.dateFormat == "DD/MM/YYYY") index = 1; + else if (sysSettings.dateFormat == "YYYY-MM-DD") index = 2; + else if (sysSettings.dateFormat == "YYYY/MM/DD") index = 3; + lv_dropdown_set_selected(ctx->dateFormatDropdown, index); + } + lv_obj_add_event_cb(ctx->dateFormatDropdown, onDateFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr); - timeZoneLabel = lv_label_create(timezone_button); - std::string timeZoneName = settings::getTimeZoneName(); - if (timeZoneName.empty()) { - timeZoneName = "not set"; - } - lv_obj_center(timeZoneLabel); - lv_label_set_text(timeZoneLabel, timeZoneName.c_str()); + // Timezone selector - isShown = true; - } + auto* timezone_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_width(timezone_wrapper, LV_PCT(100)); + lv_obj_set_height(timezone_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(timezone_wrapper, 8, 0); + lv_obj_set_style_border_width(timezone_wrapper, 0, 0); + + auto* timezone_label = lv_label_create(timezone_wrapper); + lv_label_set_text(timezone_label, "Timezone"); + lv_obj_align(timezone_label, LV_ALIGN_LEFT_MID, 4, 0); + + auto* timezone_button = lv_button_create(timezone_wrapper); + lv_obj_set_width(timezone_button, 150); + lv_obj_align(timezone_button, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(timezone_button, onTimeZonePressed, LV_EVENT_SHORT_CLICKED, ctx); - void onHide(AppContext& app) override { - isShown = false; + ctx->timeZoneLabel = lv_label_create(timezone_button); + std::string timeZoneName = settings::getTimeZoneName(); + if (timeZoneName.empty()) { + timeZoneName = "not set"; } + lv_obj_center(ctx->timeZoneLabel); + lv_label_set_text(ctx->timeZoneLabel, timeZoneName.c_str()); +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - void onResult(AppContext& app, LaunchId launchId, Result result, std::unique_ptr bundle) override { - if (result == Result::Ok && bundle != nullptr) { - const auto name = timezone::getResultName(*bundle); - const auto code = timezone::getResultCode(*bundle); - LOG_I(TAG, "Result name=%s code=%s", name.c_str(), code.c_str()); - - // 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()); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + if (event.result.launch_id == ctx.pendingTimeZoneDialogId) { + ctx.pendingTimeZoneDialogId = 0; + if (event.result.result == 0 /* Ok */) { + const auto name = timezone::getLastName(); + LOG_I(TAG, "Result name=%s code=%s", name.c_str(), timezone::getLastCode().c_str()); + if (!name.empty()) { + lvgl_lock(); + lv_label_set_text(ctx.timeZoneLabel, name.c_str()); + lvgl_unlock(); + } + } } - lvgl_unlock(); - } + app_manager_stop(event.result.launch_id); + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "TimeDateSettings", - .appName = "Time & Date", - .appIcon = LVGL_ICON_SHARED_CALENDAR_MONTH, - .appCategory = Category::Settings, - .createApp = create -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); -LaunchId start() { - return app::start(manifest.appId); + return 0; } } // namespace +uint32_t start() { + uint32_t instanceId = 0; + app_manager_start(manifest.id, &instanceId); + return instanceId; +} + +extern const ::AppManifest manifest = { + .id = "TimeDateSettings", + .name = "Time & Date", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } +}; + +} // namespace tt::app::timedatesettings diff --git a/Tactility/Source/app/timezone/TimeZone.cpp b/Tactility/Source/app/timezone/TimeZone.cpp index 37ba02aa5..1429aac21 100644 --- a/Tactility/Source/app/timezone/TimeZone.cpp +++ b/Tactility/Source/app/timezone/TimeZone.cpp @@ -1,19 +1,23 @@ -#include -#include #include #include #include +#include #include #include -#include -#include #include +#include +#include +#include + +#include + #include #include #include #include +#include #include @@ -21,18 +25,39 @@ namespace tt::app::timezone { constexpr auto* TAG = "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; -extern const AppManifest manifest; +namespace { struct TimeZoneEntry { std::string name; std::string code; }; -static bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) { +struct Context { + uint32_t appInstanceId; + Mutex mutex; + std::vector entries; + std::unique_ptr updateTimer; + lv_obj_t* listWidget = nullptr; + lv_obj_t* filterTextareaWidget = nullptr; + bool saveTimeZone = false; + // The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this + // is a plain (non-atomic) field safely shared between the LVGL thread (writer, before + // emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it). + int32_t result = 1; // Cancelled - safety-net default if closed without picking a time zone +}; + + +// The last picked name/code. Static rather than per-instance: simple, and in practice only one +// TimeZone dialog is ever open at a time. Written on the LVGL thread (the item-selected +// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastName()/getLastCode() +// after receiving that event - safe without a lock for the same reason Context::result is (see +// AlertDialog.cpp). +std::string lastName; +std::string lastCode; + +bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) { std::string partial_strip = input.substr(1, input.size() - 3); auto first_end_quote = partial_strip.find('"'); if (first_end_quote == std::string::npos) { @@ -44,215 +69,225 @@ static bool parseEntry(const std::string& input, std::string& outName, std::stri } } -// region Result - -std::string getResultName(const Bundle& bundle) { - std::string result; - bundle.optString(RESULT_BUNDLE_NAME_INDEX, result); - return result; -} - -std::string getResultCode(const Bundle& bundle) { - std::string result; - bundle.optString(RESULT_BUNDLE_CODE_INDEX, result); - return result; +void onTextareaValueChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + if (ctx->updateTimer->isRunning()) { + ctx->updateTimer->stop(); + } + ctx->updateTimer->start(); + ctx->mutex.unlock(); + } } -void setResultName(Bundle& bundle, const std::string& name) { - bundle.putString(RESULT_BUNDLE_NAME_INDEX, name); -} +void createListItem(Context* ctx, lv_obj_t* list, const std::string& title, size_t index) { + auto* btn = lv_list_add_button(list, nullptr, title.c_str()); + struct ButtonContext { + Context* ctx; + size_t index; + }; + auto* buttonCtx = new ButtonContext { ctx, index }; + lv_obj_add_event_cb(btn, [](lv_event_t* e) { + auto* buttonCtx = static_cast(lv_event_get_user_data(e)); + delete buttonCtx; + }, LV_EVENT_DELETE, buttonCtx); + lv_obj_add_event_cb(btn, [](lv_event_t* e) { + auto* buttonCtx = static_cast(lv_event_get_user_data(e)); + auto* ctx = buttonCtx->ctx; + auto index = buttonCtx->index; + LOG_I(TAG, "Selected item at index %d", (int)index); -void setResultCode(Bundle& bundle, const std::string& code) { - bundle.putString(RESULT_BUNDLE_CODE_INDEX, code); -} + auto& entry = ctx->entries[index]; -// endregion + if (ctx->saveTimeZone) { + settings::setTimeZone(entry.name, entry.code); + } -class TimeZoneApp final : public App { + lastName = entry.name; + lastCode = entry.code; - Mutex mutex; - std::vector entries; - std::unique_ptr updateTimer; - lv_obj_t* listWidget = nullptr; - lv_obj_t* filterTextareaWidget = nullptr; - bool saveTimeZone = false; + ctx->result = 0; // Ok + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); + }, LV_EVENT_SHORT_CLICKED, buttonCtx); +} - static void onTextareaValueChangedCallback(lv_event_t* e) { - auto* app = (TimeZoneApp*)lv_event_get_user_data(e); - app->onTextareaValueChanged(e); +void readTimeZones(Context* ctx, std::string filter) { + auto path = std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv"; + auto* file = fopen(path.c_str(), "rb"); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", path.c_str()); + return; } - - void onTextareaValueChanged(lv_event_t* e) { - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - if (updateTimer->isRunning()) { - updateTimer->stop(); + char line[96]; + std::string name; + std::string code; + uint32_t count = 0; + std::vector new_entries; + while (fgets(line, 96, file)) { + if (parseEntry(line, name, code)) { + if (string::lowercase(name).find(filter) != std::string::npos) { + count++; + new_entries.push_back({.name = name, .code = code}); + + // Safety guard + if (count > 50) { + // TODO: Show warning that we're not displaying a complete list + break; + } } - - updateTimer->start(); - - mutex.unlock(); + } else { + LOG_E(TAG, "Parse error at line %llu", count); } } - static void onListItemSelectedCallback(lv_event_t* e) { - auto index = reinterpret_cast(lv_event_get_user_data(e)); - auto app = std::static_pointer_cast(getCurrentApp()); - assert(app != nullptr); - app->onListItemSelected(index); - } - - void onListItemSelected(std::size_t index) { - LOG_I(TAG, "Selected item at index %d", (int)index); - - auto& entry = entries[index]; + fclose(file); - if (saveTimeZone) { - settings::setTimeZone(entry.name, entry.code); - } + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + ctx->entries = std::move(new_entries); + ctx->mutex.unlock(); + } else { + LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); + } - auto bundle = std::make_unique(); - setResultName(*bundle, entry.name); - setResultCode(*bundle, entry.code); + LOG_I(TAG, "Processed %llu entries", count); +} - setResult(Result::Ok, std::move(bundle)); - stop(manifest.appId); +void updateList(Context* ctx) { + if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) { + std::string filter = string::lowercase(std::string(lv_textarea_get_text(ctx->filterTextareaWidget))); + lvgl_unlock(); + readTimeZones(ctx, filter); + } else { + LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL"); + return; } - static void createListItem(lv_obj_t* list, const std::string& title, size_t index) { - auto* btn = lv_list_add_button(list, nullptr, title.c_str()); - lv_obj_add_event_cb(btn, &onListItemSelectedCallback, LV_EVENT_SHORT_CLICKED, (void*)index); - } + if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) { + if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { + lv_obj_clean(ctx->listWidget); - void readTimeZones(std::string filter) { - auto path = std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv"; - auto* file = fopen(path.c_str(), "rb"); - if (file == nullptr) { - LOG_E(TAG, "Failed to open %s", path.c_str()); - return; - } - char line[96]; - std::string name; - std::string code; - uint32_t count = 0; - std::vector new_entries; - while (fgets(line, 96, file)) { - if (parseEntry(line, name, code)) { - if (string::lowercase(name).find(filter) != std::string::npos) { - count++; - new_entries.push_back({.name = name, .code = code}); - - // Safety guard - if (count > 50) { - // TODO: Show warning that we're not displaying a complete list - break; - } - } - } else { - LOG_E(TAG, "Parse error at line %llu", count); + uint32_t index = 0; + for (auto& entry : ctx->entries) { + createListItem(ctx, ctx->listWidget, entry.name, index); + index++; } - } - - fclose(file); - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - entries = std::move(new_entries); - mutex.unlock(); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); + ctx->mutex.unlock(); } - LOG_I(TAG, "Processed %llu entries", count); + lvgl_unlock(); + } else { + LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL"); } +} - void updateList() { - if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) { - std::string filter = string::lowercase(std::string(lv_textarea_get_text(filterTextareaWidget))); - lvgl_unlock(); - readTimeZones(filter); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL"); - return; - } +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) { - if (mutex.lock(100 / portTICK_PERIOD_MS)) { - lv_obj_clean(listWidget); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Select Time zone"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* search_wrapper = lv_obj_create(parent); + lv_obj_set_size(search_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(search_wrapper, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(search_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_all(search_wrapper, 0, 0); + lv_obj_set_style_border_width(search_wrapper, 0, 0); + + auto* icon = lv_image_create(search_wrapper); + lv_obj_set_style_margin_left(icon, 8, 0); + lv_obj_set_style_image_recolor_opa(icon, 255, 0); + lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0); + lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_font(), LV_STATE_DEFAULT); + lv_image_set_src(icon, LVGL_ICON_SHARED_SEARCH); + + auto* textarea = lv_textarea_create(search_wrapper); + lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam"); + lv_textarea_set_one_line(textarea, true); + lv_obj_add_event_cb(textarea, onTextareaValueChanged, LV_EVENT_VALUE_CHANGED, ctx); + ctx->filterTextareaWidget = textarea; + lv_obj_set_flex_grow(textarea, 1); + + auto* list = lv_list_create(parent); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_style_border_width(list, 0, 0); + ctx->listWidget = list; +} - uint32_t index = 0; - for (auto& entry : entries) { - createListItem(listWidget, entry.name, index); - index++; - } +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + // argv layout: [0]="1"/"0" (saveTimeZone). - mutex.unlock(); - } + Context ctx; + ctx.appInstanceId = appInstanceId; + ctx.saveTimeZone = argc > 0 && argv[0][0] == '1'; - lvgl_unlock(); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL"); + ctx.updateTimer = std::make_unique(Timer::Type::Once, 500 / portTICK_PERIOD_MS, [&ctx] { + updateList(&ctx); + }); + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + ctx.updateTimer->start(); + + while (true) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(appInstanceId); // no-op: modal children never supersede anything + break; } } -public: - - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - lvgl::toolbar_create(parent, app); - - auto* search_wrapper = lv_obj_create(parent); - lv_obj_set_size(search_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(search_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(search_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_all(search_wrapper, 0, 0); - lv_obj_set_style_border_width(search_wrapper, 0, 0); - - auto* icon = lv_image_create(search_wrapper); - lv_obj_set_style_margin_left(icon, 8, 0); - lv_obj_set_style_image_recolor_opa(icon, 255, 0); - lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0); - lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_font(), LV_STATE_DEFAULT); - lv_image_set_src(icon, LVGL_ICON_SHARED_SEARCH); - - auto* textarea = lv_textarea_create(search_wrapper); - lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam"); - lv_textarea_set_one_line(textarea, true); - lv_obj_add_event_cb(textarea, onTextareaValueChangedCallback, LV_EVENT_VALUE_CHANGED, this); - filterTextareaWidget = textarea; - lv_obj_set_flex_grow(textarea, 1); - - auto* list = lv_list_create(parent); - lv_obj_set_width(list, LV_PCT(100)); - lv_obj_set_flex_grow(list, 1); - lv_obj_set_style_border_width(list, 0, 0); - listWidget = list; - } + ctx.updateTimer->stop(); + window_manager_remove(window); + app_event_unsubscribe(&sub); - void onCreate(AppContext& app) override { - auto parameters = app.getParameters(); - if (parameters != nullptr) { - parameters->optBool(PARAM_SAVE_TIME_ZONE, saveTimeZone); - } + return ctx.result; +} - updateTimer = std::make_unique(Timer::Type::Once, 500 / portTICK_PERIOD_MS, [this] { - updateList(); - }); - } -}; +} // namespace -extern const AppManifest manifest = { - .appId = "TimeZone", - .appName = "Select Time zone", - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create -}; +uint32_t start(uint32_t callerAppInstanceId, bool saveTimeZone) { + const char* argv[] = { saveTimeZone ? "1" : "0" }; + uint32_t instanceId = 0; + app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId); + return instanceId; +} -LaunchId start(bool saveTimeZone) { - auto bundle = std::make_shared(); - bundle->putBool(PARAM_SAVE_TIME_ZONE, saveTimeZone); - return app::start(manifest.appId, bundle); +std::string getLastName() { + return lastName; } +std::string getLastCode() { + return lastCode; +} + +extern const ::AppManifest manifest = { + .id = "TimeZone", + .name = "Select Time zone", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + } diff --git a/Tactility/Source/app/touchcalibration/TouchCalibration.cpp b/Tactility/Source/app/touchcalibration/TouchCalibration.cpp index e637c3b9c..fba9994f2 100644 --- a/Tactility/Source/app/touchcalibration/TouchCalibration.cpp +++ b/Tactility/Source/app/touchcalibration/TouchCalibration.cpp @@ -5,6 +5,12 @@ #include #include +#include +#include +#include + +#include + #include #include #include @@ -16,20 +22,19 @@ namespace tt::app::touchcalibration { constexpr auto* TAG = "TouchCalibration"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; -LaunchId start() { - return app::start(manifest.appId); -} +namespace { -class TouchCalibrationApp final : public App { +constexpr int32_t TARGET_MARGIN = 24; - static constexpr int32_t TARGET_MARGIN = 24; +struct Sample { + uint16_t x; + uint16_t y; +}; - struct Sample { - uint16_t x; - uint16_t y; - }; +struct Context { + uint32_t appInstanceId; Sample samples[4] = {}; uint8_t sampleCount = 0; @@ -39,227 +44,254 @@ class TouchCalibrationApp final : public App { lv_obj_t* target = nullptr; lv_obj_t* titleLabel = nullptr; lv_obj_t* hintLabel = nullptr; +}; - static void onPress(lv_event_t* event) { - auto* self = static_cast(lv_event_get_user_data(event)); - if (self != nullptr) { - self->onPressInternal(event); - } + +lv_point_t getTargetPoint(uint8_t index, lv_coord_t width, lv_coord_t height) { + switch (index) { + case 0: + return {.x = TARGET_MARGIN, .y = TARGET_MARGIN}; + case 1: + return {.x = width - TARGET_MARGIN, .y = TARGET_MARGIN}; + case 2: + return {.x = width - TARGET_MARGIN, .y = height - TARGET_MARGIN}; + default: + return {.x = TARGET_MARGIN, .y = height - TARGET_MARGIN}; } +} - static lv_point_t getTargetPoint(uint8_t index, lv_coord_t width, lv_coord_t height) { - switch (index) { - case 0: - return {.x = TARGET_MARGIN, .y = TARGET_MARGIN}; - case 1: - return {.x = width - TARGET_MARGIN, .y = TARGET_MARGIN}; - case 2: - return {.x = width - TARGET_MARGIN, .y = height - TARGET_MARGIN}; - default: - return {.x = TARGET_MARGIN, .y = height - TARGET_MARGIN}; - } +void updateUi(Context* ctx) { + if (ctx->target == nullptr || ctx->root == nullptr || ctx->titleLabel == nullptr || ctx->hintLabel == nullptr) { + return; } - void updateUi() { - if (target == nullptr || root == nullptr || titleLabel == nullptr || hintLabel == nullptr) { - return; - } + const auto width = lv_obj_get_content_width(ctx->root); + const auto height = lv_obj_get_content_height(ctx->root); - const auto width = lv_obj_get_content_width(root); - const auto height = lv_obj_get_content_height(root); + if (ctx->sampleCount < 4) { + const auto point = getTargetPoint(ctx->sampleCount, width, height); + lv_obj_set_pos(ctx->target, point.x - 14, point.y - 14); + lv_label_set_text(ctx->titleLabel, "Touchscreen Calibration"); + lv_label_set_text_fmt(ctx->hintLabel, "Tap target %u/4", static_cast(ctx->sampleCount + 1)); + } +} - if (sampleCount < 4) { - const auto point = getTargetPoint(sampleCount, width, height); - lv_obj_set_pos(target, point.x - 14, point.y - 14); - lv_label_set_text(titleLabel, "Touchscreen Calibration"); - lv_label_set_text_fmt(hintLabel, "Tap target %u/4", static_cast(sampleCount + 1)); - } +// Drives the on-screen outcome text/state; the actual result (Ok/Error) is reported to the +// caller from onPress() below, via ctx->calibrationApplied, once the user taps to dismiss. +void finishCalibration(Context* ctx) { + const int32_t xLow = (static_cast(ctx->samples[0].x) + static_cast(ctx->samples[3].x)) / 2; + const int32_t xHigh = (static_cast(ctx->samples[1].x) + static_cast(ctx->samples[2].x)) / 2; + const int32_t yLow = (static_cast(ctx->samples[0].y) + static_cast(ctx->samples[1].y)) / 2; + const int32_t yHigh = (static_cast(ctx->samples[2].y) + static_cast(ctx->samples[3].y)) / 2; + + // Targets sit TARGET_MARGIN in from each edge (see getTargetPoint()), not at the screen + // edges themselves - xLow/xHigh/yLow/yHigh are raw samples at those inset positions, not + // at 0/width or 0/height. Extrapolate them out to the true edges so the saved range (which + // lvgl_pointer.h maps onto the full [0, resolution) display range) lines up correctly + // across the whole screen instead of being off by a margin's worth of scale and offset. + const auto width = lv_obj_get_content_width(ctx->root); + const auto height = lv_obj_get_content_height(ctx->root); + const int32_t xSpan = static_cast(width) - 2 * TARGET_MARGIN; + const int32_t ySpan = static_cast(height) - 2 * TARGET_MARGIN; + + if (xSpan <= 0 || ySpan <= 0) { + lv_label_set_text(ctx->titleLabel, "Calibration Failed"); + lv_label_set_text(ctx->hintLabel, "Screen too small. Tap to close."); + lv_obj_add_flag(ctx->target, LV_OBJ_FLAG_HIDDEN); + return; } - void finishCalibration() { - const int32_t xLow = (static_cast(samples[0].x) + static_cast(samples[3].x)) / 2; - const int32_t xHigh = (static_cast(samples[1].x) + static_cast(samples[2].x)) / 2; - const int32_t yLow = (static_cast(samples[0].y) + static_cast(samples[1].y)) / 2; - const int32_t yHigh = (static_cast(samples[2].y) + static_cast(samples[3].y)) / 2; - - // Targets sit TARGET_MARGIN in from each edge (see getTargetPoint()), not at the screen - // edges themselves - xLow/xHigh/yLow/yHigh are raw samples at those inset positions, not - // at 0/width or 0/height. Extrapolate them out to the true edges so the saved range (which - // lvgl_pointer.h maps onto the full [0, resolution) display range) lines up correctly - // across the whole screen instead of being off by a margin's worth of scale and offset. - const auto width = lv_obj_get_content_width(root); - const auto height = lv_obj_get_content_height(root); - const int32_t xSpan = static_cast(width) - 2 * TARGET_MARGIN; - const int32_t ySpan = static_cast(height) - 2 * TARGET_MARGIN; - - if (xSpan <= 0 || ySpan <= 0) { - lv_label_set_text(titleLabel, "Calibration Failed"); - lv_label_set_text(hintLabel, "Screen too small. Tap to close."); - lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN); - setResult(Result::Error); - return; - } + const int32_t xMin = xLow - (xHigh - xLow) * TARGET_MARGIN / xSpan; + const int32_t xMax = xHigh + (xHigh - xLow) * TARGET_MARGIN / xSpan; + const int32_t yMin = yLow - (yHigh - yLow) * TARGET_MARGIN / ySpan; + const int32_t yMax = yHigh + (yHigh - yLow) * TARGET_MARGIN / ySpan; + + settings::touch::TouchCalibrationSettings settings = settings::touch::getDefault(); + settings.enabled = true; + settings.xMin = xMin; + settings.xMax = xMax; + settings.yMin = yMin; + settings.yMax = yMax; + + if (!settings::touch::isValid(settings)) { + lv_label_set_text(ctx->titleLabel, "Calibration Failed"); + lv_label_set_text(ctx->hintLabel, "Range invalid. Tap to close."); + lv_obj_add_flag(ctx->target, LV_OBJ_FLAG_HIDDEN); + return; + } - const int32_t xMin = xLow - (xHigh - xLow) * TARGET_MARGIN / xSpan; - const int32_t xMax = xHigh + (xHigh - xLow) * TARGET_MARGIN / xSpan; - const int32_t yMin = yLow - (yHigh - yLow) * TARGET_MARGIN / ySpan; - const int32_t yMax = yHigh + (yHigh - yLow) * TARGET_MARGIN / ySpan; - - settings::touch::TouchCalibrationSettings settings = settings::touch::getDefault(); - settings.enabled = true; - settings.xMin = xMin; - settings.xMax = xMax; - settings.yMin = yMin; - settings.yMax = yMax; - - if (!settings::touch::isValid(settings)) { - lv_label_set_text(titleLabel, "Calibration Failed"); - lv_label_set_text(hintLabel, "Range invalid. Tap to close."); - lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN); - setResult(Result::Error); - return; - } + if (!settings::touch::save(settings)) { + lv_label_set_text(ctx->titleLabel, "Calibration Failed"); + lv_label_set_text(ctx->hintLabel, "Unable to save settings. Tap to close."); + lv_obj_add_flag(ctx->target, LV_OBJ_FLAG_HIDDEN); + return; + } - if (!settings::touch::save(settings)) { - lv_label_set_text(titleLabel, "Calibration Failed"); - lv_label_set_text(hintLabel, "Unable to save settings. Tap to close."); - lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN); - setResult(Result::Error); - return; - } + LvglPointerCalibration calibration = { + .x_min = xMin, + .x_max = xMax, + .y_min = yMin, + .y_max = yMax, + }; + lvgl_lock(); + auto* indev = lvgl_pointer_get_default(); + if (indev != nullptr) { + lvgl_pointer_set_calibration(indev, &calibration); + } + lvgl_unlock(); + ctx->calibrationApplied = true; - LvglPointerCalibration calibration = { - .x_min = xMin, - .x_max = xMax, - .y_min = yMin, - .y_max = yMax, - }; - lvgl_lock(); - auto* indev = lvgl_pointer_get_default(); - if (indev != nullptr) { - lvgl_pointer_set_calibration(indev, &calibration); - } - lvgl_unlock(); - calibrationApplied = true; + LOG_I(TAG, "Saved calibration x=[%d, %d] y=[%d, %d]", xMin, xMax, yMin, yMax); + lv_label_set_text(ctx->titleLabel, "Calibration Complete"); + lv_label_set_text(ctx->hintLabel, "Touch anywhere to continue."); + lv_obj_add_flag(ctx->target, LV_OBJ_FLAG_HIDDEN); +} - LOG_I(TAG, "Saved calibration x=[%d, %d] y=[%d, %d]", xMin, xMax, yMin, yMax); - lv_label_set_text(titleLabel, "Calibration Complete"); - lv_label_set_text(hintLabel, "Touch anywhere to continue."); - lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN); - setResult(Result::Ok); +void onPress(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* indev = lv_event_get_indev(event); + if (indev == nullptr) { + return; } - void onPressInternal(lv_event_t* event) { - auto* indev = lv_event_get_indev(event); - if (indev == nullptr) { - return; - } + lv_point_t point = {0, 0}; + lv_indev_get_point(indev, &point); - lv_point_t point = {0, 0}; - lv_indev_get_point(indev, &point); + if (ctx->sampleCount < 4) { + ctx->samples[ctx->sampleCount] = { + .x = static_cast(std::max(static_cast(0), point.x)), + .y = static_cast(std::max(static_cast(0), point.y)), + }; + ctx->sampleCount++; - if (sampleCount < 4) { - samples[sampleCount] = { - .x = static_cast(std::max(static_cast(0), point.x)), - .y = static_cast(std::max(static_cast(0), point.y)), - }; - sampleCount++; - - if (sampleCount < 4) { - updateUi(); - } else { - finishCalibration(); - } - return; + if (ctx->sampleCount < 4) { + updateUi(ctx); + } else { + finishCalibration(ctx); } + return; + } + + // Async, non-blocking - must NOT call app_manager_stop()/app_manager_finish() directly + // here: this callback runs ON the LVGL task, and app-lifecycle transitions must happen on + // this app's own thread (woken up via app_event_await() below). The result (Ok/Error) is + // reported by appMain() itself when it returns, based on ctx.calibrationApplied. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_style_bg_color(parent, lv_color_black(), LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(parent, 0, LV_STATE_DEFAULT); + + ctx->root = lv_obj_create(parent); + lv_obj_set_size(ctx->root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_opa(ctx->root, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->root, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(ctx->root, 0, LV_STATE_DEFAULT); + + ctx->titleLabel = lv_label_create(ctx->root); + lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, 14); + lv_obj_set_style_text_color(ctx->titleLabel, lv_color_white(), LV_STATE_DEFAULT); + lv_label_set_text(ctx->titleLabel, "Touchscreen Calibration"); + + ctx->hintLabel = lv_label_create(ctx->root); + lv_obj_align(ctx->hintLabel, LV_ALIGN_BOTTOM_MID, 0, -14); + lv_obj_set_style_text_color(ctx->hintLabel, lv_color_white(), LV_STATE_DEFAULT); + lv_label_set_text(ctx->hintLabel, "Tap target 1/4"); + + ctx->target = lv_button_create(ctx->root); + lv_obj_set_size(ctx->target, 28, 28); + lv_obj_set_style_radius(ctx->target, LV_RADIUS_CIRCLE, LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(ctx->target, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT); + // Ensure root receives all presses for sampling. + lv_obj_remove_flag(ctx->target, LV_OBJ_FLAG_CLICKABLE); + + auto* targetLabel = lv_label_create(ctx->target); + lv_label_set_text(targetLabel, "+"); + lv_obj_center(targetLabel); + + lv_obj_add_flag(ctx->root, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(ctx->root, onPress, LV_EVENT_PRESSED, ctx); + + updateUi(ctx); +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; - stop(manifest.appId); + // Clear any active calibration so the taps sampled below are raw, uncalibrated coordinates. + lvgl_lock(); + auto* startIndev = lvgl_pointer_get_default(); + if (startIndev != nullptr) { + lvgl_pointer_set_calibration(startIndev, nullptr); } + lvgl_unlock(); -public: + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - void onCreate(AppContext& app) override { - (void)app; - // Clear any active calibration so the taps sampled below are raw, uncalibrated coordinates. - lvgl_lock(); - auto* indev = lvgl_pointer_get_default(); - if (indev != nullptr) { - lvgl_pointer_set_calibration(indev, nullptr); + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } - lvgl_unlock(); } - void onDestroy(AppContext& app) override { - (void)app; - // finishCalibration() already applied a new calibration on success. On cancel/failure, - // restore whatever calibration was on disk before onCreate() cleared it above. - if (calibrationApplied) { - return; - } + window_manager_remove(window); + app_event_unsubscribe(&sub); + // finishCalibration() already applied a new calibration on success. On cancel/failure, + // restore whatever calibration was on disk before the block above cleared it. + if (!ctx.calibrationApplied) { settings::touch::TouchCalibrationSettings settings; lvgl_lock(); - auto* indev = lvgl_pointer_get_default(); - if (indev != nullptr && settings::touch::load(settings) && settings.enabled && settings::touch::isValid(settings)) { + auto* endIndev = lvgl_pointer_get_default(); + if (endIndev != nullptr && settings::touch::load(settings) && settings.enabled && settings::touch::isValid(settings)) { LvglPointerCalibration calibration = { .x_min = settings.xMin, .x_max = settings.xMax, .y_min = settings.yMin, .y_max = settings.yMax, }; - lvgl_pointer_set_calibration(indev, &calibration); + lvgl_pointer_set_calibration(endIndev, &calibration); } lvgl_unlock(); } - void onShow(AppContext& app, lv_obj_t* parent) override { - (void)app; - - lv_obj_set_style_bg_color(parent, lv_color_black(), LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT); - lv_obj_set_style_radius(parent, 0, LV_STATE_DEFAULT); - - root = lv_obj_create(parent); - lv_obj_set_size(root, LV_PCT(100), LV_PCT(100)); - lv_obj_set_style_bg_opa(root, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(root, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(root, 0, LV_STATE_DEFAULT); - - titleLabel = lv_label_create(root); - lv_obj_align(titleLabel, LV_ALIGN_TOP_MID, 0, 14); - lv_obj_set_style_text_color(titleLabel, lv_color_white(), LV_STATE_DEFAULT); - lv_label_set_text(titleLabel, "Touchscreen Calibration"); - - hintLabel = lv_label_create(root); - lv_obj_align(hintLabel, LV_ALIGN_BOTTOM_MID, 0, -14); - lv_obj_set_style_text_color(hintLabel, lv_color_white(), LV_STATE_DEFAULT); - lv_label_set_text(hintLabel, "Tap target 1/4"); - - target = lv_button_create(root); - lv_obj_set_size(target, 28, 28); - lv_obj_set_style_radius(target, LV_RADIUS_CIRCLE, LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(target, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT); - // Ensure root receives all presses for sampling. - lv_obj_remove_flag(target, LV_OBJ_FLAG_CLICKABLE); - - auto* targetLabel = lv_label_create(target); - lv_label_set_text(targetLabel, "+"); - lv_obj_center(targetLabel); - - lv_obj_add_flag(root, LV_OBJ_FLAG_CLICKABLE); - lv_obj_add_event_cb(root, onPress, LV_EVENT_PRESSED, this); - - updateUi(); - } -}; + return ctx.calibrationApplied ? 0 : 2; // Ok : Error +} + +} // namespace + +uint32_t start(uint32_t callerAppInstanceId) { + uint32_t instanceId = 0; + app_manager_start_for_result(manifest.id, callerAppInstanceId, 0, nullptr, &instanceId); + return instanceId; +} -extern const AppManifest manifest = { - .appId = "TouchCalibration", - .appName = "Touch Calibration", - .appCategory = Category::Settings, - .appFlags = AppManifest::Flags::HideStatusBar, - .createApp = create +extern const ::AppManifest manifest = { + .id = "TouchCalibration", + .name = "Touch Calibration", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace tt::app::touchcalibration diff --git a/Tactility/Source/app/trackball/TrackballSettings.cpp b/Tactility/Source/app/trackball/TrackballSettings.cpp index 165ef19e9..56095db98 100644 --- a/Tactility/Source/app/trackball/TrackballSettings.cpp +++ b/Tactility/Source/app/trackball/TrackballSettings.cpp @@ -2,18 +2,25 @@ #include #include -#include #include +#include #include #include #include -#include #include +#include +#include +#include + +#include + namespace tt::app::trackballsettings { +extern const ::AppManifest manifest; + constexpr auto* TAG = "TrackballSettings"; // Convert mode to dropdown index (dropdown order: Encoder=0, Pointer=1) @@ -25,8 +32,29 @@ static uint32_t modeToDropdownIndex(LvglTrackballMode mode) { return 0; // default to Encoder } -class TrackballSettingsApp final : public App { +static lv_indev_t* findFirstTrackballIndev() { + lv_indev_t* indev = lv_indev_get_next(nullptr); + while (indev != nullptr) { + void* driver_data = lv_indev_get_driver_data(indev); + if (driver_data) { + LvglDeviceContext* context = static_cast(driver_data); + if (context->device) { + const DeviceType* device_type = device_get_type(context->device); + if (device_type == &TRACKBALL_TYPE) { + return indev; + } + } + } + indev = lv_indev_get_next(indev); + } + return nullptr; +} + +namespace { + +struct Context { + uint32_t appInstanceId; LvglTrackballSettings tbSettings; bool updated = false; // The trackball indev currently bound by lvgl_devices_attach() at LVGL startup, if any - @@ -37,218 +65,249 @@ class TrackballSettingsApp final : public App { lv_obj_t* trackballModeDropdown = nullptr; lv_obj_t* encoderSensitivitySlider = nullptr; lv_obj_t* pointerSensitivitySlider = nullptr; +}; - void applyLive() { - if (trackballIndev == nullptr) { - return; - } - lvgl_lock(); - lvgl_trackball_set_settings(trackballIndev, &tbSettings); - if (tbSettings.mode == LVGL_TRACKBALL_MODE_POINTER) { - lvgl_trackball_set_cursor_image(trackballIndev, TT_ASSETS_UI_CURSOR); - } - lvgl_unlock(); + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void applyLive(Context* ctx) { + if (ctx->trackballIndev == nullptr) { + return; + } + lvgl_lock(); + lvgl_trackball_set_settings(ctx->trackballIndev, &ctx->tbSettings); + if (ctx->tbSettings.mode == LVGL_TRACKBALL_MODE_POINTER) { + lvgl_trackball_set_cursor_image(ctx->trackballIndev, TT_ASSETS_UI_CURSOR); } + lvgl_unlock(); +} - static void onTrackballSwitch(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - bool enabled = lv_obj_has_state(app->switchTrackball, LV_STATE_CHECKED); - app->tbSettings.enabled = enabled; - app->updated = true; - app->applyLive(); - - // Enable/disable controls based on trackball state - if (enabled) { - if (app->trackballModeDropdown) lv_obj_clear_state(app->trackballModeDropdown, LV_STATE_DISABLED); - if (app->encoderSensitivitySlider) lv_obj_clear_state(app->encoderSensitivitySlider, LV_STATE_DISABLED); - if (app->pointerSensitivitySlider) lv_obj_clear_state(app->pointerSensitivitySlider, LV_STATE_DISABLED); - } else { - if (app->trackballModeDropdown) lv_obj_add_state(app->trackballModeDropdown, LV_STATE_DISABLED); - if (app->encoderSensitivitySlider) lv_obj_add_state(app->encoderSensitivitySlider, LV_STATE_DISABLED); - if (app->pointerSensitivitySlider) lv_obj_add_state(app->pointerSensitivitySlider, LV_STATE_DISABLED); - } +void onTrackballSwitch(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + bool enabled = lv_obj_has_state(ctx->switchTrackball, LV_STATE_CHECKED); + ctx->tbSettings.enabled = enabled; + ctx->updated = true; + applyLive(ctx); + + // Enable/disable controls based on trackball state + if (enabled) { + if (ctx->trackballModeDropdown) lv_obj_clear_state(ctx->trackballModeDropdown, LV_STATE_DISABLED); + if (ctx->encoderSensitivitySlider) lv_obj_clear_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED); + if (ctx->pointerSensitivitySlider) lv_obj_clear_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED); + } else { + if (ctx->trackballModeDropdown) lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED); + if (ctx->encoderSensitivitySlider) lv_obj_add_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED); + if (ctx->pointerSensitivitySlider) lv_obj_add_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED); } +} - static void onTrackballModeChanged(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - uint32_t selected = lv_dropdown_get_selected(app->trackballModeDropdown); +void onTrackballModeChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + uint32_t selected = lv_dropdown_get_selected(ctx->trackballModeDropdown); - // Validate selection matches expected enum values (dropdown order: Encoder=0, Pointer=1) - LvglTrackballMode mode; - switch (selected) { - case 0: mode = LVGL_TRACKBALL_MODE_ENCODER; break; - case 1: mode = LVGL_TRACKBALL_MODE_POINTER; break; - default: return; // Invalid selection, ignore - } + // Validate selection matches expected enum values (dropdown order: Encoder=0, Pointer=1) + LvglTrackballMode mode; + switch (selected) { + case 0: mode = LVGL_TRACKBALL_MODE_ENCODER; break; + case 1: mode = LVGL_TRACKBALL_MODE_POINTER; break; + default: return; // Invalid selection, ignore + } - app->tbSettings.mode = mode; - app->updated = true; + ctx->tbSettings.mode = mode; + ctx->updated = true; - // Apply mode change immediately - app->applyLive(); - } + // Apply mode change immediately + applyLive(ctx); +} - static void onEncoderSensitivityChanged(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - int32_t value = lv_slider_get_value(app->encoderSensitivitySlider); - app->tbSettings.encoder_sensitivity = static_cast(value); - app->updated = true; +void onEncoderSensitivityChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + int32_t value = lv_slider_get_value(ctx->encoderSensitivitySlider); + ctx->tbSettings.encoder_sensitivity = static_cast(value); + ctx->updated = true; - // Apply immediately - app->applyLive(); - } + // Apply immediately + applyLive(ctx); +} - static void onPointerSensitivityChanged(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - int32_t value = lv_slider_get_value(app->pointerSensitivitySlider); - app->tbSettings.pointer_sensitivity = static_cast(value); - app->updated = true; +void onPointerSensitivityChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + int32_t value = lv_slider_get_value(ctx->pointerSensitivitySlider); + ctx->tbSettings.pointer_sensitivity = static_cast(value); + ctx->updated = true; - // Apply immediately - app->applyLive(); + // Apply immediately + applyLive(ctx); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + ctx->tbSettings = settings::trackball::loadOrGetDefault(); + auto ui_density = lvgl_get_ui_density(); + ctx->updated = false; + ctx->trackballIndev = findFirstTrackballIndev(); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Trackball"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + if (ctx->trackballIndev == nullptr) { + auto* wrapper = lv_obj_create(parent); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + auto* label = lv_label_create(wrapper); + lv_label_set_text(label, "No trackball device found"); + return; } - static lv_indev_t* findFirstTrackballIndev() { - lv_indev_t* indev = lv_indev_get_next(nullptr); - while (indev != nullptr) { - void* driver_data = lv_indev_get_driver_data(indev); - if (driver_data) { - LvglDeviceContext* context = static_cast(driver_data); - if (context->device) { - const DeviceType* device_type = device_get_type(context->device); - if (device_type == &TRACKBALL_TYPE) { - return indev; - } - } - } + // The live indev may still be running with lvgl_trackball_settings_get_default() (it's + // bound at LVGL startup before persisted settings are known) - bring it in line with what + // this screen is about to display. + applyLive(ctx); + + ctx->switchTrackball = lvgl_toolbar_add_switch_action(toolbar); + lv_obj_add_event_cb(ctx->switchTrackball, onTrackballSwitch, LV_EVENT_VALUE_CHANGED, ctx); + if (ctx->tbSettings.enabled) lv_obj_add_state(ctx->switchTrackball, LV_STATE_CHECKED); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + // Trackball mode dropdown + auto* tb_mode_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(tb_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(tb_mode_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(tb_mode_wrapper, 0, LV_STATE_DEFAULT); + + auto* tb_mode_label = lv_label_create(tb_mode_wrapper); + lv_label_set_text(tb_mode_label, "Mode"); + lv_obj_align(tb_mode_label, LV_ALIGN_LEFT_MID, 0, 0); + + ctx->trackballModeDropdown = lv_dropdown_create(tb_mode_wrapper); + lv_dropdown_set_options(ctx->trackballModeDropdown, "Encoder\nPointer"); + lv_obj_align(ctx->trackballModeDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + lv_dropdown_set_selected(ctx->trackballModeDropdown, modeToDropdownIndex(ctx->tbSettings.mode)); + lv_obj_add_event_cb(ctx->trackballModeDropdown, onTrackballModeChanged, LV_EVENT_VALUE_CHANGED, ctx); + + // Disable dropdown if trackball is disabled + if (!ctx->tbSettings.enabled) { + lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED); + } - indev = lv_indev_get_next(indev); - } - return nullptr; + // Encoder sensitivity slider + auto* enc_sens_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(enc_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(enc_sens_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(enc_sens_wrapper, 0, LV_STATE_DEFAULT); + if (ui_density != LVGL_UI_DENSITY_COMPACT) { + lv_obj_set_style_pad_ver(enc_sens_wrapper, 4, LV_STATE_DEFAULT); } -public: - void onShow(AppContext& app, lv_obj_t* parent) override { - tbSettings = settings::trackball::loadOrGetDefault(); - auto ui_density = lvgl_get_ui_density(); - updated = false; - trackballIndev = findFirstTrackballIndev(); - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - lv_obj_t* toolbar = lvgl::toolbar_create(parent, app); - - if (trackballIndev == nullptr) { - auto* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - auto* label = lv_label_create(wrapper); - lv_label_set_text(label, "No trackball device found"); - return; - } + auto* enc_sens_label = lv_label_create(enc_sens_wrapper); + lv_label_set_text(enc_sens_label, "Encoder Speed"); + lv_obj_align(enc_sens_label, LV_ALIGN_LEFT_MID, 0, 0); - // The live indev may still be running with lvgl_trackball_settings_get_default() (it's - // bound at LVGL startup before persisted settings are known) - bring it in line with what - // this screen is about to display. - applyLive(); - - switchTrackball = lvgl_toolbar_add_switch_action(toolbar); - lv_obj_add_event_cb(switchTrackball, onTrackballSwitch, LV_EVENT_VALUE_CHANGED, this); - if (tbSettings.enabled) lv_obj_add_state(switchTrackball, LV_STATE_CHECKED); - - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); - - // Trackball mode dropdown - auto* tb_mode_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(tb_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(tb_mode_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(tb_mode_wrapper, 0, LV_STATE_DEFAULT); - - auto* tb_mode_label = lv_label_create(tb_mode_wrapper); - lv_label_set_text(tb_mode_label, "Mode"); - lv_obj_align(tb_mode_label, LV_ALIGN_LEFT_MID, 0, 0); - - trackballModeDropdown = lv_dropdown_create(tb_mode_wrapper); - lv_dropdown_set_options(trackballModeDropdown, "Encoder\nPointer"); - lv_obj_align(trackballModeDropdown, LV_ALIGN_RIGHT_MID, 0, 0); - lv_dropdown_set_selected(trackballModeDropdown, modeToDropdownIndex(tbSettings.mode)); - lv_obj_add_event_cb(trackballModeDropdown, onTrackballModeChanged, LV_EVENT_VALUE_CHANGED, this); - - // Disable dropdown if trackball is disabled - if (!tbSettings.enabled) { - lv_obj_add_state(trackballModeDropdown, LV_STATE_DISABLED); - } + ctx->encoderSensitivitySlider = lv_slider_create(enc_sens_wrapper); + lv_slider_set_range(ctx->encoderSensitivitySlider, 1, 10); + lv_slider_set_value(ctx->encoderSensitivitySlider, ctx->tbSettings.encoder_sensitivity, LV_ANIM_OFF); + lv_obj_set_width(ctx->encoderSensitivitySlider, LV_PCT(50)); + lv_obj_align(ctx->encoderSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->encoderSensitivitySlider, onEncoderSensitivityChanged, LV_EVENT_VALUE_CHANGED, ctx); - // Encoder sensitivity slider - auto* enc_sens_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(enc_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_hor(enc_sens_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(enc_sens_wrapper, 0, LV_STATE_DEFAULT); - if (ui_density != LVGL_UI_DENSITY_COMPACT) { - lv_obj_set_style_pad_ver(enc_sens_wrapper, 4, LV_STATE_DEFAULT); - } + if (!ctx->tbSettings.enabled) { + lv_obj_add_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED); + } - auto* enc_sens_label = lv_label_create(enc_sens_wrapper); - lv_label_set_text(enc_sens_label, "Encoder Speed"); - lv_obj_align(enc_sens_label, LV_ALIGN_LEFT_MID, 0, 0); + // Pointer sensitivity slider + auto* ptr_sens_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ptr_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(ptr_sens_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ptr_sens_wrapper, 0, LV_STATE_DEFAULT); + if (ui_density != LVGL_UI_DENSITY_COMPACT) { + lv_obj_set_style_pad_ver(ptr_sens_wrapper, 4, LV_STATE_DEFAULT); + } - encoderSensitivitySlider = lv_slider_create(enc_sens_wrapper); - lv_slider_set_range(encoderSensitivitySlider, 1, 10); - lv_slider_set_value(encoderSensitivitySlider, tbSettings.encoder_sensitivity, LV_ANIM_OFF); - lv_obj_set_width(encoderSensitivitySlider, LV_PCT(50)); - lv_obj_align(encoderSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(encoderSensitivitySlider, onEncoderSensitivityChanged, LV_EVENT_VALUE_CHANGED, this); + auto* ptr_sens_label = lv_label_create(ptr_sens_wrapper); + lv_label_set_text(ptr_sens_label, "Pointer Speed"); + lv_obj_align(ptr_sens_label, LV_ALIGN_LEFT_MID, 0, 0); - if (!tbSettings.enabled) { - lv_obj_add_state(encoderSensitivitySlider, LV_STATE_DISABLED); - } + ctx->pointerSensitivitySlider = lv_slider_create(ptr_sens_wrapper); + lv_slider_set_range(ctx->pointerSensitivitySlider, 1, 10); + lv_slider_set_value(ctx->pointerSensitivitySlider, ctx->tbSettings.pointer_sensitivity, LV_ANIM_OFF); + lv_obj_set_width(ctx->pointerSensitivitySlider, LV_PCT(50)); + lv_obj_align(ctx->pointerSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->pointerSensitivitySlider, onPointerSensitivityChanged, LV_EVENT_VALUE_CHANGED, ctx); - // Pointer sensitivity slider - auto* ptr_sens_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(ptr_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_hor(ptr_sens_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ptr_sens_wrapper, 0, LV_STATE_DEFAULT); - if (ui_density != LVGL_UI_DENSITY_COMPACT) { - lv_obj_set_style_pad_ver(ptr_sens_wrapper, 4, LV_STATE_DEFAULT); - } + if (!ctx->tbSettings.enabled) { + lv_obj_add_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED); + } +} - auto* ptr_sens_label = lv_label_create(ptr_sens_wrapper); - lv_label_set_text(ptr_sens_label, "Pointer Speed"); - lv_obj_align(ptr_sens_label, LV_ALIGN_LEFT_MID, 0, 0); +// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is +// giving up its thread for a save/resume cycle, or closing for good) whenever they changed. +void persistIfUpdated(Context& ctx) { + if (ctx.updated) { + const auto copy = ctx.tbSettings; + getMainDispatcher().dispatch([copy]{ settings::trackball::save(copy); }); + ctx.updated = false; + } +} - pointerSensitivitySlider = lv_slider_create(ptr_sens_wrapper); - lv_slider_set_range(pointerSensitivitySlider, 1, 10); - lv_slider_set_value(pointerSensitivitySlider, tbSettings.pointer_sensitivity, LV_ANIM_OFF); - lv_obj_set_width(pointerSensitivitySlider, LV_PCT(50)); - lv_obj_align(pointerSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(pointerSensitivitySlider, onPointerSensitivityChanged, LV_EVENT_VALUE_CHANGED, this); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; - if (!tbSettings.enabled) { - lv_obj_add_state(pointerSensitivitySlider, LV_STATE_DISABLED); - } - } + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - void onHide(AppContext& app) override { - if (updated) { - const auto copy = tbSettings; - getMainDispatcher().dispatch([copy]{ settings::trackball::save(copy); }); - updated = false; + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + persistIfUpdated(ctx); + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "TrackballSettings", - .appName = "Trackball", - .appIcon = LVGL_ICON_SHARED_CIRCLE, - .appCategory = Category::Settings, - .createApp = create + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "TrackballSettings", + .name = "Trackball", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } diff --git a/Tactility/Source/app/usbsettings/UsbSettings.cpp b/Tactility/Source/app/usbsettings/UsbSettings.cpp index 02a607df8..fcac088b6 100644 --- a/Tactility/Source/app/usbsettings/UsbSettings.cpp +++ b/Tactility/Source/app/usbsettings/UsbSettings.cpp @@ -1,70 +1,125 @@ -#include -#include #include -#include -#include +#include +#include +#include + +#include -#include +#include +#include #define TAG "usb_settings" namespace tt::app::usbsettings { -static void onRebootMassStorageSdmmc(lv_event_t* event) { +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; +}; + + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} + +void onRebootMassStorageSdmmc(lv_event_t* event) { hal::usb::rebootIntoMassStorageSdmmc(); } // Flash reboot handler -static void onRebootMassStorageFlash(lv_event_t* event) { +void onRebootMassStorageFlash(lv_event_t* event) { hal::usb::rebootIntoMassStorageFlash(); } -class UsbSettingsApp : public App { +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); - void onShow(AppContext& app, lv_obj_t* parent) override { - auto* toolbar = lvgl::toolbar_create(parent, app); - lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + auto* toolbar = lvgl_toolbar_create(parent, "USB"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); - // Create a wrapper container for buttons - auto* wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_size(wrapper, lv_pct(100), LV_SIZE_CONTENT); - lv_obj_align(wrapper, LV_ALIGN_CENTER, 0, 0); + // Create a wrapper container for buttons + auto* wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_size(wrapper, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_align(wrapper, LV_ALIGN_CENTER, 0, 0); - bool hasSd = hal::usb::canRebootIntoMassStorageSdmmc(); - bool hasFlash = hal::usb::canRebootIntoMassStorageFlash(); + bool hasSd = hal::usb::canRebootIntoMassStorageSdmmc(); + bool hasFlash = hal::usb::canRebootIntoMassStorageFlash(); - if (hasSd) { - auto* button_sd = lv_button_create(wrapper); - auto* label_sd = lv_label_create(button_sd); - lv_label_set_text(label_sd, "Reboot as USB storage (SD)"); - lv_obj_add_event_cb(button_sd, onRebootMassStorageSdmmc, LV_EVENT_SHORT_CLICKED, nullptr); - } + if (hasSd) { + auto* button_sd = lv_button_create(wrapper); + auto* label_sd = lv_label_create(button_sd); + lv_label_set_text(label_sd, "Reboot as USB storage (SD)"); + lv_obj_add_event_cb(button_sd, onRebootMassStorageSdmmc, LV_EVENT_SHORT_CLICKED, nullptr); + } - if (hasFlash) { - auto* button_flash = lv_button_create(wrapper); - auto* label_flash = lv_label_create(button_flash); - lv_label_set_text(label_flash, "Reboot as USB storage (Flash)"); - lv_obj_add_event_cb(button_flash, onRebootMassStorageFlash, LV_EVENT_SHORT_CLICKED, nullptr); - } + if (hasFlash) { + auto* button_flash = lv_button_create(wrapper); + auto* label_flash = lv_label_create(button_flash); + lv_label_set_text(label_flash, "Reboot as USB storage (Flash)"); + lv_obj_add_event_cb(button_flash, onRebootMassStorageFlash, LV_EVENT_SHORT_CLICKED, nullptr); + } - if (!hasSd && !hasFlash) { - bool supported = hal::usb::isSupported(); - const char* message = supported ? "USB storage not available" : "USB driver not supported"; - auto* label = lv_label_create(wrapper); - lv_label_set_text(label, message); + if (!hasSd && !hasFlash) { + bool supported = hal::usb::isSupported(); + const char* message = supported ? "USB storage not available" : "USB driver not supported"; + auto* label = lv_label_create(wrapper); + lv_label_set_text(label, message); + } +} + +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } } -}; -extern const AppManifest manifest = { - .appId = "UsbSettings", - .appName = "USB", - .appIcon = LVGL_ICON_SHARED_USB, - .appCategory = Category::Settings, - .createApp = create + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "UsbSettings", + .name = "USB", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } // namespace diff --git a/Tactility/Source/app/webserversettings/WebServerSettings.cpp b/Tactility/Source/app/webserversettings/WebServerSettings.cpp index f217955c3..c2fd0e184 100644 --- a/Tactility/Source/app/webserversettings/WebServerSettings.cpp +++ b/Tactility/Source/app/webserversettings/WebServerSettings.cpp @@ -2,14 +2,20 @@ #include #include -#include #include #include +#include +#include +#include + +#include + #include -#include +#include #include +#include #include #include @@ -18,7 +24,12 @@ namespace tt::app::webserversettings { constexpr auto* TAG = "WebServerSettingsApp"; -class WebServerSettingsApp final : public App { +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; settings::webserver::WebServerSettings wsSettings; settings::webserver::WebServerSettings originalSettings; @@ -33,347 +44,397 @@ class WebServerSettingsApp final : public App { lv_obj_t* textAreaWebServerPassword = nullptr; lv_obj_t* labelUrl = nullptr; lv_obj_t* labelUrlValue = nullptr; +}; - static void onWifiModeChanged(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - auto* dropdown = static_cast(lv_event_get_target(e)); - auto index = lv_dropdown_get_selected(dropdown); - getMainDispatcher().dispatch([app, index] { - app->wsSettings.wifiMode = static_cast(index); - app->updated = true; - app->wifiSettingsChanged = true; - lvgl_lock(); - app->updateUrlDisplay(); - lvgl_unlock(); - }); - } - static void onWebServerEnabledSwitch(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - bool enabled = lv_obj_has_state(app->switchWebServerEnabled, LV_STATE_CHECKED); - getMainDispatcher().dispatch([app, enabled] { - app->wsSettings.webServerEnabled = enabled; - app->updated = true; - lvgl_lock(); - app->updateUrlDisplay(); - lvgl_unlock(); - - // Apply immediately instead of waiting for app exit - const auto copy = app->wsSettings; - if (!settings::webserver::save(copy)) { - LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot"); - } - service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); - LOG_I(TAG, "WebServer %s", enabled ? "enabling..." : "disabling..."); - service::webserver::setWebServerEnabled(enabled); - }); - } +void updateUrlDisplay(Context* ctx); +void createWidgets(lv_obj_t* parent, void* userData); - static void onWebServerAuthEnabledSwitch(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - bool enabled = lv_obj_has_state(app->switchWebServerAuthEnabled, LV_STATE_CHECKED); - - if (app->textAreaWebServerUsername && app->textAreaWebServerPassword) { - if (enabled) { - lv_obj_remove_state(app->textAreaWebServerUsername, LV_STATE_DISABLED); - lv_obj_add_flag(app->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - lv_obj_remove_state(app->textAreaWebServerPassword, LV_STATE_DISABLED); - lv_obj_add_flag(app->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); - } else { - lv_obj_add_state(app->textAreaWebServerUsername, LV_STATE_DISABLED); - lv_obj_remove_flag(app->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); +void onWifiModeChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + auto* dropdown = static_cast(lv_event_get_target(e)); + auto index = lv_dropdown_get_selected(dropdown); + getMainDispatcher().dispatch([ctx, index] { + ctx->wsSettings.wifiMode = static_cast(index); + ctx->updated = true; + ctx->wifiSettingsChanged = true; + lvgl_lock(); + updateUrlDisplay(ctx); + lvgl_unlock(); + }); +} - lv_obj_add_state(app->textAreaWebServerPassword, LV_STATE_DISABLED); - lv_obj_remove_flag(app->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); - } +void onWebServerEnabledSwitch(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + bool enabled = lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED); + getMainDispatcher().dispatch([ctx, enabled] { + ctx->wsSettings.webServerEnabled = enabled; + ctx->updated = true; + lvgl_lock(); + updateUrlDisplay(ctx); + lvgl_unlock(); + + // Apply immediately instead of waiting for app exit + const auto copy = ctx->wsSettings; + if (!settings::webserver::save(copy)) { + LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot"); } + service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); + LOG_I(TAG, "WebServer %s", enabled ? "enabling..." : "disabling..."); + service::webserver::setWebServerEnabled(enabled); + }); +} - getMainDispatcher().dispatch([app, enabled] { - app->wsSettings.webServerAuthEnabled = enabled; - app->updated = true; - }); - } +void onWebServerAuthEnabledSwitch(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + bool enabled = lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED); - static void onCredentialChanged(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - getMainDispatcher().dispatch([app] { - app->updated = true; - }); - } + if (ctx->textAreaWebServerUsername && ctx->textAreaWebServerPassword) { + if (enabled) { + lv_obj_remove_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED); + lv_obj_add_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); - static void onApPasswordChanged(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - getMainDispatcher().dispatch([app] { - app->updated = true; - app->wifiSettingsChanged = true; - }); + lv_obj_remove_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED); + lv_obj_add_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); + } else { + lv_obj_add_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED); + lv_obj_remove_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); + + lv_obj_add_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED); + lv_obj_remove_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); + } } - static void onApOpenNetworkSwitch(lv_event_t* e) { - auto* app = static_cast(lv_event_get_user_data(e)); - bool openNetwork = lv_obj_has_state(app->switchApOpenNetwork, LV_STATE_CHECKED); + getMainDispatcher().dispatch([ctx, enabled] { + ctx->wsSettings.webServerAuthEnabled = enabled; + ctx->updated = true; + }); +} - if (app->textAreaApPassword) { - if (openNetwork) { - lv_obj_add_state(app->textAreaApPassword, LV_STATE_DISABLED); - lv_obj_remove_flag(app->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); - } else { - lv_obj_remove_state(app->textAreaApPassword, LV_STATE_DISABLED); - lv_obj_add_flag(app->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); - } - } +void onCredentialChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + getMainDispatcher().dispatch([ctx] { + ctx->updated = true; + }); +} - getMainDispatcher().dispatch([app, openNetwork] { - app->wsSettings.apOpenNetwork = openNetwork; - app->updated = true; - app->wifiSettingsChanged = true; - }); - } +void onApPasswordChanged(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + getMainDispatcher().dispatch([ctx] { + ctx->updated = true; + ctx->wifiSettingsChanged = true; + }); +} - void updateUrlDisplay() { - if (!labelUrlValue) return; +void onApOpenNetworkSwitch(lv_event_t* e) { + auto* ctx = static_cast(lv_event_get_user_data(e)); + bool openNetwork = lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED); - if (!wsSettings.webServerEnabled) { - lv_label_set_text(labelUrlValue, "Disabled"); - return; + if (ctx->textAreaApPassword) { + if (openNetwork) { + lv_obj_add_state(ctx->textAreaApPassword, LV_STATE_DISABLED); + lv_obj_remove_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); + } else { + lv_obj_remove_state(ctx->textAreaApPassword, LV_STATE_DISABLED); + lv_obj_add_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); } + } - std::string url = "http://"; - - if (wsSettings.wifiMode == settings::webserver::WiFiMode::AccessPoint) { - // AP mode - always 192.168.4.1 - url += "192.168.4.1"; - } else { - // Station mode - try to get actual IP - esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - if (netif != nullptr) { - esp_netif_ip_info_t ip_info; - if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) { - char ip_str[16]; - snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); - url += ip_str; - } else { - url = "Connecting..."; - } + getMainDispatcher().dispatch([ctx, openNetwork] { + ctx->wsSettings.apOpenNetwork = openNetwork; + ctx->updated = true; + ctx->wifiSettingsChanged = true; + }); +} + +void updateUrlDisplay(Context* ctx) { + if (!ctx->labelUrlValue) return; + + if (!ctx->wsSettings.webServerEnabled) { + lv_label_set_text(ctx->labelUrlValue, "Disabled"); + return; + } + + std::string url = "http://"; + + if (ctx->wsSettings.wifiMode == settings::webserver::WiFiMode::AccessPoint) { + // AP mode - always 192.168.4.1 + url += "192.168.4.1"; + } else { + // Station mode - try to get actual IP + esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (netif != nullptr) { + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) { + char ip_str[16]; + snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); + url += ip_str; } else { - url = "Not connected"; + url = "Connecting..."; } + } else { + url = "Not connected"; } + } - if (url.starts_with("http://")) { - if (wsSettings.webServerPort != 80) { - url += ":" + std::to_string(wsSettings.webServerPort); - } + if (url.starts_with("http://")) { + if (ctx->wsSettings.webServerPort != 80) { + url += ":" + std::to_string(ctx->wsSettings.webServerPort); } + } - lv_label_set_text(labelUrlValue, url.c_str()); + lv_label_set_text(ctx->labelUrlValue, url.c_str()); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Web Server"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + // Web Server Enable toggle + ctx->switchWebServerEnabled = lvgl_toolbar_add_switch_action(toolbar); + if (ctx->wsSettings.webServerEnabled) { + lv_obj_add_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED); + } + lv_obj_add_event_cb(ctx->switchWebServerEnabled, onWebServerEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + // WiFi Mode dropdown + auto* wifi_mode_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(wifi_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(wifi_mode_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(wifi_mode_wrapper, 0, LV_STATE_DEFAULT); + auto* wifi_mode_label = lv_label_create(wifi_mode_wrapper); + lv_label_set_text(wifi_mode_label, "WiFi Mode"); + lv_obj_align(wifi_mode_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->dropdownWifiMode = lv_dropdown_create(wifi_mode_wrapper); + lv_obj_align(ctx->dropdownWifiMode, LV_ALIGN_RIGHT_MID, 0, 0); + lv_dropdown_set_options(ctx->dropdownWifiMode, "Station\nAccess Point"); + lv_dropdown_set_selected(ctx->dropdownWifiMode, static_cast(ctx->wsSettings.wifiMode)); + lv_obj_add_event_cb(ctx->dropdownWifiMode, onWifiModeChanged, LV_EVENT_VALUE_CHANGED, ctx); + + // AP Open Network toggle + auto* ap_open_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ap_open_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ap_open_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ap_open_wrapper, 0, LV_STATE_DEFAULT); + auto* ap_open_label = lv_label_create(ap_open_wrapper); + lv_label_set_text(ap_open_label, "AP Open Network"); + lv_obj_align(ap_open_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->switchApOpenNetwork = lv_switch_create(ap_open_wrapper); + if (ctx->wsSettings.apOpenNetwork) lv_obj_add_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED); + lv_obj_align(ctx->switchApOpenNetwork, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + // AP Password + auto* ap_pass_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ap_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ap_pass_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ap_pass_wrapper, 0, LV_STATE_DEFAULT); + auto* ap_pass_label = lv_label_create(ap_pass_wrapper); + lv_label_set_text(ap_pass_label, "AP Password"); + lv_obj_align(ap_pass_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->textAreaApPassword = lv_textarea_create(ap_pass_wrapper); + lv_obj_set_width(ctx->textAreaApPassword, 120); + lv_obj_align(ctx->textAreaApPassword, LV_ALIGN_RIGHT_MID, 0, 0); + lv_textarea_set_one_line(ctx->textAreaApPassword, true); + lv_textarea_set_max_length(ctx->textAreaApPassword, 64); + lv_textarea_set_password_mode(ctx->textAreaApPassword, true); + lv_textarea_set_text(ctx->textAreaApPassword, ctx->wsSettings.apPassword.c_str()); + lv_obj_add_event_cb(ctx->textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, ctx); + // Disable password field if open network is enabled + if (ctx->wsSettings.apOpenNetwork) { + lv_obj_add_state(ctx->textAreaApPassword, LV_STATE_DISABLED); + lv_obj_remove_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); } -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; + // Web Server Authentication Enable toggle + auto* ws_auth_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ws_auth_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ws_auth_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ws_auth_wrapper, 0, LV_STATE_DEFAULT); + auto* ws_auth_label = lv_label_create(ws_auth_wrapper); + lv_label_set_text(ws_auth_label, "Require Authentication"); + lv_obj_align(ws_auth_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->switchWebServerAuthEnabled = lv_switch_create(ws_auth_wrapper); + if (ctx->wsSettings.webServerAuthEnabled) lv_obj_add_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED); + lv_obj_align(ctx->switchWebServerAuthEnabled, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx); + + // WebServer Username + auto* ws_user_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ws_user_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ws_user_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ws_user_wrapper, 0, LV_STATE_DEFAULT); + auto* ws_user_label = lv_label_create(ws_user_wrapper); + lv_label_set_text(ws_user_label, "Username"); + lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper); + if (!ctx->wsSettings.webServerAuthEnabled) { + lv_obj_add_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED); + lv_obj_remove_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); + } + lv_obj_set_width(ctx->textAreaWebServerUsername, 120); + lv_obj_align(ctx->textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0); + lv_textarea_set_one_line(ctx->textAreaWebServerUsername, true); + lv_textarea_set_max_length(ctx->textAreaWebServerUsername, 32); + lv_textarea_set_text(ctx->textAreaWebServerUsername, ctx->wsSettings.webServerUsername.c_str()); + lv_obj_add_event_cb(ctx->textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx); + + // WebServer Password + auto* ws_pass_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(ws_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ws_pass_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ws_pass_wrapper, 0, LV_STATE_DEFAULT); + auto* ws_pass_label = lv_label_create(ws_pass_wrapper); + lv_label_set_text(ws_pass_label, "Password"); + lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0); + ctx->textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper); + if (!ctx->wsSettings.webServerAuthEnabled) { + lv_obj_add_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED); + lv_obj_remove_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); + } + lv_obj_set_width(ctx->textAreaWebServerPassword, 120); + lv_obj_align(ctx->textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0); + lv_textarea_set_one_line(ctx->textAreaWebServerPassword, true); + lv_textarea_set_max_length(ctx->textAreaWebServerPassword, 64); + lv_textarea_set_password_mode(ctx->textAreaWebServerPassword, true); + lv_textarea_set_text(ctx->textAreaWebServerPassword, ctx->wsSettings.webServerPassword.c_str()); + lv_obj_add_event_cb(ctx->textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx); + + // URL Display + auto* url_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_size(url_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(url_wrapper, 10, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(url_wrapper, 1, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(url_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_flex_cross_place(url_wrapper, LV_FLEX_ALIGN_START, 0); + + ctx->labelUrl = lv_label_create(url_wrapper); + lv_label_set_text(ctx->labelUrl, "Web Server URL:"); + + ctx->labelUrlValue = lv_label_create(url_wrapper); + if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) { + lv_obj_set_style_text_color(ctx->labelUrlValue, lv_theme_get_color_secondary(ctx->labelUrlValue), LV_PART_MAIN); + } else { + lv_obj_set_style_text_color(ctx->labelUrlValue, lv_palette_main(LV_PALETTE_BLUE), 0); } - void onShow(AppContext& app, lv_obj_t* parent) override { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + updateUrlDisplay(ctx); - lv_obj_t* toolbar = lvgl::toolbar_create(parent, app); + // Info text + auto* info_label = lv_label_create(main_wrapper); + lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP); + lv_obj_set_width(info_label, LV_PCT(95)); + if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) { + lv_obj_set_style_text_color(info_label, lv_palette_main(LV_PALETTE_GREY), 0); + } + lv_label_set_text(info_label, + "WiFi Station credentials are managed separately.\n" + "Use the WiFi menu to connect to networks.\n\n" + "AP mode uses the password configured above."); +} - // Web Server Enable toggle - switchWebServerEnabled = lvgl_toolbar_add_switch_action(toolbar); - if (wsSettings.webServerEnabled) { - lv_obj_add_state(switchWebServerEnabled, LV_STATE_CHECKED); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.wsSettings = settings::webserver::loadOrGetDefault(); + // Reflect the server's actual running state, in case it differs from the persisted setting + ctx.wsSettings.webServerEnabled = service::webserver::isWebServerEnabled(); + ctx.originalSettings = ctx.wsSettings; + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; } - lv_obj_add_event_cb(switchWebServerEnabled, onWebServerEnabledSwitch, LV_EVENT_VALUE_CHANGED, this); - - auto* main_wrapper = lv_obj_create(parent); - lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_width(main_wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(main_wrapper, 1); - - // WiFi Mode dropdown - auto* wifi_mode_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(wifi_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(wifi_mode_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(wifi_mode_wrapper, 0, LV_STATE_DEFAULT); - auto* wifi_mode_label = lv_label_create(wifi_mode_wrapper); - lv_label_set_text(wifi_mode_label, "WiFi Mode"); - lv_obj_align(wifi_mode_label, LV_ALIGN_LEFT_MID, 0, 0); - dropdownWifiMode = lv_dropdown_create(wifi_mode_wrapper); - lv_obj_align(dropdownWifiMode, LV_ALIGN_RIGHT_MID, 0, 0); - lv_dropdown_set_options(dropdownWifiMode, "Station\nAccess Point"); - lv_dropdown_set_selected(dropdownWifiMode, static_cast(wsSettings.wifiMode)); - lv_obj_add_event_cb(dropdownWifiMode, onWifiModeChanged, LV_EVENT_VALUE_CHANGED, this); - - // AP Open Network toggle - auto* ap_open_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(ap_open_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(ap_open_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ap_open_wrapper, 0, LV_STATE_DEFAULT); - auto* ap_open_label = lv_label_create(ap_open_wrapper); - lv_label_set_text(ap_open_label, "AP Open Network"); - lv_obj_align(ap_open_label, LV_ALIGN_LEFT_MID, 0, 0); - switchApOpenNetwork = lv_switch_create(ap_open_wrapper); - if (wsSettings.apOpenNetwork) lv_obj_add_state(switchApOpenNetwork, LV_STATE_CHECKED); - lv_obj_align(switchApOpenNetwork, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, this); - - // AP Password - auto* ap_pass_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(ap_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(ap_pass_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ap_pass_wrapper, 0, LV_STATE_DEFAULT); - auto* ap_pass_label = lv_label_create(ap_pass_wrapper); - lv_label_set_text(ap_pass_label, "AP Password"); - lv_obj_align(ap_pass_label, LV_ALIGN_LEFT_MID, 0, 0); - textAreaApPassword = lv_textarea_create(ap_pass_wrapper); - lv_obj_set_width(textAreaApPassword, 120); - lv_obj_align(textAreaApPassword, LV_ALIGN_RIGHT_MID, 0, 0); - lv_textarea_set_one_line(textAreaApPassword, true); - lv_textarea_set_max_length(textAreaApPassword, 64); - lv_textarea_set_password_mode(textAreaApPassword, true); - lv_textarea_set_text(textAreaApPassword, wsSettings.apPassword.c_str()); - lv_obj_add_event_cb(textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, this); - // Disable password field if open network is enabled - if (wsSettings.apOpenNetwork) { - lv_obj_add_state(textAreaApPassword, LV_STATE_DISABLED); - lv_obj_remove_flag(textAreaApPassword, LV_OBJ_FLAG_CLICKABLE); + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; } + } - // Web Server Authentication Enable toggle - auto* ws_auth_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(ws_auth_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(ws_auth_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ws_auth_wrapper, 0, LV_STATE_DEFAULT); - auto* ws_auth_label = lv_label_create(ws_auth_wrapper); - lv_label_set_text(ws_auth_label, "Require Authentication"); - lv_obj_align(ws_auth_label, LV_ALIGN_LEFT_MID, 0, 0); - switchWebServerAuthEnabled = lv_switch_create(ws_auth_wrapper); - if (wsSettings.webServerAuthEnabled) lv_obj_add_state(switchWebServerAuthEnabled, LV_STATE_CHECKED); - lv_obj_align(switchWebServerAuthEnabled, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, this); - - // WebServer Username - auto* ws_user_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(ws_user_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(ws_user_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ws_user_wrapper, 0, LV_STATE_DEFAULT); - auto* ws_user_label = lv_label_create(ws_user_wrapper); - lv_label_set_text(ws_user_label, "Username"); - lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0); - textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper); - if (!wsSettings.webServerAuthEnabled) { - lv_obj_add_state(textAreaWebServerUsername, LV_STATE_DISABLED); - lv_obj_remove_flag(textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); + // Equivalent of the old model's onHide(). + if (ctx.updated) { + // Read values from text areas - the window (and its widgets) is still alive at this + // point, since window_manager_remove() below hasn't run yet, but this runs on this + // app's own thread rather than the LVGL task, so the LVGL lock is needed. + lvgl_lock(); + if (ctx.textAreaApPassword) { + ctx.wsSettings.apPassword = lv_textarea_get_text(ctx.textAreaApPassword); } - lv_obj_set_width(textAreaWebServerUsername, 120); - lv_obj_align(textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0); - lv_textarea_set_one_line(textAreaWebServerUsername, true); - lv_textarea_set_max_length(textAreaWebServerUsername, 32); - lv_textarea_set_text(textAreaWebServerUsername, wsSettings.webServerUsername.c_str()); - lv_obj_add_event_cb(textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, this); - - // WebServer Password - auto* ws_pass_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(ws_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(ws_pass_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ws_pass_wrapper, 0, LV_STATE_DEFAULT); - auto* ws_pass_label = lv_label_create(ws_pass_wrapper); - lv_label_set_text(ws_pass_label, "Password"); - lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0); - textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper); - if (!wsSettings.webServerAuthEnabled) { - lv_obj_add_state(textAreaWebServerPassword, LV_STATE_DISABLED); - lv_obj_remove_flag(textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); + if (ctx.textAreaWebServerUsername) { + ctx.wsSettings.webServerUsername = lv_textarea_get_text(ctx.textAreaWebServerUsername); } - lv_obj_set_width(textAreaWebServerPassword, 120); - lv_obj_align(textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0); - lv_textarea_set_one_line(textAreaWebServerPassword, true); - lv_textarea_set_max_length(textAreaWebServerPassword, 64); - lv_textarea_set_password_mode(textAreaWebServerPassword, true); - lv_textarea_set_text(textAreaWebServerPassword, wsSettings.webServerPassword.c_str()); - lv_obj_add_event_cb(textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, this); - - // URL Display - auto* url_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_size(url_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(url_wrapper, 10, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(url_wrapper, 1, LV_STATE_DEFAULT); - lv_obj_set_flex_flow(url_wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_flex_cross_place(url_wrapper, LV_FLEX_ALIGN_START, 0); - - labelUrl = lv_label_create(url_wrapper); - lv_label_set_text(labelUrl, "Web Server URL:"); - - labelUrlValue = lv_label_create(url_wrapper); - if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) { - lv_obj_set_style_text_color(labelUrlValue, lv_theme_get_color_secondary(labelUrlValue), LV_PART_MAIN); - } else { - lv_obj_set_style_text_color(labelUrlValue, lv_palette_main(LV_PALETTE_BLUE), 0); + if (ctx.textAreaWebServerPassword) { + ctx.wsSettings.webServerPassword = lv_textarea_get_text(ctx.textAreaWebServerPassword); } + lvgl_unlock(); - updateUrlDisplay(); - - // Info text - auto* info_label = lv_label_create(main_wrapper); - lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP); - lv_obj_set_width(info_label, LV_PCT(95)); - if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) { - lv_obj_set_style_text_color(info_label, lv_palette_main(LV_PALETTE_GREY), 0); - } - lv_label_set_text(info_label, - "WiFi Station credentials are managed separately.\n" - "Use the WiFi menu to connect to networks.\n\n" - "AP mode uses the password configured above."); - } + // 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 = ctx.wsSettings; + const bool wifiChanged = ctx.wifiSettingsChanged; - void onHide(AppContext& app) override { - if (updated) { - // Read values from text areas - if (textAreaApPassword) { - wsSettings.apPassword = lv_textarea_get_text(textAreaApPassword); - } - if (textAreaWebServerUsername) { - wsSettings.webServerUsername = lv_textarea_get_text(textAreaWebServerUsername); - } - if (textAreaWebServerPassword) { - wsSettings.webServerPassword = lv_textarea_get_text(textAreaWebServerPassword); + getMainDispatcher().dispatch([copy, wifiChanged] { + // Save to flash (fast, low memory pressure) + if (!settings::webserver::save(copy)) { + LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot"); } - // 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; - - getMainDispatcher().dispatch([copy, wifiChanged]{ - // Save to flash (fast, low memory pressure) - if (!settings::webserver::save(copy)) { - LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot"); - } - - // Publish event immediately after save so WebServer cache refreshes BEFORE requests arrive - service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); - - // Only reconnect WiFi if WiFi settings actually changed - if (wifiChanged) { - LOG_I(TAG, "WiFi mode changed to %s", copy.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station"); - } - }); - } + // Publish event immediately after save so WebServer cache refreshes BEFORE requests arrive + service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged); + + // Only reconnect WiFi if WiFi settings actually changed + if (wifiChanged) { + LOG_I(TAG, "WiFi mode changed to %s", copy.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station"); + } + }); } -}; -extern const AppManifest manifest = { - .appId = "WebServerSettings", - .appName = "Web Server", - .appIcon = LVGL_ICON_SHARED_CLOUD, - .appCategory = Category::System, - .createApp = create + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "WebServerSettings", + .name = "Web Server", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } }; } diff --git a/Tactility/Source/app/wifiapsettings/WifiApSettings.cpp b/Tactility/Source/app/wifiapsettings/WifiApSettings.cpp index fc6aad7ac..349b20660 100644 --- a/Tactility/Source/app/wifiapsettings/WifiApSettings.cpp +++ b/Tactility/Source/app/wifiapsettings/WifiApSettings.cpp @@ -1,250 +1,266 @@ -#include -#include -#include #include #include #include #include +#include +#include +#include + +#include + #include #include -#include #include namespace tt::app::wifiapsettings { constexpr auto* TAG = "WifiApSettings"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; -void start(const std::string& ssid) { - auto bundle = std::make_shared(); - bundle->putString("ssid", ssid); - app::start(manifest.appId, bundle); -} +namespace { -class WifiApSettings : public App { +struct Context { + uint32_t appInstanceId; + std::string ssid; - bool viewEnabled = false; lv_obj_t* busySpinner = nullptr; lv_obj_t* connectButton = nullptr; lv_obj_t* disconnectButton = nullptr; - std::string ssid; + + uint32_t forgetDialogId = 0; PubSub::SubscriptionHandle wifiSubscription = nullptr; +}; - static void onPressForget(lv_event_t* event) { - std::vector choices = { - "Yes", - "No" - }; - alertdialog::start("Confirmation", "Forget the Wi-Fi access point?", choices); - } - static void onToggleAutoConnect(lv_event_t* event) { - auto* self = static_cast(lv_event_get_user_data(event)); - auto* enable_switch = static_cast(lv_event_get_target(event)); - bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED); - - service::wifi::settings::WifiApSettings settings; - if (service::wifi::settings::load(self->ssid.c_str(), settings)) { - settings.autoConnect = is_on; - if (!service::wifi::settings::save(settings)) { - LOG_E(TAG, "Failed to save settings"); - } - } else { - LOG_E(TAG, "Failed to load settings"); - } - } +void updateViews(Context* ctx); - static void onPressConnect(lv_event_t* event) { - auto app = getCurrentAppContext(); - auto parameters = app->getParameters(); - check(parameters != nullptr, "Parameters missing"); - - std::string ssid = parameters->getString("ssid"); - service::wifi::settings::WifiApSettings settings; - if (service::wifi::settings::load(ssid.c_str(), settings)) { - auto* button = lv_event_get_target_obj(event); - lv_obj_add_state(button, LV_STATE_DISABLED); - service::wifi::connect(settings, false); - } - } +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); +} - static void onPressDisconnect(lv_event_t* event) { - if (service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive) { - auto* button = lv_event_get_target_obj(event); - lv_obj_add_state(button, LV_STATE_DISABLED); - service::wifi::disconnect(); - } - } +void onPressForget(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->forgetDialogId = alertdialog::start(ctx->appInstanceId, "Confirmation", "Forget the Wi-Fi access point?", std::vector { "Yes", "No" }); +} - void onWifiEvent(service::wifi::WifiEvent event) const { - requestViewUpdate(); - } +void onToggleAutoConnect(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* enable_switch = static_cast(lv_event_get_target(event)); + bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED); - void requestViewUpdate() const { - if (viewEnabled) { - lvgl_lock(); - updateViews(); - lvgl_unlock(); + service::wifi::settings::WifiApSettings settings; + if (service::wifi::settings::load(ctx->ssid.c_str(), settings)) { + settings.autoConnect = is_on; + if (!service::wifi::settings::save(settings)) { + LOG_E(TAG, "Failed to save settings"); } + } else { + LOG_E(TAG, "Failed to load settings"); } +} - void updateConnectButton() const { - if (service::wifi::getConnectionTarget() == ssid && service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive) { - lv_obj_remove_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(connectButton, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_state(disconnectButton, LV_STATE_DISABLED); - } else { - lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_flag(connectButton, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_state(connectButton, LV_STATE_DISABLED); - } +void onPressConnect(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + service::wifi::settings::WifiApSettings settings; + if (service::wifi::settings::load(ctx->ssid.c_str(), settings)) { + auto* button = lv_event_get_target_obj(event); + lv_obj_add_state(button, LV_STATE_DISABLED); + service::wifi::connect(settings, false); } +} - void updateBusySpinner() const { - if (service::wifi::getRadioState() == service::wifi::RadioState::ConnectionPending) { - lv_obj_remove_flag(busySpinner, LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(busySpinner, LV_OBJ_FLAG_HIDDEN); - } +void onPressDisconnect(lv_event_t*) { + if (service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive) { + service::wifi::disconnect(); } +} - void updateViews() const { - updateConnectButton(); - updateBusySpinner(); +void updateConnectButton(Context* ctx) { + if (service::wifi::getConnectionTarget() == ctx->ssid && service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive) { + lv_obj_remove_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_state(ctx->disconnectButton, LV_STATE_DISABLED); + } else { + lv_obj_add_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_state(ctx->connectButton, LV_STATE_DISABLED); } +} -public: - - void onCreate(AppContext& app) override { - const auto parameters = app.getParameters(); - check(parameters != nullptr, "Parameters missing"); - ssid = parameters->getString("ssid"); +void updateBusySpinner(Context* ctx) { + if (service::wifi::getRadioState() == service::wifi::RadioState::ConnectionPending) { + lv_obj_remove_flag(ctx->busySpinner, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(ctx->busySpinner, LV_OBJ_FLAG_HIDDEN); } +} - void onShow(AppContext& app, lv_obj_t* parent) override { - wifiSubscription = service::wifi::getPubsub()->subscribe([this](auto event) { - requestViewUpdate(); - }); - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - auto* toolbar = lvgl_toolbar_create(parent, ssid.c_str()); - busySpinner = lvgl_toolbar_add_spinner_action(toolbar); - - auto* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); - lvgl::obj_set_style_bg_invisible(wrapper); - - disconnectButton = lv_button_create(wrapper); - lv_obj_set_width(disconnectButton, LV_PCT(100)); - lv_obj_add_event_cb(disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, nullptr); - auto* disconnect_label = lv_label_create(disconnectButton); - lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(disconnect_label, "Disconnect"); - - connectButton = lv_button_create(wrapper); - lv_obj_set_width(connectButton, LV_PCT(100)); - lv_obj_add_event_cb(connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, nullptr); - auto* connect_label = lv_label_create(connectButton); - lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(connect_label, "Connect"); - - // Forget - - auto* forget_button = lv_button_create(wrapper); - lv_obj_set_width(forget_button, LV_PCT(100)); - lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, nullptr); - auto* forget_button_label = lv_label_create(forget_button); - lv_obj_align(forget_button_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(forget_button_label, "Forget"); - - // Auto-connect - - auto* auto_connect_wrapper = lv_obj_create(wrapper); - lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lvgl::obj_set_style_bg_invisible(auto_connect_wrapper); - lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT); - - auto* auto_connect_label = lv_label_create(auto_connect_wrapper); - lv_label_set_text(auto_connect_label, "Auto-connect"); - lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0); - - auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper); - lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, this); - lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0); - - service::wifi::settings::WifiApSettings settings; - if (service::wifi::settings::load(ssid.c_str(), settings)) { - if (settings.autoConnect) { - lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED); - } else { - lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED); - } - } else { - LOG_W(TAG, "No settings found"); - lv_obj_add_flag(forget_button, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(auto_connect_wrapper, LV_OBJ_FLAG_HIDDEN); - } +void updateViews(Context* ctx) { + updateConnectButton(ctx); + updateBusySpinner(ctx); +} - viewEnabled = true; +void requestViewUpdate(Context* ctx) { + lvgl_lock(); + updateViews(ctx); + lvgl_unlock(); +} - updateViews(); +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + ctx->wifiSubscription = service::wifi::getPubsub()->subscribe([ctx](auto) { + requestViewUpdate(ctx); + }); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, ctx->ssid.c_str()); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + ctx->busySpinner = lvgl_toolbar_add_spinner_action(toolbar); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lvgl::obj_set_style_bg_invisible(wrapper); + + ctx->disconnectButton = lv_button_create(wrapper); + lv_obj_set_width(ctx->disconnectButton, LV_PCT(100)); + lv_obj_add_event_cb(ctx->disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, ctx); + auto* disconnect_label = lv_label_create(ctx->disconnectButton); + lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(disconnect_label, "Disconnect"); + + ctx->connectButton = lv_button_create(wrapper); + lv_obj_set_width(ctx->connectButton, LV_PCT(100)); + lv_obj_add_event_cb(ctx->connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, ctx); + auto* connect_label = lv_label_create(ctx->connectButton); + lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(connect_label, "Connect"); + + // Forget + + auto* forget_button = lv_button_create(wrapper); + lv_obj_set_width(forget_button, LV_PCT(100)); + lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, ctx); + auto* forget_button_label = lv_label_create(forget_button); + lv_obj_align(forget_button_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(forget_button_label, "Forget"); + + // Auto-connect + + auto* auto_connect_wrapper = lv_obj_create(wrapper); + lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT); + lvgl::obj_set_style_bg_invisible(auto_connect_wrapper); + lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT); + + auto* auto_connect_label = lv_label_create(auto_connect_wrapper); + lv_label_set_text(auto_connect_label, "Auto-connect"); + lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0); + + auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper); + lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx); + lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0); + + service::wifi::settings::WifiApSettings settings; + if (service::wifi::settings::load(ctx->ssid.c_str(), settings)) { + if (settings.autoConnect) { + lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED); + } else { + lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED); + } + } else { + LOG_W(TAG, "No settings found"); + lv_obj_add_flag(forget_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(auto_connect_wrapper, LV_OBJ_FLAG_HIDDEN); } - void onHide(AppContext& app) override { - service::wifi::getPubsub()->unsubscribe(wifiSubscription); - wifiSubscription = nullptr; - viewEnabled = false; - } + updateViews(ctx); +} - void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr bundle) override { - if (result != Result::Ok || bundle == nullptr) { - return; - } +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { - auto index = alertdialog::getResultIndex(*bundle); - if (index != 0) { // 0 = Yes - return; - } + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.ssid = (argc > 0) ? argv[0] : std::string(); - auto parameters = appContext.getParameters(); - check(parameters != nullptr, "Parameters missing"); + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); - std::string ssid = parameters->getString("ssid"); - if (!service::wifi::settings::remove(ssid.c_str())) { - LOG_E(TAG, "Failed to remove SSID"); - return; - } + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - LOG_I(TAG, "Removed SSID"); - if ( - service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive && - service::wifi::getConnectionTarget() == ssid - ) { - service::wifi::disconnect(); + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + case APP_EVENT_RESULT: + if (event.result.launch_id == ctx.forgetDialogId && event.result.result == 0) { // 0 = Yes + if (!service::wifi::settings::remove(ctx.ssid.c_str())) { + LOG_E(TAG, "Failed to remove SSID"); + } else { + LOG_I(TAG, "Removed SSID"); + if ( + service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive && + service::wifi::getConnectionTarget() == ctx.ssid + ) { + service::wifi::disconnect(); + } + app_manager_finish(appInstanceId); + shouldClose = true; + } + } + app_manager_stop(event.result.launch_id); + break; + default: + break; } + } - // Stop app - stop(); + if (ctx.wifiSubscription != nullptr) { + service::wifi::getPubsub()->unsubscribe(ctx.wifiSubscription); } -}; + window_manager_remove(window); + app_event_unsubscribe(&sub); -extern const AppManifest manifest = { - .appId = "WifiApSettings", - .appName = "Wi-Fi AP Settings", - .appIcon = LV_SYMBOL_WIFI, - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create -}; + return 0; +} } // namespace +void start(const std::string& ssid) { + const char* argv[] = { ssid.c_str() }; + uint32_t instanceId = 0; + app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId); +} + +extern const ::AppManifest manifest = { + .id = "WifiApSettings", + .name = "Wi-Fi AP Settings", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + +} // namespace diff --git a/Tactility/Source/app/wificonnect/State.cpp b/Tactility/Source/app/wificonnect/State.cpp deleted file mode 100644 index 7b9605d74..000000000 --- a/Tactility/Source/app/wificonnect/State.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include - -namespace tt::app::wificonnect { - -void State::setConnectionError(bool error) { - lock.lock(); - connectionError = error; - lock.unlock(); -} - -bool State::hasConnectionError() const { - lock.lock(); - auto result = connectionError; - lock.unlock(); - return result; -} - -void State::setApSettings(const service::wifi::settings::WifiApSettings& newSettings) { - lock.lock(); - this->apSettings = newSettings; - lock.unlock(); -} - -void State::setConnecting(bool isConnecting) { - lock.lock(); - connecting = isConnecting; - lock.unlock(); -} - -bool State::isConnecting() const { - lock.lock(); - auto result = connecting; - lock.unlock(); - return result; -} - -} // namespace diff --git a/Tactility/Source/app/wificonnect/View.cpp b/Tactility/Source/app/wificonnect/View.cpp deleted file mode 100644 index d64117566..000000000 --- a/Tactility/Source/app/wificonnect/View.cpp +++ /dev/null @@ -1,219 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -namespace tt::app::wificonnect { - -constexpr auto* TAG = "WifiConnect"; - -void View::resetErrors() { - lv_obj_add_flag(password_error, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(ssid_error, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(connection_error, LV_OBJ_FLAG_HIDDEN); -} - -static void onConnect(lv_event_t* event) { - auto wifi = std::static_pointer_cast(getCurrentApp()); - auto& view = wifi->getView(); - - wifi->getState().setConnectionError(false); - view.resetErrors(); - - const char* ssid = lv_textarea_get_text(view.ssid_textarea); - size_t ssid_len = strlen(ssid); - if (ssid_len > TT_WIFI_SSID_LIMIT) { - LOG_E(TAG, "SSID too long"); - lv_label_set_text(view.ssid_error, "SSID too long"); - lv_obj_remove_flag(view.ssid_error, LV_OBJ_FLAG_HIDDEN); - return; - } - - const char* password = lv_textarea_get_text(view.password_textarea); - size_t password_len = strlen(password); - if (password_len > TT_WIFI_CREDENTIALS_PASSWORD_LIMIT) { - LOG_E(TAG, "Password too long"); - lv_label_set_text(view.password_error, "Password too long"); - lv_obj_remove_flag(view.password_error, LV_OBJ_FLAG_HIDDEN); - return; - } - - bool store = lv_obj_get_state(view.remember_switch) & LV_STATE_CHECKED; - - view.setLoading(true); - - service::wifi::settings::WifiApSettings settings; - settings.password = password; - settings.ssid = ssid; - settings.channel = 0; - settings.autoConnect = TT_WIFI_AUTO_CONNECT; // No UI yet, so use global setting:w - - auto* bindings = &wifi->getBindings(); - bindings->onConnectSsid( - settings, - store, - bindings->onConnectSsidContext - ); -} - -void View::setLoading(bool loading) { - if (loading) { - lv_obj_add_flag(connect_button, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_flag(connecting_spinner, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_state(password_textarea, LV_STATE_DISABLED); - lv_obj_add_state(ssid_textarea, LV_STATE_DISABLED); - lv_obj_add_state(remember_switch, LV_STATE_DISABLED); - } else { - lv_obj_remove_flag(connect_button, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(connecting_spinner, LV_OBJ_FLAG_HIDDEN); - lv_obj_remove_state(password_textarea, LV_STATE_DISABLED); - lv_obj_remove_state(ssid_textarea, LV_STATE_DISABLED); - lv_obj_remove_state(remember_switch, LV_STATE_DISABLED); - } -} - -void View::createBottomButtons(lv_obj_t* parent) { - auto* button_container = lv_obj_create(parent); - lv_obj_set_width(button_container, LV_PCT(100)); - lv_obj_set_height(button_container, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(button_container, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_gap(button_container, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(button_container, 0, LV_STATE_DEFAULT); - - remember_switch = lv_switch_create(button_container); - lv_obj_add_state(remember_switch, LV_STATE_CHECKED); - lv_obj_align(remember_switch, LV_ALIGN_LEFT_MID, 0, 0); - - auto* remember_label = lv_label_create(button_container); - lv_label_set_text(remember_label, "Remember"); - lv_obj_align(remember_label, LV_ALIGN_CENTER, 0, 0); - lv_obj_align_to(remember_label, remember_switch, LV_ALIGN_OUT_RIGHT_MID, 4, 0); - - connecting_spinner = lvgl_spinner_create(button_container); - lv_obj_align(connecting_spinner, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_flag(connecting_spinner, LV_OBJ_FLAG_HIDDEN); - - connect_button = lv_btn_create(button_container); - auto* connect_label = lv_label_create(connect_button); - lv_label_set_text(connect_label, "Connect"); - lv_obj_align(connect_button, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_add_event_cb(connect_button, &onConnect, LV_EVENT_SHORT_CLICKED, nullptr); -} - -// TODO: Standardize dialogs -void View::init(AppContext& app, lv_obj_t* parent) { - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - lvgl::toolbar_create(parent, app); - - auto* wrapper = lv_obj_create(parent); - lv_obj_set_width(wrapper, LV_PCT(100)); - lv_obj_set_flex_grow(wrapper, 1); - lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); - - // SSID - - auto* ssid_wrapper = lv_obj_create(wrapper); - lv_obj_set_width(ssid_wrapper, LV_PCT(100)); - lv_obj_set_height(ssid_wrapper, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(ssid_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_gap(ssid_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ssid_wrapper, 0, LV_STATE_DEFAULT); - - auto* ssid_label_wrapper = lv_obj_create(ssid_wrapper); - lv_obj_set_width(ssid_label_wrapper, LV_PCT(50)); - lv_obj_set_height(ssid_label_wrapper, LV_SIZE_CONTENT); - lv_obj_align(ssid_label_wrapper, LV_ALIGN_LEFT_MID, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ssid_label_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(ssid_label_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(ssid_label_wrapper, 0, LV_STATE_DEFAULT); - - auto* ssid_label = lv_label_create(ssid_label_wrapper); - lv_label_set_text(ssid_label, "Network:"); - - ssid_textarea = lv_textarea_create(ssid_wrapper); - lv_textarea_set_one_line(ssid_textarea, true); - lv_obj_align(ssid_textarea, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_set_width(ssid_textarea, LV_PCT(50)); - - ssid_error = lv_label_create(wrapper); - lv_obj_set_style_text_color(ssid_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT); - lv_obj_add_flag(ssid_error, LV_OBJ_FLAG_HIDDEN); - - // Password - - auto* password_wrapper = lv_obj_create(wrapper); - lv_obj_set_width(password_wrapper, LV_PCT(100)); - lv_obj_set_height(password_wrapper, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(password_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_gap(password_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(password_wrapper, 0, LV_STATE_DEFAULT); - - auto* password_label_wrapper = lv_obj_create(password_wrapper); - lv_obj_set_width(password_label_wrapper, LV_PCT(50)); - lv_obj_set_height(password_label_wrapper, LV_SIZE_CONTENT); - lv_obj_align_to(password_label_wrapper, password_wrapper, LV_ALIGN_LEFT_MID, 0, 0); - lv_obj_set_style_border_width(password_label_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_left(password_label_wrapper, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_right(password_label_wrapper, 0, LV_STATE_DEFAULT); - - auto* password_label = lv_label_create(password_label_wrapper); - lv_label_set_text(password_label, "Password:"); - - password_textarea = lv_textarea_create(password_wrapper); - lv_textarea_set_one_line(password_textarea, true); - lv_textarea_set_password_mode(password_textarea, true); - lv_obj_align(password_textarea, LV_ALIGN_RIGHT_MID, 0, 0); - lv_obj_set_width(password_textarea, LV_PCT(50)); - - password_error = lv_label_create(wrapper); - lv_obj_set_style_text_color(password_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT); - lv_obj_add_flag(password_error, LV_OBJ_FLAG_HIDDEN); - - // Connection error - connection_error = lv_label_create(wrapper); - lv_obj_set_style_text_color(connection_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT); - lv_obj_add_flag(connection_error, LV_OBJ_FLAG_HIDDEN); - - // Bottom buttons - createBottomButtons(wrapper); - - // Init from app parameters - auto bundle = app.getParameters(); - if (bundle != nullptr) { - std::string ssid; - if (optSsidParameter(bundle, ssid)) { - lv_textarea_set_text(ssid_textarea, ssid.c_str()); - - if (!ssid.empty()) { - lv_group_focus_obj(password_textarea); - } - } - - std::string password; - if (optPasswordParameter(bundle, password)) { - lv_textarea_set_text(password_textarea, password.c_str()); - } - } -} - -void View::update() { - if (state->hasConnectionError()) { - setLoading(false); - resetErrors(); - lv_label_set_text(connection_error, "Connection failed"); - lv_obj_remove_flag(connection_error, LV_OBJ_FLAG_HIDDEN); - } -} - -} // namespace diff --git a/Tactility/Source/app/wificonnect/WifiConnect.cpp b/Tactility/Source/app/wificonnect/WifiConnect.cpp index 89c998b41..587b9d8e9 100644 --- a/Tactility/Source/app/wificonnect/WifiConnect.cpp +++ b/Tactility/Source/app/wificonnect/WifiConnect.cpp @@ -1,115 +1,348 @@ #include -#include -#include #include +#include +#include + +#include +#include +#include + +#include #include +#include +#include + +#include + +#include +#include namespace tt::app::wificonnect { constexpr auto* TAG = "WifiConnect"; -constexpr auto* WIFI_CONNECT_PARAM_SSID = "ssid"; // String -constexpr auto* WIFI_CONNECT_PARAM_PASSWORD = "password"; // String -extern const AppManifest manifest; +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; + + std::string initialSsid; + std::string initialPassword; + + // Touched only from the LVGL task: directly by onConnectPressed() (an LVGL event callback, + // which already runs with the LVGL lock held), and by onWifiEvent() (a wifi-pubsub + // callback running on some other thread) which explicitly wraps its touches in + // lvgl_lock()/lvgl_unlock() - see WifiApSettings.cpp for the same convention. + bool connecting = false; + bool connectionError = false; + + lv_obj_t* ssid_textarea = nullptr; + lv_obj_t* ssid_error = nullptr; + lv_obj_t* password_textarea = nullptr; + lv_obj_t* password_error = nullptr; + lv_obj_t* connect_button = nullptr; + lv_obj_t* remember_switch = nullptr; + lv_obj_t* connecting_spinner = nullptr; + lv_obj_t* connection_error = nullptr; -static void onConnect(const service::wifi::settings::WifiApSettings& ap_settings, bool remember, void* parameter) { - auto* wifi = static_cast(parameter); - wifi->getState().setApSettings(ap_settings); - wifi->getState().setConnecting(true); - service::wifi::connect(ap_settings, remember); + PubSub::SubscriptionHandle wifiSubscription = nullptr; +}; + + +void updateView(Context* ctx); +void resetErrors(Context* ctx); +void setLoading(Context* ctx, bool loading); + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); } -void WifiConnect::onWifiEvent(service::wifi::WifiEvent event) { - State& state = getState(); +// Runs on the wifi service's pubsub thread, not the LVGL task or this app's own thread. +void onWifiEvent(Context* ctx, service::wifi::WifiEvent event) { + bool shouldClose = false; + + lvgl_lock(); if (event.type == WIFI_EVENT_TYPE_STATION_CONNECTION_RESULT) { if (event.connection_error == WIFI_STATION_CONNECTION_ERROR_NONE) { - if (state.isConnecting()) { - state.setConnecting(false); - stop(manifest.appId); + if (ctx->connecting) { + ctx->connecting = false; + shouldClose = true; } } else { - if (state.isConnecting()) { - state.setConnecting(false); - state.setConnectionError(true); - requestViewUpdate(); + if (ctx->connecting) { + ctx->connecting = false; + ctx->connectionError = true; + updateView(ctx); } } } - requestViewUpdate(); -} - -WifiConnect::WifiConnect() { - wifiSubscription = service::wifi::getPubsub()->subscribe([this](auto event) { - onWifiEvent(event); - }); + updateView(ctx); + lvgl_unlock(); - bindings = (Bindings) { - .onConnectSsid = onConnect, - .onConnectSsidContext = this, - }; + if (shouldClose) { + // Async, non-blocking - same reasoning as onBackPressed() (must not call + // app_manager_stop() on ourselves); safe to call from any thread. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(ctx->appInstanceId, &closeEvent); + } } -WifiConnect::~WifiConnect() { - service::wifi::getPubsub()->unsubscribe(wifiSubscription); +void resetErrors(Context* ctx) { + lv_obj_add_flag(ctx->password_error, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->ssid_error, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->connection_error, LV_OBJ_FLAG_HIDDEN); } -void WifiConnect::lock() { - mutex.lock(); +void setLoading(Context* ctx, bool loading) { + if (loading) { + lv_obj_add_flag(ctx->connect_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_state(ctx->password_textarea, LV_STATE_DISABLED); + lv_obj_add_state(ctx->ssid_textarea, LV_STATE_DISABLED); + lv_obj_add_state(ctx->remember_switch, LV_STATE_DISABLED); + } else { + lv_obj_remove_flag(ctx->connect_button, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_state(ctx->password_textarea, LV_STATE_DISABLED); + lv_obj_remove_state(ctx->ssid_textarea, LV_STATE_DISABLED); + lv_obj_remove_state(ctx->remember_switch, LV_STATE_DISABLED); + } } -void WifiConnect::unlock() { - mutex.unlock(); +void updateView(Context* ctx) { + if (ctx->connectionError) { + setLoading(ctx, false); + resetErrors(ctx); + lv_label_set_text(ctx->connection_error, "Connection failed"); + lv_obj_remove_flag(ctx->connection_error, LV_OBJ_FLAG_HIDDEN); + } } -void WifiConnect::requestViewUpdate() { - lock(); - if (viewEnabled) { - lvgl_lock(); - view.update(); - lvgl_unlock(); +void onConnectPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + + ctx->connectionError = false; + resetErrors(ctx); + + const char* ssid = lv_textarea_get_text(ctx->ssid_textarea); + size_t ssid_len = strlen(ssid); + if (ssid_len > TT_WIFI_SSID_LIMIT) { + LOG_E(TAG, "SSID too long"); + lv_label_set_text(ctx->ssid_error, "SSID too long"); + lv_obj_remove_flag(ctx->ssid_error, LV_OBJ_FLAG_HIDDEN); + return; } - unlock(); -} -void WifiConnect::onShow(AppContext& app, lv_obj_t* parent) { - lock(); - viewEnabled = true; - view.init(app, parent); - view.update(); - unlock(); + const char* password = lv_textarea_get_text(ctx->password_textarea); + size_t password_len = strlen(password); + if (password_len > TT_WIFI_CREDENTIALS_PASSWORD_LIMIT) { + LOG_E(TAG, "Password too long"); + lv_label_set_text(ctx->password_error, "Password too long"); + lv_obj_remove_flag(ctx->password_error, LV_OBJ_FLAG_HIDDEN); + return; + } + + bool store = lv_obj_get_state(ctx->remember_switch) & LV_STATE_CHECKED; + + setLoading(ctx, true); + + service::wifi::settings::WifiApSettings settings; + settings.password = password; + settings.ssid = ssid; + settings.channel = 0; + settings.autoConnect = TT_WIFI_AUTO_CONNECT; // No UI yet, so use global setting + + ctx->connecting = true; + service::wifi::connect(settings, store); } -void WifiConnect::onHide(AppContext& app) { - // No need to lock view, as this is called from within Gui's LVGL context - lock(); - viewEnabled = false; - unlock(); +void createBottomButtons(Context* ctx, lv_obj_t* parent) { + auto* button_container = lv_obj_create(parent); + lv_obj_set_width(button_container, LV_PCT(100)); + lv_obj_set_height(button_container, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(button_container, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_gap(button_container, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(button_container, 0, LV_STATE_DEFAULT); + + ctx->remember_switch = lv_switch_create(button_container); + lv_obj_add_state(ctx->remember_switch, LV_STATE_CHECKED); + lv_obj_align(ctx->remember_switch, LV_ALIGN_LEFT_MID, 0, 0); + + auto* remember_label = lv_label_create(button_container); + lv_label_set_text(remember_label, "Remember"); + lv_obj_align(remember_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_align_to(remember_label, ctx->remember_switch, LV_ALIGN_OUT_RIGHT_MID, 4, 0); + + ctx->connecting_spinner = lvgl_spinner_create(button_container); + lv_obj_align(ctx->connecting_spinner, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN); + + ctx->connect_button = lv_btn_create(button_container); + auto* connect_label = lv_label_create(ctx->connect_button); + lv_label_set_text(connect_label, "Connect"); + lv_obj_align(ctx->connect_button, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_add_event_cb(ctx->connect_button, onConnectPressed, LV_EVENT_SHORT_CLICKED, ctx); } -extern const AppManifest manifest = { - .appId = "WifiConnect", - .appName = "Wi-Fi Connect", - .appIcon = LV_SYMBOL_WIFI, - .appCategory = Category::System, - .appFlags = AppManifest::Flags::Hidden, - .createApp = create -}; +// TODO: Standardize dialogs +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + ctx->wifiSubscription = service::wifi::getPubsub()->subscribe([ctx](auto event) { + onWifiEvent(ctx, event); + }); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Wi-Fi Connect"); + // The global toolbar nav callback only knows how to stop old-model apps. + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + + // SSID + + auto* ssid_wrapper = lv_obj_create(wrapper); + lv_obj_set_width(ssid_wrapper, LV_PCT(100)); + lv_obj_set_height(ssid_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(ssid_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_gap(ssid_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ssid_wrapper, 0, LV_STATE_DEFAULT); + + auto* ssid_label_wrapper = lv_obj_create(ssid_wrapper); + lv_obj_set_width(ssid_label_wrapper, LV_PCT(50)); + lv_obj_set_height(ssid_label_wrapper, LV_SIZE_CONTENT); + lv_obj_align(ssid_label_wrapper, LV_ALIGN_LEFT_MID, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ssid_label_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(ssid_label_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(ssid_label_wrapper, 0, LV_STATE_DEFAULT); + + auto* ssid_label = lv_label_create(ssid_label_wrapper); + lv_label_set_text(ssid_label, "Network:"); + + ctx->ssid_textarea = lv_textarea_create(ssid_wrapper); + lv_textarea_set_one_line(ctx->ssid_textarea, true); + lv_obj_align(ctx->ssid_textarea, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_width(ctx->ssid_textarea, LV_PCT(50)); + + ctx->ssid_error = lv_label_create(wrapper); + lv_obj_set_style_text_color(ctx->ssid_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT); + lv_obj_add_flag(ctx->ssid_error, LV_OBJ_FLAG_HIDDEN); + + // Password + + auto* password_wrapper = lv_obj_create(wrapper); + lv_obj_set_width(password_wrapper, LV_PCT(100)); + lv_obj_set_height(password_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(password_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_gap(password_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(password_wrapper, 0, LV_STATE_DEFAULT); + + auto* password_label_wrapper = lv_obj_create(password_wrapper); + lv_obj_set_width(password_label_wrapper, LV_PCT(50)); + lv_obj_set_height(password_label_wrapper, LV_SIZE_CONTENT); + lv_obj_align_to(password_label_wrapper, password_wrapper, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_border_width(password_label_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(password_label_wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(password_label_wrapper, 0, LV_STATE_DEFAULT); + + auto* password_label = lv_label_create(password_label_wrapper); + lv_label_set_text(password_label, "Password:"); + + ctx->password_textarea = lv_textarea_create(password_wrapper); + lv_textarea_set_one_line(ctx->password_textarea, true); + lv_textarea_set_password_mode(ctx->password_textarea, true); + lv_obj_align(ctx->password_textarea, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_width(ctx->password_textarea, LV_PCT(50)); + + ctx->password_error = lv_label_create(wrapper); + lv_obj_set_style_text_color(ctx->password_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT); + lv_obj_add_flag(ctx->password_error, LV_OBJ_FLAG_HIDDEN); + + // Connection error + ctx->connection_error = lv_label_create(wrapper); + lv_obj_set_style_text_color(ctx->connection_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT); + lv_obj_add_flag(ctx->connection_error, LV_OBJ_FLAG_HIDDEN); -LaunchId start(const std::string& ssid, const std::string& password) { - auto parameters = std::make_shared(); - parameters->putString(WIFI_CONNECT_PARAM_SSID, ssid); - parameters->putString(WIFI_CONNECT_PARAM_PASSWORD, password); - return app::start(manifest.appId, parameters); + // Bottom buttons + createBottomButtons(ctx, wrapper); + + // Init from app parameters + if (!ctx->initialSsid.empty()) { + lv_textarea_set_text(ctx->ssid_textarea, ctx->initialSsid.c_str()); + lv_group_focus_obj(ctx->password_textarea); + } + if (!ctx->initialPassword.empty()) { + lv_textarea_set_text(ctx->password_textarea, ctx->initialPassword.c_str()); + } } -bool optSsidParameter(const std::shared_ptr& bundle, std::string& ssid) { - return bundle->optString(WIFI_CONNECT_PARAM_SSID, ssid); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + + Context ctx {}; + ctx.appInstanceId = appInstanceId; + ctx.initialSsid = (argc > 0) ? argv[0] : std::string(); + ctx.initialPassword = (argc > 1) ? argv[1] : std::string(); + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } + } + + if (ctx.wifiSubscription != nullptr) { + service::wifi::getPubsub()->unsubscribe(ctx.wifiSubscription); + } + window_manager_remove(window); + app_event_unsubscribe(&sub); + + return 0; } -bool optPasswordParameter(const std::shared_ptr& bundle, std::string& password) { - return bundle->optString(WIFI_CONNECT_PARAM_PASSWORD, password); +} // namespace + +void start(const std::string& ssid, const std::string& password) { + const char* argv[] = { ssid.c_str(), password.c_str() }; + uint32_t instanceId = 0; + app_manager_start_with_parameters(manifest.id, 2, argv, &instanceId); } +extern const ::AppManifest manifest = { + .id = "WifiConnect", + .name = "Wi-Fi Connect", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, +}; + } // namespace diff --git a/Tactility/Source/app/wifimanage/View.cpp b/Tactility/Source/app/wifimanage/View.cpp index 5628dd62e..b841544a4 100644 --- a/Tactility/Source/app/wifimanage/View.cpp +++ b/Tactility/Source/app/wifimanage/View.cpp @@ -11,6 +11,9 @@ #include #include +#include +#include + #include #include @@ -18,7 +21,15 @@ namespace tt::app::wifimanage { constexpr auto* TAG = "WifiManageView"; -std::shared_ptr optWifiManage(); +static void onBackPressed(lv_event_t* event) { + auto* appInstanceId = static_cast(lv_event_get_user_data(event)); + // Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits + // (thread_join) for this app's own thread to finish, which needs the LVGL lock + // (window_manager_remove()) - but this callback runs ON the LVGL task, which would + // deadlock against itself. + AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(*appInstanceId, &closeEvent); +} static uint8_t mapRssiToPercentage(int rssi) { auto abs_rssi = std::abs(rssi); @@ -35,11 +46,8 @@ static uint8_t mapRssiToPercentage(int rssi) { static void onEnableSwitchChanged(lv_event_t* event) { auto* enable_switch = static_cast(lv_event_get_target(event)); bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED); - - auto wifi = std::static_pointer_cast(getCurrentApp()); - auto bindings = wifi->getBindings(); - - bindings.onWifiToggled(is_on); + auto* bindings = static_cast(lv_event_get_user_data(event)); + bindings->onWifiToggled(is_on); } static void onEnableOnBootSwitchChanged(lv_event_t* event) { @@ -289,18 +297,18 @@ void View::updateEnableOnBootToggle() { // region Main -void View::init(const AppContext& app, lv_obj_t* parent) { +void View::init(uint32_t newAppInstanceId, lv_obj_t* parent) { + appInstanceId = newAppInstanceId; lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); root = parent; - paths = app.getPaths(); - // Toolbar - lv_obj_t* toolbar = lvgl::toolbar_create(parent, app); + lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Wi-Fi"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, &appInstanceId); scanning_spinner = lvgl_toolbar_add_spinner_action(toolbar); diff --git a/Tactility/Source/app/wifimanage/WifiManage.cpp b/Tactility/Source/app/wifimanage/WifiManage.cpp index 77ea5c627..1748b0392 100644 --- a/Tactility/Source/app/wifimanage/WifiManage.cpp +++ b/Tactility/Source/app/wifimanage/WifiManage.cpp @@ -1,21 +1,39 @@ #include #include -#include #include #include -#include + +#include +#include +#include + +#include #include -#include #include namespace tt::app::wifimanage { constexpr auto* TAG = "WifiManage"; -extern const AppManifest manifest; +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; + PubSub::SubscriptionHandle wifiSubscription = nullptr; + Mutex mutex; + Bindings bindings {}; + State state; + View view = View(&bindings, &state); + + void lock() { mutex.lock(); } + void unlock() { mutex.unlock(); } +}; + static void onConnect(const std::string& ssid) { service::wifi::settings::WifiApSettings settings; @@ -44,45 +62,25 @@ static void onConnectToHidden() { wificonnect::start(); } -WifiManage::WifiManage() { - bindings = (Bindings) { - .onWifiToggled = onWifiToggled, - .onConnectSsid = onConnect, - .onDisconnect = onDisconnect, - .onShowApSettings = onShowApSettings, - .onConnectToHidden = onConnectToHidden - }; -} - -void WifiManage::lock() { - mutex.lock(); -} - -void WifiManage::unlock() { - mutex.unlock(); +void requestViewUpdate(Context* ctx) { + ctx->lock(); + lvgl_lock(); + ctx->view.update(); + lvgl_unlock(); + ctx->unlock(); } -void WifiManage::requestViewUpdate() { - lock(); - if (isViewEnabled) { - lvgl_lock(); - view.update(); - lvgl_unlock(); - } - unlock(); -} - -void WifiManage::onWifiEvent(service::wifi::WifiEvent event) { +void onWifiEvent(Context* ctx, service::wifi::WifiEvent event) { auto radio_state = service::wifi::getRadioState(); LOG_I(TAG, "Update with state %s", service::wifi::radioStateToString(radio_state)); - getState().setRadioState(radio_state); + ctx->state.setRadioState(radio_state); switch (event.type) { case WIFI_EVENT_TYPE_SCAN_STARTED: - getState().setScanning(true); + ctx->state.setScanning(true); break; case WIFI_EVENT_TYPE_SCAN_FINISHED: - getState().setScanning(false); - getState().updateApRecords(); + ctx->state.setScanning(false); + ctx->state.updateApRecords(); break; case WIFI_EVENT_TYPE_RADIO_STATE_CHANGED: if (event.radio_state == WIFI_RADIO_STATE_ON && !service::wifi::isScanning()) { @@ -93,26 +91,43 @@ void WifiManage::onWifiEvent(service::wifi::WifiEvent event) { break; } - requestViewUpdate(); + requestViewUpdate(ctx); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + ctx->lock(); + ctx->state.setConnectSsid("Connected"); // TODO update with proper SSID + ctx->view.init(ctx->appInstanceId, parent); + ctx->view.update(); + ctx->unlock(); } -void WifiManage::onShow(AppContext& app, lv_obj_t* parent) { - wifiSubscription = service::wifi::getPubsub()->subscribe([this](auto event) { - onWifiEvent(event); +int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { + Context ctx; + ctx.appInstanceId = appInstanceId; + ctx.bindings = (Bindings) { + .onWifiToggled = onWifiToggled, + .onConnectSsid = onConnect, + .onDisconnect = onDisconnect, + .onShowApSettings = onShowApSettings, + .onConnectToHidden = onConnectToHidden + }; + + ctx.wifiSubscription = service::wifi::getPubsub()->subscribe([&ctx](auto event) { + onWifiEvent(&ctx, event); }); // State update (it has its own locking) - state.setRadioState(service::wifi::getRadioState()); - state.setScanning(service::wifi::isScanning()); - state.updateApRecords(); - - // View update - lock(); - isViewEnabled = true; - state.setConnectSsid("Connected"); // TODO update with proper SSID - view.init(app, parent); - view.update(); - unlock(); + ctx.state.setRadioState(service::wifi::getRadioState()); + ctx.state.setScanning(service::wifi::isScanning()); + ctx.state.updateApRecords(); + + AppEventSubscription sub {}; + sub.app_instance_id = appInstanceId; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); service::wifi::RadioState radio_state = service::wifi::getRadioState(); bool can_scan = radio_state == service::wifi::RadioState::On || @@ -127,26 +142,47 @@ void WifiManage::onShow(AppContext& app, lv_obj_t* parent) { if (can_scan && !service::wifi::isScanning()) { service::wifi::scan(); } -} -void WifiManage::onHide(AppContext& app) { - lock(); - service::wifi::getPubsub()->unsubscribe(wifiSubscription); - wifiSubscription = nullptr; - isViewEnabled = false; - unlock(); -} + bool shouldClose = false; + while (!shouldClose) { + AppEvent event {}; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + switch (event.type) { + case APP_EVENT_CLOSE: + app_manager_finish(appInstanceId); + shouldClose = true; + break; + default: + break; + } + } -extern const AppManifest manifest = { - .appId = "WifiManage", - .appName = "Wi-Fi", - .appIcon = LVGL_ICON_SHARED_WIFI, - .appCategory = Category::Settings, - .createApp = create -}; + ctx.lock(); + service::wifi::getPubsub()->unsubscribe(ctx.wifiSubscription); + ctx.wifiSubscription = nullptr; + ctx.unlock(); + + window_manager_remove(window); + app_event_unsubscribe(&sub); -LaunchId start() { - return app::start(manifest.appId); + return 0; } } // namespace + +uint32_t start(uint32_t callerAppInstanceId) { + uint32_t instanceId = 0; + app_manager_start_for_result(manifest.id, callerAppInstanceId, 0, nullptr, &instanceId); + return instanceId; +} + +extern const ::AppManifest manifest = { + .id = "WifiManage", + .name = "Wi-Fi", + .category = APP_CATEGORY_SETTINGS, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) } +}; + +} // namespace tt::app::wifimanage diff --git a/Tactility/Source/file/PropertiesFile.cpp b/Tactility/Source/file/PropertiesFile.cpp index 3bd1d2974..1712a583d 100644 --- a/Tactility/Source/file/PropertiesFile.cpp +++ b/Tactility/Source/file/PropertiesFile.cpp @@ -1,50 +1,31 @@ #include "Tactility/file/PropertiesFile.h" -#include #include -#include + +#include namespace tt::file { -constexpr auto* TAG = "PropertiesFile"; +bool loadPropertiesFile(const std::string& filePath, std::function callback) { + // Matches the original semantics: a missing file is a real failure the caller checks for + // (e.g. "no saved settings yet"), unlike properties_file_open() itself, which treats a + // missing file as a fresh, empty store to be created on close(). + if (!isFile(filePath)) { + return false; + } -bool getKeyValuePair(const std::string& input, std::string& key, std::string& value) { - auto index = input.find('='); - if (index == std::string::npos) { + PropertiesFile* file = properties_file_open(filePath.c_str()); + if (file == nullptr) { return false; } - key = input.substr(0, index); - value = input.substr(index + 1); - return true; -} -bool loadPropertiesFile(const std::string& filePath, std::function callback) { - // Reading properties is a common operation; make this debug-level to avoid - // flooding the serial console under frequent polling. - LOG_D(TAG, "Reading properties file %s", filePath.c_str()); - uint16_t line_count = 0; - std::string key_prefix = ""; - // Malformed lines are skipped, valid lines are loaded and callback is called - return readLines(filePath, true, [&key_prefix, &line_count, &filePath, &callback](const std::string& line) { - line_count++; - std::string key, value; - // Trim all whitespace including \r\n (Windows line endings) - auto trimmed_line = string::trim(line, " \t\r\n"); - if (!trimmed_line.starts_with("#") && !trimmed_line.empty()) { - if (trimmed_line.starts_with("[")) { - key_prefix = trimmed_line; - } else { - if (getKeyValuePair(trimmed_line, key, value)) { - std::string trimmed_key = key_prefix + string::trim(key, " \t"); - std::string trimmed_value = string::trim(value, " \t"); - callback(trimmed_key, trimmed_value); - } else { - LOG_E(TAG, "Failed to parse line %d of %s (skipped)", line_count, filePath.c_str()); - // Continue loading other lines - } - } - } - }); + properties_file_for_each(file, [](const char* key, const char* value, void* context) { + auto* typed_callback = static_cast*>(context); + (*typed_callback)(key, value); + }, &callback); + + properties_file_close(file); + return true; } bool loadPropertiesFile(const std::string& filePath, std::map& outProperties) { @@ -54,19 +35,16 @@ bool loadPropertiesFile(const std::string& filePath, std::map& properties) { - FileMutexGuard guard(filePath); - - LOG_I(TAG, "Saving properties file %s", filePath.c_str()); - - FILE* file = fopen(filePath.c_str(), "w"); + PropertiesFile* file = properties_file_open(filePath.c_str()); if (file == nullptr) { - LOG_E(TAG, "Failed to open %s", filePath.c_str()); return false; } - for (const auto& [key, value]: properties) { fprintf(file, "%s=%s\n", key.c_str(), value.c_str()); } + for (const auto& [key, value] : properties) { + properties_file_set(file, key.c_str(), value.c_str()); + } - fclose(file); + properties_file_close(file); return true; } diff --git a/Tactility/Source/lvgl/Toolbar.cpp b/Tactility/Source/lvgl/Toolbar.cpp index 0bcf7709b..9312f1a5f 100644 --- a/Tactility/Source/lvgl/Toolbar.cpp +++ b/Tactility/Source/lvgl/Toolbar.cpp @@ -1,10 +1 @@ #include -#include - -namespace tt::lvgl { - -lv_obj_t* toolbar_create(lv_obj_t* parent, const app::AppContext& app) { - return lvgl_toolbar_create(parent, app.getManifest().appName.c_str()); -} - -} // namespace diff --git a/Tactility/Source/network/Http.cpp b/Tactility/Source/network/Http.cpp index 6832d2639..c5e3025bd 100644 --- a/Tactility/Source/network/Http.cpp +++ b/Tactility/Source/network/Http.cpp @@ -2,8 +2,6 @@ #include #include -#include "Tactility/service/gui/GuiService.h" - #include #ifdef ESP_PLATFORM @@ -22,7 +20,6 @@ void download( const std::function& onSuccess, const std::function& onError ) { - service::gui::warnIfRunningOnGuiTask("HTTP"); LOG_I(TAG, "Downloading from %s to %s", url.c_str(), downloadFilePath.c_str()); #ifdef ESP_PLATFORM getMainDispatcher().dispatch([url, certFilePath, downloadFilePath, onSuccess, onError] { diff --git a/Tactility/Source/service/development/DevelopmentService.cpp b/Tactility/Source/service/development/DevelopmentService.cpp index f59c7fd07..55cc1f091 100644 --- a/Tactility/Source/service/development/DevelopmentService.cpp +++ b/Tactility/Source/service/development/DevelopmentService.cpp @@ -1,22 +1,22 @@ #ifdef ESP_PLATFORM -#include +#include +#include + +#include -#include -#include +#include +#include #include #include #include -#include -#include #include -#include +#include +#include #include #include -#include - namespace tt::service::development { extern const ServiceManifest manifest; @@ -101,12 +101,19 @@ esp_err_t DevelopmentService::handleAppRun(httpd_req_t* request) { return ESP_FAIL; } - const auto& app_id = id_key_pos->second; - if (app::isRunning(app_id)) { - app::stopAll(app_id); + char app_id[32]; + AppInstanceId instance_id; + // Warning: possible app closure between getting app id and instance id + if ( + app_manager_get_topmost_app_id(app_id, sizeof(app_id)) == ERROR_NONE && + app_manager_get_topmost_instance_id(&instance_id) == ERROR_NONE + ) { + if (strcmp(id_key_pos->second.c_str(), app_id) == 0) { + app_manager_stop(instance_id); + } } - app::start(app_id); + app_manager_start(app_id, &instance_id); LOG_I(TAG, "[200] /app/run %s", id_key_pos->second.c_str()); httpd_resp_send(request, nullptr, 0); @@ -186,7 +193,7 @@ esp_err_t DevelopmentService::handleAppInstall(httpd_req_t* request) { LOG_W(TAG, "We have more bytes at the end of the request parsing?!"); } - if (!app::install(file_path)) { + if (!app_install(file_path.c_str())) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to install"); return ESP_FAIL; } @@ -218,13 +225,13 @@ esp_err_t DevelopmentService::handleAppUninstall(httpd_req_t* request) { return ESP_FAIL; } - if (!app::findAppManifestById(id_key_pos->second)) { + if (!app_manager_find_manifest(id_key_pos->second.c_str())) { LOG_I(TAG, "[200] /app/uninstall %s (app wasn't installed)", id_key_pos->second.c_str()); httpd_resp_send(request, nullptr, 0); return ESP_OK; } - if (app::uninstall(id_key_pos->second)) { + if (app_uninstall(id_key_pos->second.c_str())) { LOG_I(TAG, "[200] /app/uninstall %s", id_key_pos->second.c_str()); httpd_resp_send(request, nullptr, 0); return ESP_OK; diff --git a/Tactility/Source/service/gui/GuiService.cpp b/Tactility/Source/service/gui/GuiService.cpp deleted file mode 100644 index 858b3e30b..000000000 --- a/Tactility/Source/service/gui/GuiService.cpp +++ /dev/null @@ -1,375 +0,0 @@ -#include - -#include "lvgl/devices/keyboard.h" - -#include -#include -#include -#include -#include -#include -#include - -#include - -#include - -#include - -namespace tt::service::gui { - -extern const ServiceManifest manifest; -constexpr auto* TAG = "GuiService"; -using namespace loader; - -constexpr auto* GUI_TASK_NAME = "gui"; - -void warnIfRunningOnGuiTask(const char* context) { - const char* task_name = pcTaskGetName(nullptr); - if (strcmp(GUI_TASK_NAME, task_name) == 0) { - LOG_W(TAG, "%s shouldn't run on the GUI task", context); - } -} - -namespace { - -enum class GuiDispatchType { Show, Hide, Exit }; - -struct GuiDispatchItem { - GuiService* service; - GuiDispatchType type; - std::shared_ptr appInstance; // only used for Show -}; - -} // namespace - -// region AppManifest - -void GuiService::onGuiDispatch(void* context) { - std::unique_ptr item(static_cast(context)); - switch (item->type) { - case GuiDispatchType::Show: - item->service->showApp(item->appInstance); - break; - case GuiDispatchType::Hide: - item->service->hideApp(); - break; - case GuiDispatchType::Exit: - item->service->exitRequested = true; - break; - } -} - -void GuiService::onLoaderEvent(LoaderService::Event event) { - GuiDispatchItem* item; - if (event == LoaderService::Event::ApplicationShowing) { - auto app_instance = std::static_pointer_cast(app::getCurrentAppContext()); - item = new GuiDispatchItem{this, GuiDispatchType::Show, app_instance}; - } else if (event == LoaderService::Event::ApplicationHiding) { - // hideDoneSem is a binary semaphore signaled by every hideApp() completion, - // including the one showApp() triggers internally (GuiDispatchType::Show, when an - // app is already being shown) - that release has no waiter and leaves a stale - // permit sitting available. Drain it before dispatching, or the acquire() below - // could consume that leftover permit instead of the one this specific Hide - // dispatch is about to produce, letting Destroyed run before the real onHide() - // for this app has finished. - hideDoneSem.acquire(0); - item = new GuiDispatchItem{this, GuiDispatchType::Hide, nullptr}; - } else { - return; - } - - if (dispatcher_dispatch(dispatcher, item, onGuiDispatch) != ERROR_NONE) { - LOG_E(TAG, "Failed to dispatch gui event"); - delete item; - return; - } - - if (event == LoaderService::Event::ApplicationHiding) { - // Block here (still on the Loader thread, inside publish()'s synchronous - // subscriber call) until hideApp() has actually run to completion on the GUI - // task. LoaderService::transitionAppToState(Hiding) must not return - and - // therefore the Destroyed transition right after it, which unloads an ELF app's - // code, must not run - until App::onHide() has fully finished. Bounded so a stuck - // GUI task can't wedge app shutdown forever. - if (!hideDoneSem.acquire(pdMS_TO_TICKS(5000))) { - LOG_E(TAG, "Timed out waiting for hideApp() to complete"); - } - } -} - -int32_t GuiService::guiMain() { - auto service = findServiceById(manifest.id); - - if (!lvgl_try_lock(5000)) { - LOG_E(TAG, "LVGL guiMain start failed as LVGL couldn't be locked"); - return 0; - } - - // The screen root is created in the main task instead of during onStart because - // it allows onStart() to succeed faster and allows widget creation to happen in the background - - auto* screen_root = lv_screen_active(); - if (screen_root == nullptr) { - LOG_E(TAG, "No display found, exiting GUI task"); - lvgl_unlock(); - return 0; - } - - lv_obj_set_style_border_width(screen_root, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_all(screen_root, 0, LV_STATE_DEFAULT); - - lv_obj_t* vertical_container = lv_obj_create(screen_root); - lv_obj_set_size(vertical_container, LV_PCT(100), LV_PCT(100)); - lv_obj_set_flex_flow(vertical_container, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(vertical_container, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_gap(vertical_container, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_color(vertical_container, lv_color_black(), LV_STATE_DEFAULT); - lv_obj_set_style_border_width(vertical_container, 0, LV_STATE_DEFAULT); - lv_obj_set_style_radius(vertical_container, 0, LV_STATE_DEFAULT); - - service->statusbarWidget = lvgl::statusbar_create(vertical_container); - - auto* app_container = lv_obj_create(vertical_container); - lv_obj_set_style_pad_all(app_container, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(app_container, 0, LV_STATE_DEFAULT); - lv_obj_set_width(app_container, LV_PCT(100)); - lv_obj_set_flex_grow(app_container, 1); - lv_obj_set_flex_flow(app_container, LV_FLEX_FLOW_COLUMN); - - service->appRootWidget = app_container; - - lvgl_unlock(); - - while (!service->exitRequested) { - dispatcher_consume(service->dispatcher); - } - - service->appRootWidget = nullptr; - service->statusbarWidget = nullptr; - - return 0; -} - -lv_obj_t* GuiService::createAppViews(lv_obj_t* parent) { - lv_obj_send_event(statusbarWidget, LV_EVENT_DRAW_MAIN, nullptr); - lv_obj_t* child_container = lv_obj_create(parent); - lv_obj_set_style_pad_all(child_container, 0, LV_STATE_DEFAULT); - lv_obj_set_width(child_container, LV_PCT(100)); - lv_obj_set_style_border_width(child_container, 0, LV_STATE_DEFAULT); - lv_obj_set_flex_grow(child_container, 1); - - if (lvgl_software_keyboard_is_enabled()) { - lvgl_software_keyboard_construct(&software_keyboard, parent); - } else { - software_keyboard = { - nullptr - }; - } - - return child_container; -} - -void GuiService::redraw() { - // Lock GUI and LVGL - lock(); - - if (appRootWidget == nullptr) { - LOG_W(TAG, "No root widget"); - unlock(); - return; - } - - bool lvgl_locked = false; - while (lvgl_is_running() && !(lvgl_locked = lvgl_try_lock(1000))) { - LOG_W(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "GuiService LVGL"); - } - - if (!lvgl_locked) { - unlock(); - return; - } - if (!lvgl_is_running()) { - lvgl_unlock(); - unlock(); - return; - } - - lv_obj_clean(appRootWidget); - - if (appToRender != nullptr) { - - // Create a default group which adds all objects automatically, - // and assign all indevs to it. - // This enables navigation with limited input, such as encoder wheels. - // The previous default group (if any) is no longer referenced by anything - // after lv_obj_clean() above, so it must be freed here or it leaks. - auto* previous_group = lv_group_get_default(); - if (previous_group != nullptr) { - lv_group_delete(previous_group); - } - - lv_group_t* group = lv_group_create(); - auto* indev = lv_indev_get_next(nullptr); - while (indev) { - lv_indev_set_group(indev, group); - indev = lv_indev_get_next(indev); - } - lv_group_set_default(group); - - app::Flags flags = std::static_pointer_cast(appToRender)->getFlags(); - if (flags.hideStatusbar) { - lv_obj_add_flag(statusbarWidget, LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_remove_flag(statusbarWidget, LV_OBJ_FLAG_HIDDEN); - } - - lv_obj_t* container = createAppViews(appRootWidget); - appToRender->getApp()->onShow(*appToRender, container); - } else { - LOG_W(TAG, "Nothing to draw"); - } - - lvgl_unlock(); - - unlock(); -} - -bool GuiService::onStart(ServiceContext& service) { - exitRequested = false; - dispatcher = dispatcher_alloc(); - - thread = new Thread( - GUI_TASK_NAME, - 4096, // Last known minimum was 2800 for launching desktop - guiMain - ); - thread->setPriority(THREAD_PRIORITY_SERVICE); - - const auto loader = findLoaderService(); - assert(loader != nullptr); - loader_pubsub_subscription = loader->getPubsub()->subscribe([this](auto event) { - onLoaderEvent(event); - }); - - isStarted = true; - - lvgl::startUsbHidInput(); - - thread->start(); - - return true; -} - -void GuiService::onStop(ServiceContext& service) { - lvgl::stopUsbHidInput(); - - lock(); - - const auto loader = findLoaderService(); - assert(loader != nullptr); - loader->getPubsub()->unsubscribe(loader_pubsub_subscription); - - appToRender = nullptr; - isStarted = false; - - unlock(); - - auto* exit_item = new GuiDispatchItem{this, GuiDispatchType::Exit, nullptr}; - if (dispatcher_dispatch(dispatcher, exit_item, onGuiDispatch) != ERROR_NONE) { - LOG_E(TAG, "Failed to dispatch gui exit event"); - check(false, "Failed to dispatch exit signal to thread."); - delete exit_item; - } - thread->join(); - - lvgl_lock(); - if (software_keyboard.object != nullptr) { - lvgl_software_keyboard_destruct(&software_keyboard); - } - - auto* default_group = lv_group_get_default(); - if (default_group != nullptr) { - lv_group_delete(default_group); - lv_group_set_default(nullptr); - } - - auto* screen_root = lv_screen_active(); - if (screen_root != nullptr) { - lv_obj_clean(screen_root); - } - lvgl_unlock(); - - delete thread; - dispatcher_free(dispatcher); - dispatcher = nullptr; -} - -void GuiService::showApp(std::shared_ptr app) { - auto lock = mutex.asScopedLock(); - lock.lock(); - - if (!isStarted) { - LOG_E(TAG, "Failed to show app %s: GUI not started", app->getManifest().appId.c_str()); - return; - } - - if (appToRender != nullptr && appToRender->getLaunchId() == app->getLaunchId()) { - LOG_W(TAG, "Already showing %s", app->getManifest().appId.c_str()); - return; - } - - LOG_I(TAG, "Showing %s", app->getManifest().appId.c_str()); - // Ensure previous app triggers onHide() logic - if (appToRender != nullptr) { - hideApp(); - } - - appToRender = std::move(app); - redraw(); -} - -void GuiService::hideApp() { - // Signals hideDoneSem on every return path (including the early-return guards below) - - // onLoaderEvent() blocks on this to know App::onHide() has actually finished before - // Loader proceeds to destroy the app (see hideDoneSem's declaration for why). - struct SignalOnExit { - Semaphore& sem; - ~SignalOnExit() { sem.release(); } - } signal_on_exit { hideDoneSem }; - - auto lock = mutex.asScopedLock(); - lock.lock(); - - if (!isStarted) { - LOG_E(TAG, "Failed to hide app: GUI not started"); - return; - } - - if (appToRender == nullptr) { - LOG_W(TAG, "hideApp() called but no app is currently shown"); - return; - } - - // We must lock the LVGL port, because the viewport hide callbacks - // might call LVGL APIs (e.g. to remove the keyboard from the screen root) - lvgl_lock(); - appToRender->getApp()->onHide(*appToRender); - lvgl_unlock(); - appToRender = nullptr; -} - -std::shared_ptr findService() { - return std::static_pointer_cast( - findServiceById(manifest.id) - ); -} - -extern const ServiceManifest manifest = { - .id = "Gui", - .createService = create -}; - -// endregion - -} // namespace diff --git a/Tactility/Source/service/loader/Loader.cpp b/Tactility/Source/service/loader/Loader.cpp deleted file mode 100644 index 2e55c45ec..000000000 --- a/Tactility/Source/service/loader/Loader.cpp +++ /dev/null @@ -1,328 +0,0 @@ -#include -#include -#include -#include - -#include -#include -#include -#include - -#include - -#include -#include - -namespace tt::service::loader { - -constexpr auto* TAG = "Loader"; - -constexpr auto LOADER_TIMEOUT = (100 / portTICK_PERIOD_MS); - -// Forward declaration -extern const ServiceManifest manifest; - -static const char* appStateToString(app::State state) { - switch (state) { - using enum app::State; - case Initial: - return "initial"; - case Created: - return "started"; - case Showing: - return "showing"; - case Hiding: - return "hiding"; - case Destroyed: - return "stopped"; - default: - return "?"; - } -} - -void LoaderService::onStartAppMessage(const std::string& id, app::LaunchId launchId, std::shared_ptr parameters) { - LOG_I(TAG, "Start by id %s", id.c_str()); - - auto app_manifest = app::findAppManifestById(id); - if (app_manifest == nullptr) { - LOG_E(TAG, "App not found: %s", id.c_str()); - return; - } - - auto lock = mutex.asScopedLock(); - if (!lock.lock(LOADER_TIMEOUT)) { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - auto previous_app = !appStack.empty() ? appStack[appStack.size() - 1]: nullptr; - auto new_app = std::make_shared(app_manifest, launchId, parameters); - - new_app->mutableFlags().hideStatusbar = (app_manifest->appFlags & app::AppManifest::Flags::HideStatusBar); - - // We might have to hide the previous app first - if (previous_app != nullptr) { - transitionAppToState(previous_app, app::State::Hiding); - } - - appStack.push_back(new_app); - transitionAppToState(new_app, app::State::Created); - transitionAppToState(new_app, app::State::Showing); - - memory_print_stats(); -} - -void LoaderService::onStopTopAppMessage(const std::string& id) { - auto lock = mutex.asScopedLock(); - if (!lock.lock(LOADER_TIMEOUT)) { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - size_t original_stack_size = appStack.size(); - - if (original_stack_size == 0) { - LOG_E(TAG, "Stop app: no app running"); - return; - } - - // Stop current app - auto app_to_stop = appStack[appStack.size() - 1]; - - if (app_to_stop->getManifest().appId != id) { - LOG_E(TAG, "Stop app: id mismatch (wanted %s but found %s on top of stack)", id.c_str(), app_to_stop->getManifest().appId.c_str()); - return; - } - - if (original_stack_size == 1 && app_to_stop->getManifest().appName != "Boot") { - LOG_E(TAG, "Stop app: can't stop root app"); - return; - } - - bool result_set = false; - app::Result result; - std::unique_ptr result_bundle; - if (app_to_stop->getApp()->moveResult(result, result_bundle)) { - result_set = true; - } - - auto app_to_stop_launch_id = app_to_stop->getLaunchId(); - - transitionAppToState(app_to_stop, app::State::Hiding); - transitionAppToState(app_to_stop, app::State::Destroyed); - - appStack.pop_back(); - - // We only expect the app to be referenced within the current scope - if (app_to_stop.use_count() > 1) { - LOG_W(TAG, "Memory leak: Stopped %s, but use count is %d", app_to_stop->getManifest().appId.c_str(), (int)(app_to_stop.use_count() - 1)); - } - - // Refcount is expected to be 2: 1 within app_to_stop and 1 within the current scope - if (app_to_stop->getApp().use_count() > 2) { - LOG_W(TAG, "Memory leak: Stopped %s, but use count is %d", app_to_stop->getManifest().appId.c_str(), (int)(app_to_stop->getApp().use_count() - 2)); - } - - std::shared_ptr instance_to_resume; - // If there's a previous app, resume it - if (!appStack.empty()) { - instance_to_resume = appStack[appStack.size() - 1]; - assert(instance_to_resume); - transitionAppToState(instance_to_resume, app::State::Showing); - } - - // Unlock so that we can send results to app and they can also start/stop new apps while processing these results - lock.unlock(); - // WARNING: After this point we cannot change the app states from this method directly anymore as we don't have a lock! - - if (instance_to_resume != nullptr) { - if (result_set) { - if (result_bundle != nullptr) { - instance_to_resume->getApp()->onResult( - *instance_to_resume, - app_to_stop_launch_id, - result, - std::move(result_bundle) - ); - } else { - instance_to_resume->getApp()->onResult( - *instance_to_resume, - app_to_stop_launch_id, - result, - nullptr - ); - } - } else { - instance_to_resume->getApp()->onResult( - *instance_to_resume, - app_to_stop_launch_id, - app::Result::Cancelled, - nullptr - ); - } - } - - memory_print_stats(); -} - -int LoaderService::findAppInStack(const std::string& id) const { - auto lock = mutex.asScopedLock(); - lock.lock(); - for (size_t i = 0; i < appStack.size(); i++) { - if (appStack[i]->getManifest().appId == id) { - return i; - } - } - return -1; -} - -void LoaderService::onStopAllAppMessage(const std::string& id) { - auto lock = mutex.asScopedLock(); - if (!lock.lock(LOADER_TIMEOUT)) { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - return; - } - - if (!isRunning(id)) { - LOG_E(TAG, "Stop all: %s not running", id.c_str()); - return; - } - - int app_to_stop_index = findAppInStack(id); - if (app_to_stop_index < 0) { - LOG_E(TAG, "Stop all: %s not found in stack", id.c_str()); - return; - } - - - // Find an app to resume, if any - std::shared_ptr instance_to_resume; - if (app_to_stop_index > 0) { - instance_to_resume = appStack[app_to_stop_index - 1]; - assert(instance_to_resume); - } - - // Stop all apps and find the LaunchId of the last-closed app, so we can call onResult() if needed - app::LaunchId last_launch_id = 0; - for (int i = appStack.size() - 1; i >= app_to_stop_index; i--) { - auto app_to_stop = appStack[i]; - // Hide the app first in case it's still being shown - if (app_to_stop->getState() == app::State::Showing) { - transitionAppToState(app_to_stop, app::State::Hiding); - } - transitionAppToState(app_to_stop, app::State::Destroyed); - last_launch_id = app_to_stop->getLaunchId(); - - appStack.pop_back(); - } - - if (instance_to_resume != nullptr) { - LOG_I(TAG, "Resuming %s", instance_to_resume->getManifest().appId.c_str()); - transitionAppToState(instance_to_resume, app::State::Showing); - - instance_to_resume->getApp()->onResult( - *instance_to_resume, - last_launch_id, - app::Result::Cancelled, - nullptr - ); - } -} - -void LoaderService::transitionAppToState(const std::shared_ptr& app, app::State state) { - const app::AppManifest& app_manifest = app->getManifest(); - const app::State old_state = app->getState(); - - LOG_I(TAG, "App \"%s\" state: %s -> %s", - app_manifest.appId.c_str(), - appStateToString(old_state), - appStateToString(state) - ); - - switch (state) { - using enum app::State; - case Initial: - check(false, LOG_MESSAGE_ILLEGAL_STATE); - case Created: - assert(app->getState() == app::State::Initial); - app->getApp()->onCreate(*app); - pubsubExternal->publish(Event::ApplicationStarted); - break; - case Showing: { - assert(app->getState() == app::State::Hiding || app->getState() == app::State::Created); - pubsubExternal->publish(Event::ApplicationShowing); - break; - } - case Hiding: { - assert(app->getState() == app::State::Showing); - pubsubExternal->publish(Event::ApplicationHiding); - break; - } - case Destroyed: - app->getApp()->onDestroy(*app); - pubsubExternal->publish(Event::ApplicationStopped); - break; - } - - app->setState(state); -} - -app::LaunchId LoaderService::start(const std::string& id, std::shared_ptr parameters) { - const auto launch_id = nextLaunchId++; - dispatcherThread->dispatch([this, id, launch_id, parameters]() { - onStartAppMessage(id, launch_id, parameters); - }); - return launch_id; -} - -void LoaderService::stopTop() { - const auto& id = getCurrentAppContext()->getManifest().appId; - stopTop(id); -} - -void LoaderService::stopTop(const std::string& id) { - LOG_I(TAG, "dispatching stopTop(%s)", id.c_str()); - dispatcherThread->dispatch([this, id] { - onStopTopAppMessage(id); - }); -} - -void LoaderService::stopAll(const std::string& id) { - LOG_I(TAG, "dispatching stopAll(%s)", id.c_str()); - dispatcherThread->dispatch([this, id] { - onStopAllAppMessage(id); - }); -} - -std::shared_ptr LoaderService::getCurrentAppContext() { - const auto lock = mutex.asScopedLock(); - lock.lock(); - if (appStack.empty()) { - return nullptr; - } else { - return appStack[appStack.size() - 1]; - } -} - -bool LoaderService::isRunning(const std::string& id) const { - const auto lock = mutex.asScopedLock(); - lock.lock(); - for (const auto& app : appStack) { - if (app->getManifest().appId == id) { - return true; - } - } - return false; -} - -std::shared_ptr findLoaderService() { - return service::findServiceById(manifest.id); -} - -extern const ServiceManifest manifest = { - .id = "Loader", - .createService = create -}; - - -} // namespace diff --git a/Tactility/Source/service/screenshot/ScreenshotTask.cpp b/Tactility/Source/service/screenshot/ScreenshotTask.cpp index 84e70e6bc..d1f19ce82 100644 --- a/Tactility/Source/service/screenshot/ScreenshotTask.cpp +++ b/Tactility/Source/service/screenshot/ScreenshotTask.cpp @@ -5,9 +5,10 @@ #include #include #include -#include #include +#include + #include #include @@ -66,7 +67,7 @@ static void makeScreenshot(const std::string& filename) { void ScreenshotTask::taskMain() { uint8_t screenshots_taken = 0; - std::string last_app_id; + uint32_t last_app_instance_id = 0; while (!isInterrupted()) { if (work.type == TASK_WORK_TYPE_DELAY) { @@ -85,15 +86,13 @@ void ScreenshotTask::taskMain() { } } } else if (work.type == TASK_WORK_TYPE_APPS) { - auto appContext = app::getCurrentAppContext(); - if (appContext != nullptr) { - const app::AppManifest& manifest = appContext->getManifest(); - if (manifest.appId != last_app_id) { - delay_millis(100); - last_app_id = manifest.appId; - auto filename = std::format("{}/screenshot-{}.png", work.path, manifest.appId); - makeScreenshot(filename); - } + AppInstanceId app_instance_id = 0; + bool has_topmost = app_manager_get_topmost_instance_id(&app_instance_id) == ERROR_NONE; + if (has_topmost && app_instance_id != last_app_instance_id) { + delay_millis(100); + last_app_instance_id = app_instance_id; + auto filename = std::format("{}/screenshot-{}.png", work.path, app_instance_id); + makeScreenshot(filename); } // Ensure the LVGL widgets are rendered as the app just started delay_millis(250); diff --git a/Tactility/Source/service/webserver/WebServerService.cpp b/Tactility/Source/service/webserver/WebServerService.cpp index 93132bf07..9c21792bc 100644 --- a/Tactility/Source/service/webserver/WebServerService.cpp +++ b/Tactility/Source/service/webserver/WebServerService.cpp @@ -8,12 +8,7 @@ #include #include -#include - #include -#include -#include -#include #include #include #include @@ -21,6 +16,7 @@ #include #include +#include #include #include @@ -31,6 +27,10 @@ #include #endif +#include "app/install.h" +#include "app/manager.h" + + #include #include #include @@ -41,8 +41,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -50,6 +50,7 @@ #include #include #include +#include namespace tt::service::webserver { @@ -1215,32 +1216,30 @@ esp_err_t WebServerService::handleApiSysinfo(httpd_req_t* request) { esp_err_t WebServerService::handleApiApps(httpd_req_t* request) { LOG_I(TAG, "GET /api/apps"); - auto manifests = app::getAppManifests(); + std::vector manifests; + app_manager_for_each_manifest([](const ::AppManifest* manifest, void* context) { + static_cast*>(context)->push_back(manifest); + }, &manifests); std::ostringstream json; json << "{\"apps\":["; bool first = true; - for (const auto& manifest : manifests) { + for (const auto* manifest : manifests) { if (!first) json << ","; first = false; json << "{"; - json << "\"id\":\"" << escapeJson(manifest->appId) << "\","; - json << "\"name\":\"" << escapeJson(manifest->appName) << "\","; - json << "\"version\":\"" << escapeJson(manifest->appVersionName) << "\","; + json << "\"id\":\"" << escapeJson(manifest->id) << "\","; + json << "\"name\":\"" << escapeJson(manifest->name) << "\","; const char* category = "user"; - if (manifest->appCategory == app::Category::System) category = "system"; - else if (manifest->appCategory == app::Category::Settings) category = "settings"; + if (manifest->category == APP_CATEGORY_SYSTEM) category = "system"; + else if (manifest->category == APP_CATEGORY_SETTINGS) category = "settings"; json << "\"category\":\"" << category << "\","; - json << "\"isExternal\":" << (manifest->appLocation.isExternal() ? "true" : "false") << ","; - json << "\"hidden\":" << ((manifest->appFlags & app::AppManifest::Flags::Hidden) ? "true" : "false"); - - if (!manifest->appIcon.empty()) { - json << ",\"icon\":\"" << escapeJson(manifest->appIcon) << "\""; - } + json << "\"isExternal\":" << (manifest->location.type == APP_LOCATION_PATH ? "true" : "false") << ","; + json << "\"hidden\":" << ((manifest->flags & APP_MANIFEST_FLAG_HIDDEN) ? "true" : "false"); json << "}"; } @@ -1262,18 +1261,16 @@ esp_err_t WebServerService::handleApiAppsRun(httpd_req_t* request) { return ESP_FAIL; } - auto manifest = app::findAppManifestById(appId); - if (!manifest) { + auto* manifest = app_manager_find_manifest(appId.c_str()); + if (manifest == nullptr) { httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "app not found"); return ESP_FAIL; } - // Stop if already running - if (app::isRunning(appId)) { - app::stopAll(appId); - } - - app::start(appId); + // Every app instance gets its own task now, so there's no "stop the existing one first" - + // this just starts a fresh instance alongside whatever's already running. + AppInstanceId instance_id = 0; + app_manager_start(appId.c_str(), &instance_id); LOG_I(TAG, "[200] /api/apps/run %s", appId.c_str()); httpd_resp_sendstr(request, "ok"); @@ -1290,20 +1287,20 @@ esp_err_t WebServerService::handleApiAppsUninstall(httpd_req_t* request) { return ESP_FAIL; } - auto manifest = app::findAppManifestById(appId); - if (!manifest) { + auto* manifest = app_manager_find_manifest(appId.c_str()); + if (manifest == nullptr) { LOG_I(TAG, "[200] /api/apps/uninstall %s (app wasn't installed)", appId.c_str()); httpd_resp_sendstr(request, "ok"); return ESP_OK; } - // Only allow uninstalling external apps - if (manifest->appLocation.isInternal()) { + // Only allow uninstalling external (side-loaded) apps + if (manifest->location.type != APP_LOCATION_PATH) { httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "cannot uninstall system apps"); return ESP_FAIL; } - if (app::uninstall(appId)) { + if (app_uninstall(appId.c_str()) == ERROR_NONE) { LOG_I(TAG, "[200] /api/apps/uninstall %s", appId.c_str()); httpd_resp_sendstr(request, "ok"); return ESP_OK; @@ -1393,7 +1390,7 @@ esp_err_t WebServerService::handleApiAppsInstall(httpd_req_t* request) { } // Install the app - if (!app::install(file_path)) { + if (app_install(file_path.c_str()) != ERROR_NONE) { file::deleteFile(file_path); httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "installation failed"); return ESP_FAIL; From 994aa31f6eb6fabfd9ca5166f2dc6af27fd01716 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 19:09:19 +0200 Subject: [PATCH 09/31] Fix PC builds --- Tactility/Source/Tactility.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 6683dc191..1b77a4ef3 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -474,7 +474,9 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) { check(module_ensure_started(&gps_meshtastic_module) == ERROR_NONE); // Registers the APP_LOCATION_MEMORY app loader (boot/launcher need it below). check(module_ensure_started(&app_module) == ERROR_NONE); +#ifdef ESP_PLATFORM check(module_ensure_started(&app_esp32_module) == ERROR_NONE); +#endif #ifdef ESP_PLATFORM initEsp(); From 87f4985037d1c09b79c5e72a28ae99f36980ea62 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 19:17:18 +0200 Subject: [PATCH 10/31] Fixes and updates --- Libraries/QRCode/CMakeLists.txt | 5 ++ .../Source/AppManifestParsingTest.cpp | 83 ------------------- Tests/Tactility/Source/BundleTest.cpp | 49 ----------- Tests/Tactility/Source/PropertiesFileTest.cpp | 30 ------- 4 files changed, 5 insertions(+), 162 deletions(-) delete mode 100644 Tests/Tactility/Source/AppManifestParsingTest.cpp delete mode 100644 Tests/Tactility/Source/BundleTest.cpp delete mode 100644 Tests/Tactility/Source/PropertiesFileTest.cpp diff --git a/Libraries/QRCode/CMakeLists.txt b/Libraries/QRCode/CMakeLists.txt index dc61e7d60..b36264cff 100644 --- a/Libraries/QRCode/CMakeLists.txt +++ b/Libraries/QRCode/CMakeLists.txt @@ -25,4 +25,9 @@ else() target_include_directories(QRCode PUBLIC src ) + + # qrcode.h polyfills bool/true/false for pre-C23 compilers - on a host compiler that + # defaults to C23 (where bool is a keyword), that polyfill itself fails to compile. Pin to + # C11 for the simulator build only; ESP-IDF's own toolchain default is unaffected. + set_target_properties(QRCode PROPERTIES C_STANDARD 11 C_STANDARD_REQUIRED ON) endif() diff --git a/Tests/Tactility/Source/AppManifestParsingTest.cpp b/Tests/Tactility/Source/AppManifestParsingTest.cpp deleted file mode 100644 index 7b83c2698..000000000 --- a/Tests/Tactility/Source/AppManifestParsingTest.cpp +++ /dev/null @@ -1,83 +0,0 @@ -#include "TestFile.h" -#include "../../Tactility/Private/Tactility/app/AppManifestParsing.h" -#include "../../Tactility/Private/Tactility/app/AppManifestParsingInternal.h" - -#include "doctest.h" - -using namespace tt; -using namespace tt::app; - -TEST_CASE("parseManifest() should parse a V1 (sectioned) manifest.properties file") { - TestFile file("test-manifest-v1.properties"); - file.writeData( - "[manifest]\n" - "version=0.1\n" - "[target]\n" - "sdk=0.0.0\n" - "platforms=esp32,esp32s3,esp32c6,esp32p4\n" - "[app]\n" - "id=one.tactility.sdktest\n" - "versionName=0.1.0\n" - "versionCode=1\n" - "name=SDK Test\n" - ); - - AppManifest manifest; - CHECK_EQ(parseManifest(file.getPath(), manifest), true); - CHECK_EQ(manifest.targetSdk, "0.0.0"); - CHECK_EQ(manifest.targetPlatforms, "esp32,esp32s3,esp32c6,esp32p4"); - CHECK_EQ(manifest.appId, "one.tactility.sdktest"); - CHECK_EQ(manifest.appName, "SDK Test"); - CHECK_EQ(manifest.appVersionName, "0.1.0"); - CHECK_EQ(manifest.appVersionCode, 1); -} - -TEST_CASE("parseManifest() should parse a V2 (flat) manifest.properties file") { - TestFile file("test-manifest-v2.properties"); - file.writeData( - "manifest.version=0.1\n" - "target.sdk=0.0.0\n" - "target.platforms=esp32,esp32s3,esp32c6,esp32p4\n" - "app.id=one.tactility.sdktest\n" - "app.version.name=0.1.0\n" - "app.version.code=1\n" - "app.name=SDK Test\n" - ); - - AppManifest manifest; - CHECK_EQ(parseManifest(file.getPath(), manifest), true); - CHECK_EQ(manifest.targetSdk, "0.0.0"); - CHECK_EQ(manifest.targetPlatforms, "esp32,esp32s3,esp32c6,esp32p4"); - CHECK_EQ(manifest.appId, "one.tactility.sdktest"); - CHECK_EQ(manifest.appName, "SDK Test"); - CHECK_EQ(manifest.appVersionName, "0.1.0"); - CHECK_EQ(manifest.appVersionCode, 1); -} - -TEST_CASE("parseManifestV1() should fail when a required key is missing") { - std::map properties = { - {"[manifest]version", "0.1"}, - {"[app]id", "one.tactility.sdktest"}, - {"[app]name", "SDK Test"}, - {"[app]versionName", "0.1.0"}, - {"[app]versionCode", "1"}, - // Missing [target]sdk - {"[target]platforms", "esp32"}, - }; - AppManifest manifest; - CHECK_EQ(parseManifestV1(properties, manifest), false); -} - -TEST_CASE("parseManifestV2() should fail when the app id is invalid") { - std::map properties = { - {"manifest.version", "0.1"}, - {"app.id", "abc"}, // too short (isValidId requires >= 5 chars) - {"app.name", "SDK Test"}, - {"app.version.name", "0.1.0"}, - {"app.version.code", "1"}, - {"target.sdk", "0.0.0"}, - {"target.platforms", "esp32"}, - }; - AppManifest manifest; - CHECK_EQ(parseManifestV2(properties, manifest), false); -} diff --git a/Tests/Tactility/Source/BundleTest.cpp b/Tests/Tactility/Source/BundleTest.cpp deleted file mode 100644 index 99f1fe6ed..000000000 --- a/Tests/Tactility/Source/BundleTest.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "doctest.h" -#include - -using namespace tt; - -TEST_CASE("boolean can be stored and retrieved") { - Bundle bundle; - bundle.putBool("key", true); - CHECK(bundle.hasBool("key")); - CHECK(bundle.getBool("key")); - bool opt_result = false; - CHECK(bundle.optBool("key", opt_result)); - CHECK_EQ(opt_result, true); -} - -TEST_CASE("int32 can be stored and retrieved") { - Bundle bundle; - bundle.putInt32("key", true); - CHECK(bundle.hasInt32("key")); - CHECK(bundle.getInt32("key")); - int32_t opt_result = false; - CHECK(bundle.optInt32("key", opt_result)); - CHECK_EQ(opt_result, true); -} - -TEST_CASE("string can be stored and retrieved") { - Bundle bundle; - bundle.putString("key", "test"); - CHECK(bundle.hasString("key")); - CHECK_EQ(bundle.getString("key"), "test"); - std::string opt_result; - CHECK(bundle.optString("key", opt_result)); - CHECK_EQ(opt_result, "test"); -} - -TEST_CASE("bundle copy makes an actual copy") { - auto* original_ptr = new Bundle(); - Bundle& original = *original_ptr; - original.putBool("bool", true); - original.putInt32("int32", 123); - original.putString("string", "text"); - - Bundle copy = original; - delete original_ptr; - - CHECK_EQ(copy.getBool("bool"), true); - CHECK_EQ(copy.getInt32("int32"), 123); - CHECK_EQ(copy.getString("string"), "text"); -} diff --git a/Tests/Tactility/Source/PropertiesFileTest.cpp b/Tests/Tactility/Source/PropertiesFileTest.cpp deleted file mode 100644 index a8f7d7c6f..000000000 --- a/Tests/Tactility/Source/PropertiesFileTest.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "TestFile.h" -#include "../../Tactility/Include/Tactility/file/PropertiesFile.h" - -#include "doctest.h" - -using namespace tt; - -TEST_CASE("loadPropertiesFile() should return false when the file does not exist") { - std::map properties; - CHECK_EQ(file::loadPropertiesFile("does_not_exist.properties", properties), false); -} - -TEST_CASE("PropertiesFile should parse a valid file properly") { - TestFile file("test.properties"); - file.writeData( - "# Comment\n" // Regular comment - " \t# Comment\n" // Prefixed comment - "key1=value1\n" // Regular property - " \tkey 2\t = \tvalue 2\t " // Property with empty space - ); - - std::map properties; - - // Load data - CHECK_EQ(file::loadPropertiesFile(file.getPath(), properties), true); - - CHECK_EQ(properties.size(), 2); - CHECK_EQ(properties["key1"], "value1"); - CHECK_EQ(properties["key 2"], "value 2"); -} From e1fb827548f30eb8d381e40ba87d723e737fc459 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 21:43:46 +0200 Subject: [PATCH 11/31] Fixes --- Buildscripts/TactilitySDK/CMakeLists.txt | 5 +- Buildscripts/release-sdk.py | 4 + Devices/generic-esp32/devicetree.yaml | 7 - Devices/generic-esp32c6/devicetree.yaml | 7 - Devices/generic-esp32p4/devicetree.yaml | 8 - Devices/generic-esp32s3/devicetree.yaml | 7 - Tactility/Include/Tactility/Bundle.h | 55 ------- Tactility/Include/Tactility/Preferences.h | 41 ----- Tactility/Source/Bundle.cpp | 113 -------------- Tactility/Source/PreferencesEsp.cpp | 147 ------------------ Tactility/Source/PreferencesMock.cpp | 84 ---------- .../Source/app/i2cscanner/I2cScanner.cpp | 36 ++++- Tactility/Source/app/setup/Setup.cpp | 41 ++++- Tactility/Source/network/Ntp.cpp | 40 ++++- Tactility/Source/settings/time.cpp | 79 +++++++--- 15 files changed, 167 insertions(+), 507 deletions(-) delete mode 100644 Tactility/Include/Tactility/Bundle.h delete mode 100644 Tactility/Include/Tactility/Preferences.h delete mode 100644 Tactility/Source/Bundle.cpp delete mode 100644 Tactility/Source/PreferencesEsp.cpp delete mode 100644 Tactility/Source/PreferencesMock.cpp diff --git a/Buildscripts/TactilitySDK/CMakeLists.txt b/Buildscripts/TactilitySDK/CMakeLists.txt index 67107519e..36967944f 100644 --- a/Buildscripts/TactilitySDK/CMakeLists.txt +++ b/Buildscripts/TactilitySDK/CMakeLists.txt @@ -5,9 +5,10 @@ idf_component_register( "Libraries/TactilityFreeRtos/Include" "Libraries/lvgl/include" "Libraries/minmea/include" + "Libraries/minitar/include" "Modules/lvgl-module/include" # DRIVER_INCLUDE_DIRS_PLACEHOLDER - REQUIRES esp_timer minitar app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module + REQUIRES esp_timer app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module ) # Regular and core features @@ -15,8 +16,10 @@ add_prebuilt_library(TactilityC Libraries/TactilityC/binary/libTactilityC.a) add_prebuilt_library(TactilityKernel Libraries/TactilityKernel/binary/libTactilityKernel.a) add_prebuilt_library(lvgl Libraries/lvgl/binary/liblvgl.a) add_prebuilt_library(minmea Libraries/minmea/binary/libminmea.a) +add_prebuilt_library(minitar Libraries/minitar/binary/libminitar.a) target_link_libraries(${COMPONENT_LIB} INTERFACE TactilityC) target_link_libraries(${COMPONENT_LIB} INTERFACE TactilityKernel) target_link_libraries(${COMPONENT_LIB} INTERFACE lvgl) target_link_libraries(${COMPONENT_LIB} INTERFACE minmea) +target_link_libraries(${COMPONENT_LIB} INTERFACE minitar) diff --git a/Buildscripts/release-sdk.py b/Buildscripts/release-sdk.py index 2aadf72c6..259426aa3 100644 --- a/Buildscripts/release-sdk.py +++ b/Buildscripts/release-sdk.py @@ -185,6 +185,10 @@ def main(): # elf_loader {'src': 'Libraries/elf_loader/elf_loader.cmake', 'dst': 'Libraries/elf_loader/'}, {'src': 'Libraries/elf_loader/license.txt', 'dst': 'Libraries/elf_loader/'}, + # minitar + {'src': 'build/esp-idf/minitar/libminitar.a', 'dst': 'Libraries/minitar/binary/'}, + {'src': 'Libraries/minitar/minitar/minitar.h', 'dst': 'Libraries/minitar/include/'}, + {'src': 'Libraries/minitar/minitar/LICENSE*', 'dst': 'Libraries/minitar/'}, # minmea {'src': 'build/esp-idf/minmea/libminmea.a', 'dst': 'Libraries/minmea/binary/'}, {'src': 'Libraries/minmea/Include/**', 'dst': 'Libraries/minmea/include/'}, diff --git a/Devices/generic-esp32/devicetree.yaml b/Devices/generic-esp32/devicetree.yaml index 577f8a6e3..6a9f8cbbe 100644 --- a/Devices/generic-esp32/devicetree.yaml +++ b/Devices/generic-esp32/devicetree.yaml @@ -1,10 +1,3 @@ dependencies: - Platforms/platform-esp32 - # Add all driver modules because the generic devices are used to build the SDK - - Drivers/bm8563-module - - Drivers/bmi270-module - - Drivers/mpu6886-module - - Drivers/pi4ioe5v6408-module - - Drivers/qmi8658-module - - Drivers/rx8130ce-module dts: generic,esp32.dts diff --git a/Devices/generic-esp32c6/devicetree.yaml b/Devices/generic-esp32c6/devicetree.yaml index 574c81953..3b5ab4d4c 100644 --- a/Devices/generic-esp32c6/devicetree.yaml +++ b/Devices/generic-esp32c6/devicetree.yaml @@ -1,10 +1,3 @@ dependencies: - Platforms/platform-esp32 - # Add all driver modules because the generic devices are used to build the SDK - - Drivers/bm8563-module - - Drivers/bmi270-module - - Drivers/mpu6886-module - - Drivers/pi4ioe5v6408-module - - Drivers/qmi8658-module - - Drivers/rx8130ce-module dts: generic,esp32c6.dts diff --git a/Devices/generic-esp32p4/devicetree.yaml b/Devices/generic-esp32p4/devicetree.yaml index c700c1ced..4c31671aa 100644 --- a/Devices/generic-esp32p4/devicetree.yaml +++ b/Devices/generic-esp32p4/devicetree.yaml @@ -1,11 +1,3 @@ dependencies: - Platforms/platform-esp32 - # Add all driver modules because the generic devices are used to build the SDK - - Drivers/bm8563-module - - Drivers/bmi270-module - - Drivers/mpu6886-module - - Drivers/pi4ioe5v6408-module - - Drivers/qmi8658-module - - Drivers/rx8130ce-module - - Drivers/sc2356-module dts: generic,esp32p4.dts diff --git a/Devices/generic-esp32s3/devicetree.yaml b/Devices/generic-esp32s3/devicetree.yaml index 7dac4215e..1a525a8ad 100644 --- a/Devices/generic-esp32s3/devicetree.yaml +++ b/Devices/generic-esp32s3/devicetree.yaml @@ -1,10 +1,3 @@ dependencies: - Platforms/platform-esp32 - # Add all driver modules because the generic devices are used to build the SDK - - Drivers/bm8563-module - - Drivers/bmi270-module - - Drivers/mpu6886-module - - Drivers/pi4ioe5v6408-module - - Drivers/qmi8658-module - - Drivers/rx8130ce-module dts: generic,esp32s3.dts diff --git a/Tactility/Include/Tactility/Bundle.h b/Tactility/Include/Tactility/Bundle.h deleted file mode 100644 index e935fc3fd..000000000 --- a/Tactility/Include/Tactility/Bundle.h +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @brief key-value storage for general purpose. - * Maps strings on a fixed set of data types. - */ -#pragma once - -#include -#include - -namespace tt { - -/** - * A dictionary that maps keys (strings) onto several atomary types. - * Thin C++ wrapper around TactilityKernel's C Bundle (tactility/bundle.h). - */ -class Bundle final { - - // Actually a TactilityKernel ::Bundle* (tactility/bundle.h), cast in Bundle.cpp - kept as - // void* here rather than a forward-declared `struct Bundle*` so this header doesn't put a - // second, unqualified `Bundle` name in scope: any TU with `using namespace tt;` in effect - // (e.g. tests) would then find both `::Bundle` and `tt::Bundle` for a bare `Bundle` lookup - // and fail with "reference to 'Bundle' is ambiguous". - void* handle; - -public: - - Bundle(); - - Bundle(const Bundle& bundle); - Bundle& operator=(const Bundle& bundle); - - ~Bundle(); - - bool getBool(const std::string& key) const; - int32_t getInt32(const std::string& key) const; - int64_t getInt64(const std::string& key) const; - std::string getString(const std::string& key) const; - - bool hasBool(const std::string& key) const; - bool hasInt32(const std::string& key) const; - bool hasInt64(const std::string& key) const; - bool hasString(const std::string& key) const; - - bool optBool(const std::string& key, bool& out) const; - bool optInt32(const std::string& key, int32_t& out) const; - bool optInt64(const std::string& key, int64_t& out) const; - bool optString(const std::string& key, std::string& out) const; - - void putBool(const std::string& key, bool value); - void putInt32(const std::string& key, int32_t value); - void putInt64(const std::string& key, int64_t value); - void putString(const std::string& key, const std::string& value); -}; - -} // namespace diff --git a/Tactility/Include/Tactility/Preferences.h b/Tactility/Include/Tactility/Preferences.h deleted file mode 100644 index 8e5a921da..000000000 --- a/Tactility/Include/Tactility/Preferences.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include -#include - -namespace tt { - -/** - * Settings that persist on NVS flash for ESP32. - * On simulator, the settings are only in-memory. - * - * Note that on ESP32, there are limitations: - * - namespace name is limited by NVS_NS_NAME_MAX_SIZE (generally 16 characters) - * - key is limited by NVS_KEY_NAME_MAX_SIZE (generally 16 characters) - */ -class Preferences { - - const char* namespace_; - -public: - explicit Preferences(const char* namespace_) { - this->namespace_ = namespace_; - } - - bool hasBool(const std::string& key) const; - bool hasInt32(const std::string& key) const; - bool hasInt64(const std::string& key) const; - bool hasString(const std::string& key) const; - - bool optBool(const std::string& key, bool& out) const; - bool optInt32(const std::string& key, int32_t& out) const; - bool optInt64(const std::string& key, int64_t& out) const; - bool optString(const std::string& key, std::string& out) const; - - void putBool(const std::string& key, bool value); - void putInt32(const std::string& key, int32_t value); - void putInt64(const std::string& key, int64_t value); - void putString(const std::string& key, const std::string& value); -}; - -} // namespace diff --git a/Tactility/Source/Bundle.cpp b/Tactility/Source/Bundle.cpp deleted file mode 100644 index cd8c8c08a..000000000 --- a/Tactility/Source/Bundle.cpp +++ /dev/null @@ -1,113 +0,0 @@ -#include "Tactility/Bundle.h" - -#include - -#include - -namespace tt { - -namespace { -::Bundle* as_kernel(void* handle) { return static_cast<::Bundle*>(handle); } -} // namespace - -Bundle::Bundle() : handle(bundle_alloc()) {} - -Bundle::Bundle(const Bundle& bundle) : handle(bundle_clone(as_kernel(bundle.handle))) {} - -Bundle& Bundle::operator=(const Bundle& bundle) { - if (this != &bundle) { - ::Bundle* cloned = bundle_clone(as_kernel(bundle.handle)); - bundle_free(as_kernel(handle)); - handle = cloned; - } - return *this; -} - -Bundle::~Bundle() { - bundle_free(as_kernel(handle)); -} - -bool Bundle::getBool(const std::string& key) const { - return bundle_get_bool(as_kernel(handle), key.c_str()); -} - -int32_t Bundle::getInt32(const std::string& key) const { - return bundle_get_int32(as_kernel(handle), key.c_str()); -} - -int64_t Bundle::getInt64(const std::string& key) const { - return bundle_get_int64(as_kernel(handle), key.c_str()); -} - -std::string Bundle::getString(const std::string& key) const { - // bundle_get_string() needs a bounded buffer; grow and retry until it fits. - std::vector buffer(64); - while (true) { - error_t error = bundle_get_string(as_kernel(handle), key.c_str(), buffer.data(), buffer.size()); - if (error == ERROR_NONE) { - return std::string(buffer.data()); - } - buffer.resize(buffer.size() * 2); - } -} - -bool Bundle::hasBool(const std::string& key) const { - return bundle_has_bool(as_kernel(handle), key.c_str()); -} - -bool Bundle::hasInt32(const std::string& key) const { - return bundle_has_int32(as_kernel(handle), key.c_str()); -} - -bool Bundle::hasInt64(const std::string& key) const { - return bundle_has_int64(as_kernel(handle), key.c_str()); -} - -bool Bundle::hasString(const std::string& key) const { - return bundle_has_string(as_kernel(handle), key.c_str()); -} - -bool Bundle::optBool(const std::string& key, bool& out) const { - return bundle_opt_bool(as_kernel(handle), key.c_str(), &out); -} - -bool Bundle::optInt32(const std::string& key, int32_t& out) const { - return bundle_opt_int32(as_kernel(handle), key.c_str(), &out); -} - -bool Bundle::optInt64(const std::string& key, int64_t& out) const { - return bundle_opt_int64(as_kernel(handle), key.c_str(), &out); -} - -bool Bundle::optString(const std::string& key, std::string& out) const { - std::vector buffer(64); - while (true) { - error_t error = bundle_opt_string(as_kernel(handle), key.c_str(), buffer.data(), buffer.size()); - if (error == ERROR_NONE) { - out = buffer.data(); - return true; - } - if (error == ERROR_NOT_FOUND) { - return false; - } - buffer.resize(buffer.size() * 2); - } -} - -void Bundle::putBool(const std::string& key, bool value) { - bundle_put_bool(as_kernel(handle), key.c_str(), value); -} - -void Bundle::putInt32(const std::string& key, int32_t value) { - bundle_put_int32(as_kernel(handle), key.c_str(), value); -} - -void Bundle::putInt64(const std::string& key, int64_t value) { - bundle_put_int64(as_kernel(handle), key.c_str(), value); -} - -void Bundle::putString(const std::string& key, const std::string& value) { - bundle_put_string(as_kernel(handle), key.c_str(), value.c_str()); -} - -} // namespace diff --git a/Tactility/Source/PreferencesEsp.cpp b/Tactility/Source/PreferencesEsp.cpp deleted file mode 100644 index ccd593796..000000000 --- a/Tactility/Source/PreferencesEsp.cpp +++ /dev/null @@ -1,147 +0,0 @@ -#ifdef ESP_PLATFORM - -#include -#include - -#include -#include - -namespace tt { - -constexpr auto* TAG = "Preferences"; - -bool Preferences::optBool(const std::string& key, bool& out) const { - nvs_handle_t handle; - if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) { - LOG_E(TAG, "Failed to open namespace %s", namespace_); - return false; - } else { - uint8_t out_number; - bool success = nvs_get_u8(handle, key.c_str(), &out_number) == ESP_OK; - nvs_close(handle); - if (success) { - out = (bool)out_number; - } - return success; - } -} - -bool Preferences::optInt32(const std::string& key, int32_t& out) const { - nvs_handle_t handle; - if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) { - LOG_E(TAG, "Failed to open namespace %s", namespace_); - return false; - } else { - bool success = nvs_get_i32(handle, key.c_str(), &out) == ESP_OK; - nvs_close(handle); - return success; - } -} - -bool Preferences::optInt64(const std::string& key, int64_t& out) const { - nvs_handle_t handle; - if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) { - LOG_E(TAG, "Failed to open namespace %s", namespace_); - return false; - } else { - bool success = nvs_get_i64(handle, key.c_str(), &out) == ESP_OK; - nvs_close(handle); - return success; - } -} - -bool Preferences::optString(const std::string& key, std::string& out) const { - nvs_handle_t handle; - if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) { - LOG_E(TAG, "Failed to open namespace %s", namespace_); - return false; - } else { - size_t out_size = 256; - char* out_data = static_cast(malloc(out_size)); - bool success = nvs_get_str(handle, key.c_str(), out_data, &out_size) == ESP_OK; - nvs_close(handle); - out = out_data; - free(out_data); - return success; - } -} - -bool Preferences::hasBool(const std::string& key) const { - bool temp; - return optBool(key, temp); -} - -bool Preferences::hasInt32(const std::string& key) const { - int32_t temp; - return optInt32(key, temp); -} - -bool Preferences::hasInt64(const std::string& key) const { - int64_t temp; - return optInt64(key, temp); -} - -bool Preferences::hasString(const std::string& key) const { - std::string temp; - return optString(key, temp); -} - -void Preferences::putBool(const std::string& key, bool value) { - nvs_handle_t handle; - if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) { - if (nvs_set_u8(handle, key.c_str(), value) != ESP_OK) { - LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str()); - } else if (nvs_commit(handle) != ESP_OK) { - LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str()); - } - nvs_close(handle); - } else { - LOG_E(TAG, "Failed to open namespace %s", namespace_); - } -} - -void Preferences::putInt32(const std::string& key, int32_t value) { - nvs_handle_t handle; - if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) { - if (nvs_set_i32(handle, key.c_str(), value) != ESP_OK) { - LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str()); - } else if (nvs_commit(handle) != ESP_OK) { - LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str()); - } - nvs_close(handle); - } else { - LOG_E(TAG, "Failed to open namespace %s", namespace_); - } -} - -void Preferences::putInt64(const std::string& key, int64_t value) { - nvs_handle_t handle; - if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) { - if (nvs_set_i64(handle, key.c_str(), value) != ESP_OK) { - LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str()); - } else if (nvs_commit(handle) != ESP_OK) { - LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str()); - } - nvs_close(handle); - } else { - LOG_E(TAG, "Failed to open namespace %s", namespace_); - } -} - -void Preferences::putString(const std::string& key, const std::string& text) { - nvs_handle_t handle; - if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) { - if (nvs_set_str(handle, key.c_str(), text.c_str()) != ESP_OK) { - LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str()); - } else if (nvs_commit(handle) != ESP_OK) { - LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str()); - } - nvs_close(handle); - } else { - LOG_E(TAG, "Failed to open namespace %s", namespace_); - } -} - -} // namespace - -#endif \ No newline at end of file diff --git a/Tactility/Source/PreferencesMock.cpp b/Tactility/Source/PreferencesMock.cpp deleted file mode 100644 index a524cb654..000000000 --- a/Tactility/Source/PreferencesMock.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#ifndef ESP_PLATFOM - -#include -#include - -namespace tt { - -static Bundle preferences; - -/** - * Creates a string that is effectively "namespace:key" so we can create a single map (bundle) - * to store all the key/value pairs. - * - * @param[in] namespace - * @param[in] key - * @param[out] out - */ -std::string get_bundle_key(const std::string& namespace_, const std::string& key) { - return namespace_ + ':' + key; -} - -bool Preferences::hasBool(const std::string& key) const { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.hasBool(bundle_key); -} - -bool Preferences::hasInt32(const std::string& key) const { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.hasInt32(bundle_key); -} - -bool Preferences::hasInt64(const std::string& key) const { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.hasInt64(bundle_key); -} - -bool Preferences::hasString(const std::string& key) const { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.hasString(bundle_key); -} - -bool Preferences::optBool(const std::string& key, bool& out) const { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.optBool(bundle_key, out); -} - -bool Preferences::optInt32(const std::string& key, int32_t& out) const { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.optInt32(bundle_key, out); -} - -bool Preferences::optInt64(const std::string& key, int64_t& out) const { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.optInt64(bundle_key, out); -} - -bool Preferences::optString(const std::string& key, std::string& out) const { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.optString(bundle_key, out); -} - -void Preferences::putBool(const std::string& key, bool value) { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.putBool(bundle_key, value); -} - -void Preferences::putInt32(const std::string& key, int32_t value) { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.putInt32(bundle_key, value); -} - -void Preferences::putInt64(const std::string& key, int64_t value) { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.putInt64(bundle_key, value); -} - -void Preferences::putString(const std::string& key, const std::string& value) { - std::string bundle_key = get_bundle_key(namespace_, key); - return preferences.putString(bundle_key, value); -} - -#endif - -} // namespace diff --git a/Tactility/Source/app/i2cscanner/I2cScanner.cpp b/Tactility/Source/app/i2cscanner/I2cScanner.cpp index 612fe7f79..6510436ff 100644 --- a/Tactility/Source/app/i2cscanner/I2cScanner.cpp +++ b/Tactility/Source/app/i2cscanner/I2cScanner.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include @@ -13,6 +12,8 @@ #include #include +#include +#include #include #include @@ -52,15 +53,40 @@ struct Context { #define PREFERENCES_BUS_INDEX_KEY "bus" +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/i2c_scanner.properties"; + return true; +} + void setLastBusIndex(int32_t index) { - auto prefs = Preferences("i2c_scanner"); - prefs.putInt32(PREFERENCES_BUS_INDEX_KEY, index); + std::string path; + if (!getPreferencesPath(path)) { + return; + } + Preferences* prefs = preferences_open(path.c_str()); + if (prefs == nullptr) { + return; + } + preferences_put_int32(prefs, PREFERENCES_BUS_INDEX_KEY, index); + preferences_close(prefs); } int32_t getLastBusIndex() { - auto prefs = Preferences("i2c_scanner"); + std::string path; + if (!getPreferencesPath(path)) { + return 0; + } + Preferences* prefs = preferences_open(path.c_str()); + if (prefs == nullptr) { + return 0; + } int32_t index = 0; - prefs.optInt32(PREFERENCES_BUS_INDEX_KEY, index); + preferences_opt_int32(prefs, PREFERENCES_BUS_INDEX_KEY, &index); + preferences_close(prefs); return index; } diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index 471283265..0998163d7 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -1,5 +1,4 @@ #include -#include #include #include #include @@ -11,6 +10,9 @@ #include +#include +#include + #include #include #include @@ -33,18 +35,47 @@ extern const ::AppManifest manifest; constexpr auto* PREFERENCES_NAMESPACE = "setup"; constexpr auto* PREFERENCES_KEY_COMPLETED = "completed"; +namespace { + +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/" + PREFERENCES_NAMESPACE + ".properties"; + return true; +} + +} // namespace + bool isCompleted() { - Preferences preferences(PREFERENCES_NAMESPACE); + std::string path; + if (!getPreferencesPath(path)) { + return false; + } + Preferences* preferences = preferences_open(path.c_str()); + if (preferences == nullptr) { + return false; + } bool completed = false; - preferences.optBool(PREFERENCES_KEY_COMPLETED, completed); + preferences_opt_bool(preferences, PREFERENCES_KEY_COMPLETED, &completed); + preferences_close(preferences); return completed; } namespace { void markCompleted() { - Preferences preferences(PREFERENCES_NAMESPACE); - preferences.putBool(PREFERENCES_KEY_COMPLETED, true); + std::string path; + if (!getPreferencesPath(path)) { + return; + } + Preferences* preferences = preferences_open(path.c_str()); + if (preferences == nullptr) { + return; + } + preferences_put_bool(preferences, PREFERENCES_KEY_COMPLETED, true); + preferences_close(preferences); } enum class Phase { diff --git a/Tactility/Source/network/Ntp.cpp b/Tactility/Source/network/Ntp.cpp index d7d43da25..c1d513366 100644 --- a/Tactility/Source/network/Ntp.cpp +++ b/Tactility/Source/network/Ntp.cpp @@ -1,9 +1,10 @@ #include -#include #include +#include +#include -#include +#include #ifdef ESP_PLATFORM #include @@ -20,24 +21,49 @@ static bool processedSyncEvent = false; #ifdef ESP_PLATFORM +static bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/time.properties"; + return true; +} + void storeTimeInNvs() { time_t now; time(&now); - auto preferences = std::make_unique("time"); - preferences->putInt64("syncTime", now); + std::string path; + if (!getPreferencesPath(path)) { + return; + } + Preferences* preferences = preferences_open(path.c_str()); + if (preferences == nullptr) { + return; + } + preferences_put_int64(preferences, "syncTime", now); + preferences_close(preferences); LOG_I(TAG, "Stored time %ld", (long)now); } void setTimeFromNvs() { - auto preferences = std::make_unique("time"); - time_t synced_time; - if (preferences->optInt64("syncTime", synced_time)) { + std::string path; + if (!getPreferencesPath(path)) { + return; + } + Preferences* preferences = preferences_open(path.c_str()); + if (preferences == nullptr) { + return; + } + int64_t synced_time = 0; + if (preferences_opt_int64(preferences, "syncTime", &synced_time)) { LOG_I(TAG, "Restoring last known time to %ld", (long)synced_time); timeval get_nvs_time; get_nvs_time.tv_sec = synced_time; settimeofday(&get_nvs_time, nullptr); } + preferences_close(preferences); } static void onTimeSynced(timeval* tv) { diff --git a/Tactility/Source/settings/time.cpp b/Tactility/Source/settings/time.cpp index 435c080f8..79f027923 100644 --- a/Tactility/Source/settings/time.cpp +++ b/Tactility/Source/settings/time.cpp @@ -1,8 +1,9 @@ #include -#include #include +#include +#include #include #ifdef ESP_PLATFORM @@ -17,6 +18,21 @@ constexpr auto* TIMEZONE_PREFERENCES_KEY_NAME = "tz_name"; constexpr auto* TIMEZONE_PREFERENCES_KEY_CODE = "tz_code"; constexpr auto* TIMEZONE_PREFERENCES_KEY_TIME24 = "tz_time24"; +namespace { + +// Same "time" namespace/file that Ntp.cpp's storeTimeInNvs()/setTimeFromNvs() use for +// "syncTime" - matches the shared NVS namespace this used to be. +bool getPreferencesPath(std::string& outPath) { + char root[128]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + outPath = std::string(root) + "/" + TIME_SETTINGS_NAMESPACE + ".properties"; + return true; +} + +} // namespace + void initTimeZone() { #ifdef ESP_PLATFORM auto code= getTimeZoneCode(); @@ -28,9 +44,15 @@ void initTimeZone() { } void setTimeZone(const std::string& name, const std::string& code) { - Preferences preferences(TIME_SETTINGS_NAMESPACE); - preferences.putString(TIMEZONE_PREFERENCES_KEY_NAME, name); - preferences.putString(TIMEZONE_PREFERENCES_KEY_CODE, code); + std::string path; + if (getPreferencesPath(path)) { + Preferences* preferences = preferences_open(path.c_str()); + if (preferences != nullptr) { + preferences_put_string(preferences, TIMEZONE_PREFERENCES_KEY_NAME, name.c_str()); + preferences_put_string(preferences, TIMEZONE_PREFERENCES_KEY_CODE, code.c_str()); + preferences_close(preferences); + } + } #ifdef ESP_PLATFORM setenv("TZ", code.c_str(), 1); @@ -41,32 +63,49 @@ void setTimeZone(const std::string& name, const std::string& code) { } std::string getTimeZoneName() { - Preferences preferences(TIME_SETTINGS_NAMESPACE); - std::string result; - if (preferences.optString(TIMEZONE_PREFERENCES_KEY_NAME, result)) { - return result; - } else { - return "Europe/Amsterdam"; + std::string path; + if (getPreferencesPath(path)) { + Preferences* preferences = preferences_open(path.c_str()); + if (preferences != nullptr) { + char buffer[64]; + error_t error = preferences_opt_string(preferences, TIMEZONE_PREFERENCES_KEY_NAME, buffer, sizeof(buffer)); + preferences_close(preferences); + if (error == ERROR_NONE) { + return buffer; + } + } } + return "Europe/Amsterdam"; } bool hasTimeZone() { - Preferences preferences(TIME_SETTINGS_NAMESPACE); - std::string timezone; - if (!preferences.optString(TIMEZONE_PREFERENCES_KEY_NAME, timezone)) { + std::string path; + if (!getPreferencesPath(path)) { return false; } - return !timezone.empty(); + Preferences* preferences = preferences_open(path.c_str()); + if (preferences == nullptr) { + return false; + } + bool has = preferences_has_string(preferences, TIMEZONE_PREFERENCES_KEY_NAME); + preferences_close(preferences); + return has; } std::string getTimeZoneCode() { - Preferences preferences(TIME_SETTINGS_NAMESPACE); - std::string result; - if (preferences.optString(TIMEZONE_PREFERENCES_KEY_CODE, result)) { - return result; - } else { - return "CET-1CEST,M3.5.0,M10.5.0/3"; // Default: Europe/Amsterdam + std::string path; + if (getPreferencesPath(path)) { + Preferences* preferences = preferences_open(path.c_str()); + if (preferences != nullptr) { + char buffer[64]; + error_t error = preferences_opt_string(preferences, TIMEZONE_PREFERENCES_KEY_CODE, buffer, sizeof(buffer)); + preferences_close(preferences); + if (error == ERROR_NONE) { + return buffer; + } + } } + return "CET-1CEST,M3.5.0,M10.5.0/3"; // Default: Europe/Amsterdam } bool isTimeFormat24Hour() { From a96fd77986505517d70dcb6e566097be91051dbb Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 21:50:50 +0200 Subject: [PATCH 12/31] Fix for macOS build --- TactilityFreeRtos/Include/Tactility/Thread.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TactilityFreeRtos/Include/Tactility/Thread.h b/TactilityFreeRtos/Include/Tactility/Thread.h index 20d4c1c27..ce09ca4ca 100644 --- a/TactilityFreeRtos/Include/Tactility/Thread.h +++ b/TactilityFreeRtos/Include/Tactility/Thread.h @@ -174,7 +174,7 @@ class Thread final { mutex.lock(); assert(mainFunction); assert(state == State::Stopped); - assert(stackSize > 0 && stackSize < (UINT16_MAX * sizeof(StackType_t))); + assert(stackSize > 0); mutex.unlock(); setState(State::Starting); From 26a8aa26c25412a88f9c08471e327de0874c526c Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 22:41:52 +0200 Subject: [PATCH 13/31] Fixes and cleanup --- Buildscripts/TactilitySDK/CMakeLists.txt | 1 - Buildscripts/TactilitySDK/TactilitySDK.cmake | 2 - Buildscripts/release-sdk.py | 53 +++----------------- Documentation/ideas.md | 2 + Tests/SdkIntegration/main/CMakeLists.txt | 8 +-- 5 files changed, 11 insertions(+), 55 deletions(-) diff --git a/Buildscripts/TactilitySDK/CMakeLists.txt b/Buildscripts/TactilitySDK/CMakeLists.txt index 36967944f..6fd65e2a8 100644 --- a/Buildscripts/TactilitySDK/CMakeLists.txt +++ b/Buildscripts/TactilitySDK/CMakeLists.txt @@ -7,7 +7,6 @@ idf_component_register( "Libraries/minmea/include" "Libraries/minitar/include" "Modules/lvgl-module/include" - # DRIVER_INCLUDE_DIRS_PLACEHOLDER REQUIRES esp_timer app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module ) diff --git a/Buildscripts/TactilitySDK/TactilitySDK.cmake b/Buildscripts/TactilitySDK/TactilitySDK.cmake index eb3c64682..fe60e8c7c 100644 --- a/Buildscripts/TactilitySDK/TactilitySDK.cmake +++ b/Buildscripts/TactilitySDK/TactilitySDK.cmake @@ -20,7 +20,6 @@ macro(tactility_project project_name) set(EXTRA_COMPONENT_DIRS "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos" "${TACTILITY_SDK_PATH}/Modules" - "${TACTILITY_SDK_PATH}/Drivers" ) set(COMPONENTS @@ -31,7 +30,6 @@ macro(tactility_project project_name) lvgl-module lvgl-window-manager-module service-module - # DRIVER_COMPONENTS_PLACEHOLDER ) endmacro() diff --git a/Buildscripts/release-sdk.py b/Buildscripts/release-sdk.py index 259426aa3..c530a4520 100644 --- a/Buildscripts/release-sdk.py +++ b/Buildscripts/release-sdk.py @@ -111,43 +111,13 @@ def add_module(target_path, module_name): cmakelists_content = create_module_cmakelists(module_name) write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content) -def discover_all_drivers(): - """ - Discover all *-module directories under Drivers/ (not Modules/ - those are handled - separately via add_module). Sorted for deterministic output across OS/filesystem order. - """ - pattern = os.path.join('Drivers', '*-module') - return sorted( - os.path.basename(p) for p in glob.glob(pattern) if os.path.isdir(p) - ) - -def generate_tactility_sdk_cmake(target_path, available_drivers): +def generate_tactility_sdk_cmake(target_path): src = os.path.join('Buildscripts', 'TactilitySDK', 'TactilitySDK.cmake') - with open(src) as f: - content = f.read() - placeholder = " # DRIVER_COMPONENTS_PLACEHOLDER" - assert placeholder in content, \ - f"Placeholder '{placeholder.strip()}' not found in {src} - template drifted, generator needs updating" - components = "\n".join(f" {d}" for d in available_drivers) - new_content = content.replace(placeholder, components) - assert placeholder not in new_content, \ - f"Placeholder '{placeholder.strip()}' still present after replacement in {src}" - with open(os.path.join(target_path, 'TactilitySDK.cmake'), 'w') as f: - f.write(new_content) - -def generate_tactility_sdk_top_cmakelists(target_path, available_drivers): + shutil.copy2(src, os.path.join(target_path, 'TactilitySDK.cmake')) + +def generate_tactility_sdk_top_cmakelists(target_path): src = os.path.join('Buildscripts', 'TactilitySDK', 'CMakeLists.txt') - with open(src) as f: - content = f.read() - placeholder = " # DRIVER_INCLUDE_DIRS_PLACEHOLDER" - assert placeholder in content, \ - f"Placeholder '{placeholder.strip()}' not found in {src} - template drifted, generator needs updating" - include_dirs = "\n".join(f' "Drivers/{d}/include"' for d in available_drivers) - new_content = content.replace(placeholder, include_dirs) - assert placeholder not in new_content, \ - f"Placeholder '{placeholder.strip()}' still present after replacement in {src}" - with open(os.path.join(target_path, 'CMakeLists.txt'), 'w') as f: - f.write(new_content) + shutil.copy2(src, os.path.join(target_path, 'CMakeLists.txt')) def main(): if len(sys.argv) < 2: @@ -208,16 +178,9 @@ def main(): add_module(target_path, "lvgl-window-manager-module") add_module(target_path, "service-module") - # Drivers - only ones actually built for this target (chip-restricted drivers like - # sc2356-module won't have a .a outside ESP32-P4) - available_drivers = [d for d in discover_all_drivers() if driver_is_available(d)] - for driver_name in available_drivers: - add_driver(target_path, driver_name) - - # Final scripts - generated (not copied verbatim) so COMPONENTS/INCLUDE_DIRS only list - # drivers actually available for this target - generate_tactility_sdk_cmake(target_path, available_drivers) - generate_tactility_sdk_top_cmakelists(target_path, available_drivers) + # Final scripts - copied verbatim + generate_tactility_sdk_cmake(target_path) + generate_tactility_sdk_top_cmakelists(target_path) # Output ESP-IDF SDK version to file esp_idf_version = os.environ.get("ESP_IDF_VERSION", "") diff --git a/Documentation/ideas.md b/Documentation/ideas.md index c1e06e060..e8940ad5d 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -12,6 +12,7 @@ ## Higher Priority +- Make it more clear to end-users that an SD card is required to run Tactility - Move "# Fix error "PSRAM space not enough for the Flash instructions" on boot:" fix from T-Deck and others to device.py - Make it possible to override stack size for an app via config file (loaded at boot), and make it possible to set preferred memory location (e.g. internal/external) - Put task stacks in PSRAM when possible. @@ -52,6 +53,7 @@ ## Medium Priority +- Consider moving certain drivers into separate modules: audio, bt, wifi, etc - Consider using https://github.com/Graphify-Labs/graphify - Consider implementing LVGL gridnav in apps https://lvgl.io/docs/open/9.3/details/auxiliary-modules/gridnav.html - Implement a LED kernel driver (single colour and RGB, plain GPIO and PWM) diff --git a/Tests/SdkIntegration/main/CMakeLists.txt b/Tests/SdkIntegration/main/CMakeLists.txt index 3e247cf3d..29dce1869 100644 --- a/Tests/SdkIntegration/main/CMakeLists.txt +++ b/Tests/SdkIntegration/main/CMakeLists.txt @@ -3,11 +3,5 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c) idf_component_register( SRCS ${SOURCE_FILES} REQUIRES TactilitySDK - lvgl-module - bm8563-module - bmi270-module - mpu6886-module - pi4ioe5v6408-module - qmi8658-module - rx8130ce-module + app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module ) From 68aae6520d7bdf26b4e75acda77ce33b08da49e5 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 22:49:53 +0200 Subject: [PATCH 14/31] Fix for SDK test --- Tests/SdkIntegration/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SdkIntegration/CMakeLists.txt b/Tests/SdkIntegration/CMakeLists.txt index 980270d3e..6bae4727c 100644 --- a/Tests/SdkIntegration/CMakeLists.txt +++ b/Tests/SdkIntegration/CMakeLists.txt @@ -10,7 +10,7 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH} ${TACTILITY_SDK_PATH}/Modules ${TACTILITY_SDK_PATH}/Drivers) +set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH} ${TACTILITY_SDK_PATH}/Modules project(SdkTest) tactility_project(SdkTest) From 22192dd1e4a627cd9c62928697eb379e02dcb0c4 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 22:51:40 +0200 Subject: [PATCH 15/31] Fix for macOS build --- .../Include/Tactility/{Paths.h => DeprecatedPaths.h} | 4 +++- Tactility/Source/{Paths.cpp => DeprecatedPaths.cpp} | 2 +- Tactility/Source/Tactility.cpp | 2 +- Tactility/Source/app/apphub/AppHubApp.cpp | 2 +- Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp | 2 +- Tactility/Source/app/boot/Boot.cpp | 2 +- Tactility/Source/app/chat/ChatSettings.cpp | 2 +- Tactility/Source/app/systeminfo/SystemInfo.cpp | 2 +- Tactility/Source/bluetooth/BluetoothPairedDevice.cpp | 2 +- Tactility/Source/bluetooth/BluetoothSettings.cpp | 4 ++-- .../Source/service/development/DevelopmentService.cpp | 4 ++-- .../Source/service/development/DevelopmentSettings.cpp | 2 +- Tactility/Source/service/webserver/WebServerService.cpp | 8 ++++---- Tactility/Source/service/wifi/WifiBootSplashInit.cpp | 2 +- Tactility/Source/settings/AudioSettings.cpp | 2 +- Tactility/Source/settings/BootSettings.cpp | 2 +- Tactility/Source/settings/SystemSettings.cpp | 2 +- Tactility/Source/settings/WebServerSettings.cpp | 6 +++--- 18 files changed, 27 insertions(+), 25 deletions(-) rename Tactility/Include/Tactility/{Paths.h => DeprecatedPaths.h} (88%) rename Tactility/Source/{Paths.cpp => DeprecatedPaths.cpp} (98%) diff --git a/Tactility/Include/Tactility/Paths.h b/Tactility/Include/Tactility/DeprecatedPaths.h similarity index 88% rename from Tactility/Include/Tactility/Paths.h rename to Tactility/Include/Tactility/DeprecatedPaths.h index 2d0093272..06532f69d 100644 --- a/Tactility/Include/Tactility/Paths.h +++ b/Tactility/Include/Tactility/DeprecatedPaths.h @@ -1,10 +1,12 @@ +/** + * DEPRECATED: Use TactilityKernels' tactility/paths.h + */ #pragma once #include #include - namespace tt { bool findFirstMountedSdCardPath(std::string& path); diff --git a/Tactility/Source/Paths.cpp b/Tactility/Source/DeprecatedPaths.cpp similarity index 98% rename from Tactility/Source/Paths.cpp rename to Tactility/Source/DeprecatedPaths.cpp index 1d9d06635..68d2782d3 100644 --- a/Tactility/Source/Paths.cpp +++ b/Tactility/Source/DeprecatedPaths.cpp @@ -1,4 +1,4 @@ -#include +#include #include "../../Modules/app-module/private/app/private/app_metadata_parsing_internal.h" diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 1b77a4ef3..024487da6 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -18,9 +18,9 @@ #include #include +#include #include #include -#include #include #include #include diff --git a/Tactility/Source/app/apphub/AppHubApp.cpp b/Tactility/Source/app/apphub/AppHubApp.cpp index 8521636f1..724c831cf 100644 --- a/Tactility/Source/app/apphub/AppHubApp.cpp +++ b/Tactility/Source/app/apphub/AppHubApp.cpp @@ -1,5 +1,5 @@ +#include #include -#include #include #include #include diff --git a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp index 9ea2b50ed..e6dbc4528 100644 --- a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp +++ b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp @@ -2,7 +2,7 @@ #include "app/metadata.h" -#include +#include #include #include #include diff --git a/Tactility/Source/app/boot/Boot.cpp b/Tactility/Source/app/boot/Boot.cpp index d3b681581..f80e23541 100644 --- a/Tactility/Source/app/boot/Boot.cpp +++ b/Tactility/Source/app/boot/Boot.cpp @@ -12,8 +12,8 @@ #include +#include #include -#include #include #include #include diff --git a/Tactility/Source/app/chat/ChatSettings.cpp b/Tactility/Source/app/chat/ChatSettings.cpp index 729e6bb15..4cfe01a0f 100644 --- a/Tactility/Source/app/chat/ChatSettings.cpp +++ b/Tactility/Source/app/chat/ChatSettings.cpp @@ -7,9 +7,9 @@ #include #include +#include #include #include -#include #include diff --git a/Tactility/Source/app/systeminfo/SystemInfo.cpp b/Tactility/Source/app/systeminfo/SystemInfo.cpp index a582e8e05..e63cf9105 100644 --- a/Tactility/Source/app/systeminfo/SystemInfo.cpp +++ b/Tactility/Source/app/systeminfo/SystemInfo.cpp @@ -1,6 +1,6 @@ #include "tactility/time.h" -#include +#include #include #include #include diff --git a/Tactility/Source/bluetooth/BluetoothPairedDevice.cpp b/Tactility/Source/bluetooth/BluetoothPairedDevice.cpp index c425b48aa..fccc67022 100644 --- a/Tactility/Source/bluetooth/BluetoothPairedDevice.cpp +++ b/Tactility/Source/bluetooth/BluetoothPairedDevice.cpp @@ -1,6 +1,6 @@ #include -#include "Tactility/Paths.h" +#include "Tactility/DeprecatedPaths.h" #include #include diff --git a/Tactility/Source/bluetooth/BluetoothSettings.cpp b/Tactility/Source/bluetooth/BluetoothSettings.cpp index 6bcb70a06..d2640a16d 100644 --- a/Tactility/Source/bluetooth/BluetoothSettings.cpp +++ b/Tactility/Source/bluetooth/BluetoothSettings.cpp @@ -1,9 +1,9 @@ #include +#include +#include #include #include -#include -#include #include namespace tt::bluetooth::settings { diff --git a/Tactility/Source/service/development/DevelopmentService.cpp b/Tactility/Source/service/development/DevelopmentService.cpp index 55cc1f091..530deb029 100644 --- a/Tactility/Source/service/development/DevelopmentService.cpp +++ b/Tactility/Source/service/development/DevelopmentService.cpp @@ -5,14 +5,14 @@ #include -#include +#include #include #include #include #include #include -#include #include +#include #include #include diff --git a/Tactility/Source/service/development/DevelopmentSettings.cpp b/Tactility/Source/service/development/DevelopmentSettings.cpp index 5913fad2b..676545130 100644 --- a/Tactility/Source/service/development/DevelopmentSettings.cpp +++ b/Tactility/Source/service/development/DevelopmentSettings.cpp @@ -1,7 +1,7 @@ #ifdef ESP_PLATFORM +#include #include #include -#include #include #include #include diff --git a/Tactility/Source/service/webserver/WebServerService.cpp b/Tactility/Source/service/webserver/WebServerService.cpp index 9c21792bc..97a551249 100644 --- a/Tactility/Source/service/webserver/WebServerService.cpp +++ b/Tactility/Source/service/webserver/WebServerService.cpp @@ -8,13 +8,13 @@ #include #include +#include +#include #include -#include +#include #include #include -#include -#include -#include +#include #include #include diff --git a/Tactility/Source/service/wifi/WifiBootSplashInit.cpp b/Tactility/Source/service/wifi/WifiBootSplashInit.cpp index de9b94e7d..36e2f8e00 100644 --- a/Tactility/Source/service/wifi/WifiBootSplashInit.cpp +++ b/Tactility/Source/service/wifi/WifiBootSplashInit.cpp @@ -9,7 +9,7 @@ #include #include -#include +#include #include #include diff --git a/Tactility/Source/settings/AudioSettings.cpp b/Tactility/Source/settings/AudioSettings.cpp index c2bc30ab8..89277b1b3 100644 --- a/Tactility/Source/settings/AudioSettings.cpp +++ b/Tactility/Source/settings/AudioSettings.cpp @@ -1,8 +1,8 @@ #include +#include #include #include -#include #include #include diff --git a/Tactility/Source/settings/BootSettings.cpp b/Tactility/Source/settings/BootSettings.cpp index 1933afd93..d6f5549b3 100644 --- a/Tactility/Source/settings/BootSettings.cpp +++ b/Tactility/Source/settings/BootSettings.cpp @@ -3,7 +3,7 @@ #include #include -#include +#include #include #include diff --git a/Tactility/Source/settings/SystemSettings.cpp b/Tactility/Source/settings/SystemSettings.cpp index e8763af99..ca797322a 100644 --- a/Tactility/Source/settings/SystemSettings.cpp +++ b/Tactility/Source/settings/SystemSettings.cpp @@ -5,7 +5,7 @@ #include #include -#include "Tactility/Paths.h" +#include "Tactility/DeprecatedPaths.h" #include diff --git a/Tactility/Source/settings/WebServerSettings.cpp b/Tactility/Source/settings/WebServerSettings.cpp index 99cc17d15..3a3817a28 100644 --- a/Tactility/Source/settings/WebServerSettings.cpp +++ b/Tactility/Source/settings/WebServerSettings.cpp @@ -1,7 +1,7 @@ -#include -#include +#include #include -#include +#include +#include #include From 3c783c1fb49ab100cfa9178163a95bbe917e1df3 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 22:58:27 +0200 Subject: [PATCH 16/31] Fixes --- Tactility/Source/app/screenshot/Screenshot.cpp | 2 +- Tactility/Source/settings/DisplaySettings.cpp | 2 +- Tactility/Source/settings/KeyboardSettings.cpp | 2 +- Tactility/Source/settings/TouchCalibrationSettings.cpp | 2 +- Tactility/Source/settings/TrackballSettings.cpp | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Tactility/Source/app/screenshot/Screenshot.cpp b/Tactility/Source/app/screenshot/Screenshot.cpp index 5532580fd..9f2377ec0 100644 --- a/Tactility/Source/app/screenshot/Screenshot.cpp +++ b/Tactility/Source/app/screenshot/Screenshot.cpp @@ -6,7 +6,7 @@ #include #include #include -#include +#include #include #include diff --git a/Tactility/Source/settings/DisplaySettings.cpp b/Tactility/Source/settings/DisplaySettings.cpp index 1ce13cb15..e0ac8b4b5 100644 --- a/Tactility/Source/settings/DisplaySettings.cpp +++ b/Tactility/Source/settings/DisplaySettings.cpp @@ -2,7 +2,7 @@ #include #include -#include +#include #include #include diff --git a/Tactility/Source/settings/KeyboardSettings.cpp b/Tactility/Source/settings/KeyboardSettings.cpp index ff77e1ea8..ef6f9b897 100644 --- a/Tactility/Source/settings/KeyboardSettings.cpp +++ b/Tactility/Source/settings/KeyboardSettings.cpp @@ -1,7 +1,7 @@ #include #include #include -#include +#include #include #include diff --git a/Tactility/Source/settings/TouchCalibrationSettings.cpp b/Tactility/Source/settings/TouchCalibrationSettings.cpp index d83f742c4..bc323c008 100644 --- a/Tactility/Source/settings/TouchCalibrationSettings.cpp +++ b/Tactility/Source/settings/TouchCalibrationSettings.cpp @@ -2,7 +2,7 @@ #include #include -#include +#include #include #include diff --git a/Tactility/Source/settings/TrackballSettings.cpp b/Tactility/Source/settings/TrackballSettings.cpp index 8e6b617f0..938127f5e 100644 --- a/Tactility/Source/settings/TrackballSettings.cpp +++ b/Tactility/Source/settings/TrackballSettings.cpp @@ -1,7 +1,7 @@ #include #include #include -#include +#include #include #include From e02766c1a48300587528ae62a62a328754d4fcf5 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 23:04:58 +0200 Subject: [PATCH 17/31] Fix for syntax --- Tests/SdkIntegration/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SdkIntegration/CMakeLists.txt b/Tests/SdkIntegration/CMakeLists.txt index 6bae4727c..23669c5dc 100644 --- a/Tests/SdkIntegration/CMakeLists.txt +++ b/Tests/SdkIntegration/CMakeLists.txt @@ -10,7 +10,7 @@ else() endif() include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH} ${TACTILITY_SDK_PATH}/Modules +set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH} ${TACTILITY_SDK_PATH}/Modules) project(SdkTest) tactility_project(SdkTest) From 52f90ae6a62318761471411d35323728efccd4ca Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 23:12:45 +0200 Subject: [PATCH 18/31] Fix --- Documentation/ideas.md | 1 + Tests/SdkIntegration/main/Source/main.c | 9 --------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/Documentation/ideas.md b/Documentation/ideas.md index e8940ad5d..4d5b07a04 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -12,6 +12,7 @@ ## Higher Priority +- Devices with a keyboard attached should always highlight the first widget (~Cardputer navigation issue), same for LV_INDEV_TYPE_ENCODER being present - Make it more clear to end-users that an SD card is required to run Tactility - Move "# Fix error "PSRAM space not enough for the Flash instructions" on boot:" fix from T-Deck and others to device.py - Make it possible to override stack size for an app via config file (loaded at boot), and make it possible to set preferred memory location (e.g. internal/external) diff --git a/Tests/SdkIntegration/main/Source/main.c b/Tests/SdkIntegration/main/Source/main.c index 5b0bcfb70..d4a9c3339 100644 --- a/Tests/SdkIntegration/main/Source/main.c +++ b/Tests/SdkIntegration/main/Source/main.c @@ -1,5 +1,3 @@ -#include - #include #include @@ -24,13 +22,6 @@ #include #include -#include -#include -#include -#include -#include -#include - static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Title"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); From 6b68e9f194bb39b93ef3428cfabcf4e2463d48ad Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 23:25:17 +0200 Subject: [PATCH 19/31] Update integration app --- Tests/SdkIntegration/main/Source/main.c | 58 ++++++++++++++----------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/Tests/SdkIntegration/main/Source/main.c b/Tests/SdkIntegration/main/Source/main.c index d4a9c3339..60dabf7d9 100644 --- a/Tests/SdkIntegration/main/Source/main.c +++ b/Tests/SdkIntegration/main/Source/main.c @@ -1,28 +1,15 @@ -#include +#include +#include +#include + +#include + +#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { +#include + +static void create_widgets(lv_obj_t* parent, void* userData) { lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Title"); lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); @@ -32,8 +19,27 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) { } int main(int argc, char* argv[]) { - tt_app_register((AppRegistration) { - .onShow = onShowApp - }); + AppInstanceId app_instance_id = app_scheduler_current_app_id(); + + struct AppEventSubscription sub = { .app_instance_id = app_instance_id }; + app_event_subscribe(&sub); + + WindowId window = window_manager_create(app_instance_id, create_widgets, NULL); + + bool should_close = false; + while (!should_close) { + struct AppEvent event; + if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) { + break; + } + if (event.type == APP_EVENT_CLOSE) { + app_manager_finish(app_instance_id); + should_close = true; + } + } + + window_manager_remove(window); + app_event_unsubscribe(&sub); + return 0; } From 9c54ac4944a1bb3e849d1eb38218e14b98217e21 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 8 Aug 2026 23:46:52 +0200 Subject: [PATCH 20/31] Fix for keyboard --- .../lvgl-module/include/lvgl/devices/keyboard.h | 9 ++++----- Modules/lvgl-module/source/devices/keyboard.cpp | 14 ++++++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Modules/lvgl-module/include/lvgl/devices/keyboard.h b/Modules/lvgl-module/include/lvgl/devices/keyboard.h index e64776763..d0c0af61e 100644 --- a/Modules/lvgl-module/include/lvgl/devices/keyboard.h +++ b/Modules/lvgl-module/include/lvgl/devices/keyboard.h @@ -50,11 +50,10 @@ void lvgl_keyboard_enable(lv_indev_t* indev); void lvgl_keyboard_disable(lv_indev_t* indev); /** - * @brief Wires a textarea up to the on-screen keyboard: shows it on focus, hides it on - * defocus/ready, and adds the textarea to the keyboard's navigation group. - * - * No-op if lvgl_software_keyboard_is_enabled() is false (i.e. a hardware keyboard is present). - * + * @brief Adds the textarea to the shared keyboard navigation group (so any keypad indev - + * hardware or on-screen - can type into it once it's focused), and, only when + * lvgl_software_keyboard_is_enabled() is true (i.e. no hardware keyboard is present), wires it + * up to show/hide the on-screen keyboard on focus/defocus/ready. * @warning Caller must hold the LVGL lock. * @param[in] keyboard the on-screen keyboard to associate with the textarea * @param[in] textarea the lv_textarea_t object to wire up diff --git a/Modules/lvgl-module/source/devices/keyboard.cpp b/Modules/lvgl-module/source/devices/keyboard.cpp index 24ad2d29d..bdc08780a 100644 --- a/Modules/lvgl-module/source/devices/keyboard.cpp +++ b/Modules/lvgl-module/source/devices/keyboard.cpp @@ -177,16 +177,22 @@ LvglSoftwareKeyboard* lvgl_software_keyboard_get_last() { } void lvgl_keyboard_add_textarea(LvglSoftwareKeyboard* keyboard, lv_obj_t* textarea) { + // Only the on-screen keyboard's show/hide wiring is specific to "no hardware keyboard" + // mode. Group membership must NOT be gated on it: a hardware keypad indev (see + // lvgl_keyboard_enable()/lvgl_software_keyboard_activate()) is bound to keyboard_group + // regardless of whether a software keyboard is in use, so skipping lv_group_add_obj() + // here left every textarea unreachable from a hardware keyboard - it was never a member + // of the group its indev delivers key events through. if (lvgl_software_keyboard_is_enabled()) { lv_obj_add_event_cb(textarea, textarea_show_keyboard, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(textarea, textarea_hide_keyboard, LV_EVENT_DEFOCUSED, nullptr); lv_obj_add_event_cb(textarea, textarea_hide_keyboard, LV_EVENT_READY, nullptr); + } - // lv_obj_t auto-remove themselves from the group when they are destroyed (last checked in LVGL 8.3) - lv_group_add_obj(keyboard_group, textarea); + // lv_obj_t auto-remove themselves from the group when they are destroyed (last checked in LVGL 8.3) + lv_group_add_obj(keyboard_group, textarea); - lvgl_software_keyboard_activate(keyboard); - } + lvgl_software_keyboard_activate(keyboard); } void lvgl_software_keyboard_activate(LvglSoftwareKeyboard* keyboard) { From 24860be6719cd89a2004c07b39a87d60dc9a8130 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 00:00:16 +0200 Subject: [PATCH 21/31] Cleanup --- .../include/lvgl_window_manager/window_manager.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h index 957be5a5c..aee01d8b8 100644 --- a/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h +++ b/Modules/lvgl-window-manager-module/include/lvgl_window_manager/window_manager.h @@ -1,14 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once -#include "../../../app-module/include/app/instance.h" - +#include #include #include #include -#include #include From e618086590d92f0dd384a87d01a35cf73ab53706 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 00:00:26 +0200 Subject: [PATCH 22/31] Cleanup --- Tactility/Source/app/boot/Boot.cpp | 46 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/Tactility/Source/app/boot/Boot.cpp b/Tactility/Source/app/boot/Boot.cpp index f80e23541..49b299c5b 100644 --- a/Tactility/Source/app/boot/Boot.cpp +++ b/Tactility/Source/app/boot/Boot.cpp @@ -68,34 +68,39 @@ std::string getBootAssetsPath(const std::string& childPath) { } void setupDisplay() { + // TODO: Support for multiple displays + Device* display = nullptr; - if (device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE) { - Device* backlight; - if (display_get_backlight(display, &backlight) == ERROR_NONE) { - if (!device_is_ready(backlight)) { - if (device_start(backlight) != ERROR_NONE) { - LOG_E(TAG, "Failed to start %s", backlight->name); - } - } + if (device_get_first_by_type(&DISPLAY_TYPE, &display) != ERROR_NONE) { + LOG_I(TAG, "No kernel display"); + return; + } - settings::display::DisplaySettings settings; - if (settings::display::load(settings)) { - } else { - settings = settings::display::getDefault(); + // Set backlight brightness + Device* backlight; + if (display_get_backlight(display, &backlight) == ERROR_NONE) { + if (!device_is_ready(backlight)) { + if (device_start(backlight) != ERROR_NONE) { + LOG_E(TAG, "Failed to start %s", backlight->name); } + } - if (backlight_set_brightness(backlight, settings.backlightDuty) == ERROR_NONE) { - LOG_I(TAG, "Backlight for %s set to %d", display->name, settings.backlightDuty); - } else { - LOG_E(TAG, "Failed to set brightness of %s", backlight->name); - } + settings::display::DisplaySettings settings; + if (settings::display::load(settings)) { + } else { + settings = settings::display::getDefault(); + } + + if (backlight_set_brightness(backlight, settings.backlightDuty) == ERROR_NONE) { + LOG_I(TAG, "Backlight for %s set to %d", display->name, settings.backlightDuty); } else { - LOG_I(TAG, "No backlight for %s", display->name); + LOG_E(TAG, "Failed to set brightness of %s", backlight->name); } - device_put(display); } else { - LOG_I(TAG, "No kernel display"); + LOG_I(TAG, "No backlight for %s", display->name); } + + device_put(display); } bool setupUsbBootMode() { @@ -253,7 +258,6 @@ void runBootSequence(TickType_t startTime) { // This works with 5 ms on a T-Lora Pager, so we give it 10 ms to be safe delay_millis(10); - // TODO: Support for multiple displays LOG_I(TAG, "Setup display"); setupDisplay(); LOG_I(TAG, "Prepare file systems"); From 884a008333a1567355db960c3effc72795420b81 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 00:06:06 +0200 Subject: [PATCH 23/31] Fix Setup --- Tactility/Source/app/setup/Setup.cpp | 34 ++++++++++---------------- TactilityKernel/source/preferences.cpp | 9 ++++++- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index 0998163d7..1a0ca97e1 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -1,7 +1,9 @@ #include + #include #include #include +#include #include #include @@ -10,8 +12,8 @@ #include +#include #include -#include #include #include @@ -32,17 +34,16 @@ namespace tt::app::setup { extern const ::AppManifest manifest; -constexpr auto* PREFERENCES_NAMESPACE = "setup"; -constexpr auto* PREFERENCES_KEY_COMPLETED = "completed"; +constexpr auto* TAG = "setup"; namespace { -bool getPreferencesPath(std::string& outPath) { +bool getCompletedMarkerPath(std::string& outPath) { char root[128]; if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { return false; } - outPath = std::string(root) + "/" + PREFERENCES_NAMESPACE + ".properties"; + outPath = std::string(root) + "/.setup_complete"; return true; } @@ -50,32 +51,23 @@ bool getPreferencesPath(std::string& outPath) { bool isCompleted() { std::string path; - if (!getPreferencesPath(path)) { - return false; - } - Preferences* preferences = preferences_open(path.c_str()); - if (preferences == nullptr) { + if (!getCompletedMarkerPath(path)) { + LOG_E(TAG, "Setup path not found"); return false; } - bool completed = false; - preferences_opt_bool(preferences, PREFERENCES_KEY_COMPLETED, &completed); - preferences_close(preferences); - return completed; + file::FileMutexGuard guard(path); + return file::isFile(path); } namespace { void markCompleted() { std::string path; - if (!getPreferencesPath(path)) { - return; - } - Preferences* preferences = preferences_open(path.c_str()); - if (preferences == nullptr) { + if (!getCompletedMarkerPath(path)) { return; } - preferences_put_bool(preferences, PREFERENCES_KEY_COMPLETED, true); - preferences_close(preferences); + file::FileMutexGuard guard(path); + file::writeString(path, ""); } enum class Phase { diff --git a/TactilityKernel/source/preferences.cpp b/TactilityKernel/source/preferences.cpp index 555b36f78..46af00f2d 100644 --- a/TactilityKernel/source/preferences.cpp +++ b/TactilityKernel/source/preferences.cpp @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 #include -#include + +#include #include +#include #include #include @@ -15,6 +17,8 @@ namespace { +constexpr auto* TAG = "preferences"; + // Escapes '\\' and '\n' so a string value can never break properties_file's one-entry-per-line // on-disk format, regardless of its content. std::string escape(const std::string& value) { @@ -176,16 +180,19 @@ extern "C" { Preferences* preferences_open(const char* path) { std::string directory = parent_directory(path); if (!directory.empty() && !ensure_directory_recursive(directory)) { + LOG_E(TAG, "Directory not found: %s", directory.c_str()); return nullptr; } PropertiesFile* file = properties_file_open(path); if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", path); return nullptr; } auto* preferences = new (std::nothrow) Preferences { file }; if (preferences == nullptr) { + LOG_E(TAG, "Out of memory"); properties_file_close(file); return nullptr; } From c5a8c99597410945f2360cb59983e943168a0029 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 00:09:44 +0200 Subject: [PATCH 24/31] Fix for file overwrite --- TactilityKernel/source/properties_file.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TactilityKernel/source/properties_file.cpp b/TactilityKernel/source/properties_file.cpp index 5194b7d22..e736cd0f7 100644 --- a/TactilityKernel/source/properties_file.cpp +++ b/TactilityKernel/source/properties_file.cpp @@ -150,6 +150,10 @@ bool save_to_file(const PropertiesFile* file) { return false; } + // rename() may not overwrite an existing destination on some filesystems (e.g. FAT on + // ESP32), so remove it first; this is best-effort and ignored if the path doesn't exist yet. + std::remove(file->path.c_str()); + if (std::rename(temp_path.c_str(), file->path.c_str()) != 0) { LOG_E(TAG, "Failed to replace %s", file->path.c_str()); std::remove(temp_path.c_str()); From 07a571f68dd465665f30998e8d2c2726839d23e8 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 11:51:08 +0200 Subject: [PATCH 25/31] Fix for installing and running apps --- Modules/app-module/source/app_install.cpp | 12 +++++++++++- .../service/development/DevelopmentService.cpp | 6 +++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Modules/app-module/source/app_install.cpp b/Modules/app-module/source/app_install.cpp index bcbd6c8db..5202cb07c 100644 --- a/Modules/app-module/source/app_install.cpp +++ b/Modules/app-module/source/app_install.cpp @@ -231,8 +231,16 @@ error_t register_installed_app_locked(const std::string& app_dir_path, const App .flags = 0, }; + // Belt-and-braces: app_install()'s earlier app_manager_remove() call is meant to have + // already cleared any stale registration for this id (e.g. left over from + // app_manager_install_path_scan()'s separate registry), but that call happens before the + // tarball is even extracted - remove once more, right before add, so a duplicate id can + // never turn a filesystem-level install success into a reported failure. + app_manager_remove(record->id.c_str()); + error_t add_result = app_manager_add(&record->manifest); if (add_result != ERROR_NONE) { + LOG_E(TAG, "Failed to register app '%s': %s", record->id.c_str(), error_to_string(add_result)); return add_result; } @@ -362,7 +370,9 @@ error_t app_install(const char* source_path) { // uninstall_locked() doesn't know about. Clear the app-manager registration unconditionally // too, or app_manager_add() below rejects the re-add as a duplicate. uninstall_locked(metadata.app_id); - if (app_manager_remove(metadata.app_id) != ERROR_NONE) { + + error_t remove_result = app_manager_remove(metadata.app_id); + if (remove_result != ERROR_NONE && remove_result != ERROR_NOT_FOUND) { LOG_E(TAG, "Install failed: failed to remove existing installation"); mutex_unlock(®istry.mutex); delete_recursively(staging_path); diff --git a/Tactility/Source/service/development/DevelopmentService.cpp b/Tactility/Source/service/development/DevelopmentService.cpp index 530deb029..0edd78493 100644 --- a/Tactility/Source/service/development/DevelopmentService.cpp +++ b/Tactility/Source/service/development/DevelopmentService.cpp @@ -113,7 +113,7 @@ esp_err_t DevelopmentService::handleAppRun(httpd_req_t* request) { } } - app_manager_start(app_id, &instance_id); + app_manager_start(id_key_pos->second.c_str(), &instance_id); LOG_I(TAG, "[200] /app/run %s", id_key_pos->second.c_str()); httpd_resp_send(request, nullptr, 0); @@ -193,7 +193,7 @@ esp_err_t DevelopmentService::handleAppInstall(httpd_req_t* request) { LOG_W(TAG, "We have more bytes at the end of the request parsing?!"); } - if (!app_install(file_path.c_str())) { + if (app_install(file_path.c_str()) != ERROR_NONE) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to install"); return ESP_FAIL; } @@ -231,7 +231,7 @@ esp_err_t DevelopmentService::handleAppUninstall(httpd_req_t* request) { return ESP_OK; } - if (app_uninstall(id_key_pos->second.c_str())) { + if (app_uninstall(id_key_pos->second.c_str()) == ERROR_NONE) { LOG_I(TAG, "[200] /app/uninstall %s", id_key_pos->second.c_str()); httpd_resp_send(request, nullptr, 0); return ESP_OK; From 52c1ec7060c31b4316dfe846bccc746331f24f65 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 11:51:11 +0200 Subject: [PATCH 26/31] Add missing symbols --- Modules/app-module/source/symbols.cpp | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Modules/app-module/source/symbols.cpp b/Modules/app-module/source/symbols.cpp index a6d00b2b6..324f0606f 100644 --- a/Modules/app-module/source/symbols.cpp +++ b/Modules/app-module/source/symbols.cpp @@ -2,7 +2,8 @@ #include #include #include -#include +#include +#include #include #include @@ -15,13 +16,15 @@ extern "C" { extern ServiceManifest app_internal_loader_service_manifest; const ModuleSymbol app_module_symbols[] = { - // app/scheduler - DEFINE_MODULE_SYMBOL(app_scheduler_current_app_id), // app/event DEFINE_MODULE_SYMBOL(app_event_subscribe), DEFINE_MODULE_SYMBOL(app_event_unsubscribe), DEFINE_MODULE_SYMBOL(app_event_emit), DEFINE_MODULE_SYMBOL(app_event_await), + // app/install + DEFINE_MODULE_SYMBOL(app_get_install_path), + DEFINE_MODULE_SYMBOL(app_install), + DEFINE_MODULE_SYMBOL(app_uninstall), // app/manager DEFINE_MODULE_SYMBOL(app_manager_start), DEFINE_MODULE_SYMBOL(app_manager_start_with_parameters), @@ -37,10 +40,15 @@ const ModuleSymbol app_module_symbols[] = { DEFINE_MODULE_SYMBOL(app_manager_get_topmost_app_id), DEFINE_MODULE_SYMBOL(app_manager_install_path_add), DEFINE_MODULE_SYMBOL(app_manager_install_path_scan), - // app/install - DEFINE_MODULE_SYMBOL(app_get_install_path), - DEFINE_MODULE_SYMBOL(app_install), - DEFINE_MODULE_SYMBOL(app_uninstall), + // app/metadata + DEFINE_MODULE_SYMBOL(app_metadata_parse), + // app/paths + DEFINE_MODULE_SYMBOL(app_paths_get_user_data_directory), + DEFINE_MODULE_SYMBOL(app_paths_get_user_data_path), + DEFINE_MODULE_SYMBOL(app_paths_get_assets_directory), + DEFINE_MODULE_SYMBOL(app_paths_get_assets_path), + // app/scheduler + DEFINE_MODULE_SYMBOL(app_scheduler_current_app_id), // terminator MODULE_SYMBOL_TERMINATOR }; From 0e8d64244877850a78299e6471ae2670d48f79e1 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 14:44:11 +0200 Subject: [PATCH 27/31] Fixes and improvements --- .../Source/app/development/Development.cpp | 22 ++++++---- Tactility/Source/file/FileMutexLvgl.cpp | 24 +++++++++++ Tactility/Source/network/HttpdReq.cpp | 41 ++++++++++++++++--- .../Source/service/wifi/WifiApSettings.cpp | 5 +++ 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/Tactility/Source/app/development/Development.cpp b/Tactility/Source/app/development/Development.cpp index 2ada04271..acc4c0f63 100644 --- a/Tactility/Source/app/development/Development.cpp +++ b/Tactility/Source/app/development/Development.cpp @@ -175,19 +175,25 @@ int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) { return 0; } - ctx.timer = std::make_unique(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [&ctx] { - if (lvgl_is_running()) { - lvgl_lock(); - updateViewState(&ctx); - lvgl_unlock(); - } - }); - AppEventSubscription sub {}; sub.app_instance_id = appInstanceId; app_event_subscribe(&sub); WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); + + ctx.timer = std::make_unique(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [&ctx, window] { + if (lvgl_is_running()) { + lvgl_lock(); + // Widgets only exist while this window is topmost - skip otherwise. Another app + // (started non-modally, e.g. via app_manager_start()) can bury this window without + // stopping this instance or notifying it; window_manager deletes a buried window's + // widgets, so touching ctx->statusLabel here would use-after-free it. + if (window_manager_get_state(window) == WINDOW_STATE_GRANTED) { + updateViewState(&ctx); + } + lvgl_unlock(); + } + }); ctx.timer->start(); bool shouldClose = false; diff --git a/Tactility/Source/file/FileMutexLvgl.cpp b/Tactility/Source/file/FileMutexLvgl.cpp index e22fc8ed7..f8df9430b 100644 --- a/Tactility/Source/file/FileMutexLvgl.cpp +++ b/Tactility/Source/file/FileMutexLvgl.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -75,6 +76,29 @@ void initFileMutexForLvgl() { return true; }); + + // SDMMC-backed SD cards aren't parented under SPI_CONTROLLER_TYPE, so the pass above never + // sees them - but on some chips (classic ESP32) SDMMC and SPI still contend for DMA/bus + // access. Lock every SD card mount if a display exists anywhere, regardless of bus topology. + if (!device_exists_of_type(&DISPLAY_TYPE)) { + return; + } + + file_system_for_each(nullptr, [](FileSystem* fs, void* context) { + char mount_path[64]; + if (file_system_get_path(fs, mount_path, sizeof(mount_path)) != ERROR_NONE) { + return true; + } + + auto* owner = file_system_get_owner(fs); + if (owner == nullptr || device_get_type(owner) != &SDCARD_TYPE) { + return true; + } + + LOG_I(TAG, "Adding file mutex for %s (SD card) - a display is present and may contend for bus/DMA resources", mount_path); + file_mutex_register(&lvgl_mutex, mount_path); + return true; + }); } } diff --git a/Tactility/Source/network/HttpdReq.cpp b/Tactility/Source/network/HttpdReq.cpp index d575f4ae0..3569480fc 100644 --- a/Tactility/Source/network/HttpdReq.cpp +++ b/Tactility/Source/network/HttpdReq.cpp @@ -1,8 +1,8 @@ #include #include -#include #include +#include #include #include @@ -186,30 +186,59 @@ size_t receiveFile(httpd_req_t* request, size_t length, const std::string& fileP char buffer[BUFFER_SIZE]; size_t bytes_received = 0; - file::FileMutexGuard guard(filePath); + // Locked only around each actual disk I/O call below, not across the httpd_req_recv() waits + // in between - this file's mutex may resolve to lvgl_lock() (see FileMutexLvgl.cpp), and + // holding that for the whole (potentially multi-second) network transfer starves LVGL's own + // task for the entire upload instead of just for each brief write. + FileMutex mutex {}; + file_mutex_get(&mutex, filePath.c_str()); + file_mutex_lock(&mutex); auto* file = fopen(filePath.c_str(), "wb"); + file_mutex_unlock(&mutex); if (file == nullptr) { LOG_E(TAG, "Failed to open file for writing: %s", filePath.c_str()); return 0; } + constexpr int MAX_TIMEOUT_RETRIES = 5; + int timeout_retries = 0; while (bytes_received < length) { auto expected_chunk_size = std::min(BUFFER_SIZE, length - bytes_received); - size_t receive_chunk_size = httpd_req_recv(request, buffer, expected_chunk_size); - if (receive_chunk_size <= 0) { + int received = httpd_req_recv(request, buffer, expected_chunk_size); + if (received == HTTPD_SOCK_ERR_TIMEOUT) { + // Timeout - retry with backoff, same as receiveByteArray(). A large file takes many + // more chunks (and much longer overall) than the small reads elsewhere in this file, + // so it's far more likely to hit at least one transient stall somewhere along the way. + timeout_retries++; + if (timeout_retries >= MAX_TIMEOUT_RETRIES) { + LOG_E(TAG, "Recv timeout after %d retries, wrote %zu/%zu bytes", timeout_retries, bytes_received, length); + break; + } + LOG_W(TAG, "Recv timeout, retry %d/%d", timeout_retries, MAX_TIMEOUT_RETRIES); + vTaskDelay(pdMS_TO_TICKS(100 * timeout_retries)); // Exponential backoff + continue; + } + if (received <= 0) { LOG_E(TAG, "Receive failed, got 0 bytes but expected %zu more", length - bytes_received); break; } - if (fwrite(buffer, 1, receive_chunk_size, file) != receive_chunk_size) { + timeout_retries = 0; + size_t receive_chunk_size = (size_t)received; + + file_mutex_lock(&mutex); + bool write_ok = fwrite(buffer, 1, receive_chunk_size, file) == receive_chunk_size; + file_mutex_unlock(&mutex); + if (!write_ok) { LOG_E(TAG, "Failed to write all bytes"); break; } bytes_received += receive_chunk_size; } - // Write file + file_mutex_lock(&mutex); fclose(file); + file_mutex_unlock(&mutex); return bytes_received; } diff --git a/Tactility/Source/service/wifi/WifiApSettings.cpp b/Tactility/Source/service/wifi/WifiApSettings.cpp index 4cf0ff7ce..0b6424d46 100644 --- a/Tactility/Source/service/wifi/WifiApSettings.cpp +++ b/Tactility/Source/service/wifi/WifiApSettings.cpp @@ -138,20 +138,24 @@ bool contains(const std::string& ssid) { bool load(const std::string& ssid, WifiApSettings& apSettings) { auto service_context = findServiceContext(); if (service_context == nullptr) { + LOG_E(TAG, "No service context"); return false; } const auto file_path = getApPropertiesFilePath(service_context->getPaths(), ssid); if (!file::isFile(file_path)) { + LOG_E(TAG, "Not a file: %s", file_path.c_str()); return false; } std::map map; if (!file::loadPropertiesFile(file_path, map)) { + LOG_E(TAG, "Failed to load properties from %s", file_path.c_str()); return false; } // SSID is required if (!map.contains(AP_PROPERTIES_KEY_SSID)) { + LOG_E(TAG, "File does not contain SSID: %s", file_path.c_str()); return false; } @@ -166,6 +170,7 @@ bool load(const std::string& ssid, WifiApSettings& apSettings) { } else if (decrypt(ssid, encrypted_password, password_decrypted)) { apSettings.password = password_decrypted; } else { + LOG_E(TAG, "Failed to decrypt password from %s", file_path.c_str()); return false; } } else { From 7aac44bc35f342974edfc79b7f8d912fbdb0be2f Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 15:08:46 +0200 Subject: [PATCH 28/31] Fix for touch --- Devices/m5stack-tab5/Source/devices/devices_v1.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Devices/m5stack-tab5/Source/devices/devices_v1.cpp b/Devices/m5stack-tab5/Source/devices/devices_v1.cpp index 280f2ef99..5ba6025f3 100644 --- a/Devices/m5stack-tab5/Source/devices/devices_v1.cpp +++ b/Devices/m5stack-tab5/Source/devices/devices_v1.cpp @@ -67,6 +67,11 @@ static void create_gt911_touch(Device* i2c0) { // Reset is pulsed via io_expander0 (detect.cpp's pulse_display_reset_pins), not a direct SoC GPIO. .pin_reset = GPIO_PIN_SPEC_NONE, .pin_interrupt = GPIO_PIN_SPEC_NONE, + .reset_pulses = 0, // no-op: pin_reset is NONE, so reset_controller_pin() skips anyway + .x_offset = 0, + .y_offset = 0, + .x_scale = 1000, + .y_scale = 1000, }; gt911_device.config = >911_config; From 8a75bc501e36866cb934841645f45a5eb2cf6f8b Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 16:17:42 +0200 Subject: [PATCH 29/31] Fixes for keyboard --- .../Source/devices/tab5_keyboard.cpp | 1 + .../lvgl-module/source/devices/keyboard.cpp | 16 +++++++++++++--- Tactility/Source/Tactility.cpp | 13 +++++++++++++ .../include/tactility/drivers/keyboard.h | 19 +++++++++++++++++++ TactilityKernel/source/drivers/keyboard.cpp | 10 ++++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp b/Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp index 3de918af2..25ef0d004 100644 --- a/Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp +++ b/Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp @@ -575,6 +575,7 @@ static error_t tab5_keyboard_read_key(Device* device, KeyboardKeyData* data) { static const KeyboardApi tab5_keyboard_api = { .read_key = tab5_keyboard_read_key, + .is_present = tab5_keyboard_is_attached, }; // Defined in module.cpp - this driver is registered directly by m5stack-tab5's own module, diff --git a/Modules/lvgl-module/source/devices/keyboard.cpp b/Modules/lvgl-module/source/devices/keyboard.cpp index bdc08780a..0774fcece 100644 --- a/Modules/lvgl-module/source/devices/keyboard.cpp +++ b/Modules/lvgl-module/source/devices/keyboard.cpp @@ -106,8 +106,11 @@ bool lvgl_hardware_keyboard_is_available() { return false; } + // TODO: Refactor the driver subsystem to so it does proper probing/releasing of such devices + // This work-around exists for the Tab5 keyboard driver. + bool present = keyboard_is_present(keyboard_device); device_put(keyboard_device); - return true; + return present; } void lvgl_hardware_keyboard_add_custom(lv_indev_t* indev) { @@ -137,9 +140,15 @@ static void textarea_show_keyboard(lv_event_t* event) { } static void textarea_hide_keyboard(lv_event_t* event) { - if (last_software_keyboard.object != nullptr) { - lvgl_software_keyboard_hide(&last_software_keyboard); + if (last_software_keyboard.object == nullptr) { + return; + } + // Only hide if the keyboard is actually bound to the textarea that triggered this + lv_obj_t* target = lv_event_get_current_target_obj(event); + if (lv_keyboard_get_textarea(last_software_keyboard.object) != target) { + return; } + lvgl_software_keyboard_hide(&last_software_keyboard); } void lvgl_software_keyboard_construct(LvglSoftwareKeyboard* keyboard, lv_obj_t* parent) { @@ -187,6 +196,7 @@ void lvgl_keyboard_add_textarea(LvglSoftwareKeyboard* keyboard, lv_obj_t* textar lv_obj_add_event_cb(textarea, textarea_show_keyboard, LV_EVENT_FOCUSED, nullptr); lv_obj_add_event_cb(textarea, textarea_hide_keyboard, LV_EVENT_DEFOCUSED, nullptr); lv_obj_add_event_cb(textarea, textarea_hide_keyboard, LV_EVENT_READY, nullptr); + lv_obj_add_event_cb(textarea, textarea_hide_keyboard, LV_EVENT_DELETE, nullptr); } // lv_obj_t auto-remove themselves from the group when they are destroyed (last checked in LVGL 8.3) diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 024487da6..769cd711c 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -42,6 +42,7 @@ #include #include +#include #include #include #include @@ -361,6 +362,9 @@ static void stopAppFromToolbar(lv_event_t*) { app_event_emit(topmost, &event); } +// The on-screen keyboard widget itself, constructed during windowManagerScreenInit +static LvglSoftwareKeyboard softwareKeyboard { .object = nullptr }; + static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) { lv_obj_t* vertical_container = lv_obj_create(root); lv_obj_set_size(vertical_container, LV_PCT(100), LV_PCT(100)); @@ -380,6 +384,11 @@ static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) { lv_obj_set_flex_grow(app_container, 1); lv_obj_set_flex_flow(app_container, LV_FLEX_FLOW_COLUMN); + // Parented to root (not app_container/vertical_container) so it overlays on top of + // everything, including the statusbar, regardless of which app is showing. Hidden until a + // focused textarea shows it (see lvgl_keyboard_add_textarea()/textarea_show_keyboard()). + lvgl_software_keyboard_construct(&softwareKeyboard, root); + return app_container; } @@ -440,6 +449,10 @@ static void onLvglStarted() { } static void onLvglStopped() { + if (softwareKeyboard.object != nullptr) { + lvgl_software_keyboard_destruct(&softwareKeyboard); + } + module_stop(&lvgl_window_manager_module); lvgl::stopUsbHidInput(); diff --git a/TactilityKernel/include/tactility/drivers/keyboard.h b/TactilityKernel/include/tactility/drivers/keyboard.h index f1390f22d..56386ba01 100644 --- a/TactilityKernel/include/tactility/drivers/keyboard.h +++ b/TactilityKernel/include/tactility/drivers/keyboard.h @@ -66,6 +66,17 @@ struct KeyboardApi { * @retval ERROR_NOT_SUPPORTED when this device has no backlight */ error_t (*get_backlight)(struct Device* device, struct Device** backlight_device); + + /** + * @brief Optional: reports whether the keyboard is physically present right now. Only + * meaningful for hot-pluggable/detachable keyboards (e.g. a removable accessory) whose + * kernel device is constructed and started once at boot regardless of physical attachment - + * leave NULL for a keyboard that's always physically present whenever its device is active + * (the common case; callers must treat NULL the same as "always present"). + * @param[in] device the keyboard device + * @return true if physically attached/present + */ + bool (*is_present)(struct Device* device); }; /** @@ -83,6 +94,14 @@ error_t keyboard_read_key(struct Device* device, struct KeyboardKeyData* data); */ error_t keyboard_get_backlight(struct Device* device, struct Device** backlight_device); +/** + * @brief Whether the keyboard device is physically present right now. True when the driver + * doesn't implement KeyboardApi::is_present (i.e. it's always physically present whenever its + * device is active) - see that field's doc comment. + * @param[in] device the keyboard device + */ +bool keyboard_is_present(struct Device* device); + extern const struct DeviceType KEYBOARD_TYPE; #ifdef __cplusplus diff --git a/TactilityKernel/source/drivers/keyboard.cpp b/TactilityKernel/source/drivers/keyboard.cpp index 8c14bcd28..3bc870259 100644 --- a/TactilityKernel/source/drivers/keyboard.cpp +++ b/TactilityKernel/source/drivers/keyboard.cpp @@ -28,6 +28,16 @@ error_t keyboard_get_backlight(Device* device, Device** backlight_device) { return KEYBOARD_DRIVER_API(driver)->get_backlight(device, backlight_device); } +bool keyboard_is_present(Device* device) { + const auto* driver = device_get_driver(device); + + if (KEYBOARD_DRIVER_API(driver)->is_present == nullptr) { + return true; + } + + return KEYBOARD_DRIVER_API(driver)->is_present(device); +} + const DeviceType KEYBOARD_TYPE { .name = "keyboard" }; From 9487441b975cb2fce87c800559e1114ca286b05e Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 17:06:03 +0200 Subject: [PATCH 30/31] Fix for duplicate wifi events on P4 --- .../source/drivers/esp32_wifi.cpp | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp index dfcddeb63..3f9f22035 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #if defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED) #include @@ -56,6 +57,15 @@ struct Esp32WifiCtx { esp_event_handler_instance_t wifiEventHandler = nullptr; esp_event_handler_instance_t ipEventHandler = nullptr; + // Dedup for WIFI_EVENT/IP_EVENT notifications: on the esp_hosted/Wi-Fi Remote transport + // (e.g. Tab5's P4 host + C6 co-processor), the RPC layer has been observed delivering the + // exact same event twice in a row (same base, same event_id, same millisecond - not two + // genuinely separate occurrences). Native WiFi doesn't exhibit this, but the handler is + // shared, so the guard applies unconditionally; it's a no-op for well-separated real events. + esp_event_base_t lastEventBase = nullptr; + int32_t lastEventId = -1; + TickType_t lastEventTick = 0; + Mutex callbackMutex{}; WifiCallbackEntry callbacks[WIFI_MAX_CALLBACKS] = {}; size_t callbackCount = 0; @@ -100,6 +110,20 @@ void fire_event(Esp32WifiCtx* ctx, WifiEvent event) { void on_wifi_or_ip_event(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { auto* ctx = static_cast(arg); + // See Esp32WifiCtx::lastEventBase/lastEventId/lastEventTick - collapse an immediate duplicate + // delivery of the same event (observed on the esp_hosted/Wi-Fi Remote transport) into one. + constexpr uint32_t DEDUP_WINDOW_MS = 50; // well under any real re-occurrence of the same event + TickType_t now = get_ticks(); + bool is_duplicate = event_base == ctx->lastEventBase && event_id == ctx->lastEventId && + (now - ctx->lastEventTick) <= millis_to_ticks(DEDUP_WINDOW_MS); + ctx->lastEventBase = event_base; + ctx->lastEventId = event_id; + ctx->lastEventTick = now; + if (is_duplicate) { + LOG_D(TAG, "Ignoring duplicate WiFi event %d", (int)event_id); + return; + } + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { mutex_lock(&ctx->mutex); bool was_pending = ctx->stationState == WIFI_STATION_STATE_CONNECTION_PENDING; From a9340ae345df0acfdda3b13b460f3d0f67c97a09 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 9 Aug 2026 17:21:55 +0200 Subject: [PATCH 31/31] Fix for resuming windows when lvgl was stopped and started again --- .../source/window_manager.cpp | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/Modules/lvgl-window-manager-module/source/window_manager.cpp b/Modules/lvgl-window-manager-module/source/window_manager.cpp index 94da9f451..0a42d429a 100644 --- a/Modules/lvgl-window-manager-module/source/window_manager.cpp +++ b/Modules/lvgl-window-manager-module/source/window_manager.cpp @@ -210,12 +210,45 @@ error_t window_manager_start(void) { return ERROR_RESOURCE; } + // A previous stop() (see its comment) may have left window records behind for an app that's + // still running (as opposed to a real full shutdown, where every app has already removed its + // own window before this runs, leaving the list empty). Rebuild the topmost one now, exactly + // like window_manager_remove()'s resurface path does when a buried window becomes topmost - + // otherwise that app's task just sits blocked in its own event loop forever with no window + // and no way to know it needs to rebuild one. + WindowCreateWidgetsFn top_create_widgets = nullptr; + void* top_user_data = nullptr; + WindowId top_id = 0; + bool has_top = false; + mutex_lock(&s.mutex); s.real_root_widget = real_widget; s.content_root_widget = content_widget; s.started = true; + if (!s.windows.empty()) { + top_create_widgets = s.windows.back().create_widgets; + top_user_data = s.windows.back().user_data; + top_id = s.windows.back().id; + has_top = true; + } mutex_unlock(&s.mutex); + if (has_top) { + lv_obj_t* new_widget = build_window_widget(content_widget, top_create_widgets, top_user_data); + + mutex_lock(&s.mutex); + bool still_topmost = !s.windows.empty() && s.windows.back().id == top_id; + if (still_topmost) { + s.top_widget = new_widget; + new_widget = nullptr; // consumed + } + mutex_unlock(&s.mutex); + + // Something else changed the window stack while we were building (e.g. a concurrent + // remove()) - discard what we just made. + delete_widget(new_widget); + } + mutex_unlock(&s.lifecycle_mutex); return ERROR_NONE; } @@ -234,8 +267,8 @@ error_t window_manager_stop(void) { return ERROR_NONE; } lv_obj_t* widget = s.real_root_widget; - // Claim every window's waiter before clearing - normally at most the topmost window's is - // ever set, but every window is being torn down here, so every one is checked. + // Claim every window's waiter before tearing down - normally at most the topmost window's + // is ever set, but every window's widget is being torn down here, so every one is checked. std::vector waiters; for (auto& window : s.windows) { if (auto* signal = claim_waiter_locked(window); signal != nullptr) { @@ -245,7 +278,15 @@ error_t window_manager_stop(void) { s.real_root_widget = nullptr; s.content_root_widget = nullptr; s.top_widget = nullptr; - s.windows.clear(); + // Deliberately NOT s.windows.clear(): this only tears down the LVGL widget tree, not the + // window records themselves. A real full shutdown (every app already removed its own window + // via window_manager_remove() before this runs) leaves the list empty anyway, so this is a + // no-op there. But a caller can also stop()/start() this module on its own, temporarily, + // while apps keep running underneath (e.g. an app borrowing the display/touch hardware + // directly) - those apps' tasks are still alive, blocked in their own event loops, with no + // way to know they need to call window_manager_create() again. Keeping the records lets + // window_manager_start() rebuild the topmost one automatically instead of leaving that app + // stuck with no window forever. s.started = false; mutex_unlock(&s.mutex);