Implement http-module (#637)

Create http-module and implement it in App Hub and App Hub Details apps.
This commit is contained in:
Ken Van Hoeylandt
2026-08-28 23:09:59 +02:00
committed by GitHub
parent d2442bedb4
commit bcbf18e363
27 changed files with 1265 additions and 77 deletions
+1
View File
@@ -174,6 +174,7 @@ def main():
add_module(target_path, "app-module") add_module(target_path, "app-module")
add_module(target_path, "crypt-module") add_module(target_path, "crypt-module")
add_module(target_path, "gps-module") add_module(target_path, "gps-module")
add_module(target_path, "http-module")
add_module(target_path, "lvgl-module") add_module(target_path, "lvgl-module")
add_module(target_path, "lvgl-window-manager-module") add_module(target_path, "lvgl-window-manager-module")
add_module(target_path, "service-module") add_module(target_path, "service-module")
@@ -11,6 +11,12 @@ CONFIG_RINGBUF_PLACE_FUNCTIONS_INTO_FLASH=y
# EmbedTLS # EmbedTLS
# Use TLS 1.2 because 1.3 conflicts with MbedTLS dynamic buffer # Use TLS 1.2 because 1.3 conflicts with MbedTLS dynamic buffer
CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y CONFIG_MBEDTLS_SSL_PROTO_TLS1_2=y
# Frees TLS IN/OUT buffers between reads/writes instead of holding them for the whole session.
# Internal-RAM-only devices need this: mbedtls_ssl_setup() otherwise fails with ALLOC_FAILED
# (-0x7F00).
# Keep MBEDTLS_SSL_IN_CONTENT_LEN at its 16384 default: servers may send TLS records up to that
# size, and a smaller buffer fails mid-transfer with MBEDTLS_ERR_SSL_INVALID_RECORD (-0x7200).
CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
# LVGL # LVGL
CONFIG_LV_USE_USER_DATA=y CONFIG_LV_USE_USER_DATA=y
CONFIG_LV_USE_FS_STDIO=y CONFIG_LV_USE_FS_STDIO=y
+1
View File
@@ -101,6 +101,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
add_subdirectory(Modules/lvgl-module) add_subdirectory(Modules/lvgl-module)
add_subdirectory(Modules/crypt-module) add_subdirectory(Modules/crypt-module)
add_subdirectory(Modules/gps-module) add_subdirectory(Modules/gps-module)
add_subdirectory(Modules/http-module)
add_subdirectory(Modules/service-module) add_subdirectory(Modules/service-module)
add_subdirectory(Modules/app-module) add_subdirectory(Modules/app-module)
add_subdirectory(Modules/lvgl-window-manager-module) add_subdirectory(Modules/lvgl-window-manager-module)
@@ -18,4 +18,6 @@ display.size=3.5"
display.shape=rectangle display.shape=rectangle
display.dpi=165 display.dpi=165
cdn.warningMessage=This device has display driver and memory issues. App Hub doesn't work.
lvgl.colorDepth=16 lvgl.colorDepth=16
@@ -16,4 +16,6 @@ display.dpi=165
touch.calibrationSupported=true touch.calibrationSupported=true
touch.calibrationRequired=false touch.calibrationRequired=false
cdn.warningMessage=This device has display driver and memory issues. App Hub doesn't work.
lvgl.colorDepth=16 lvgl.colorDepth=16
-6
View File
@@ -2,17 +2,12 @@
## Before release ## Before release
- Add `// SPDX-License-Identifier: GPL-3.0-only` and `// SPDX-License-Identifier: Apache-2.0` to individual files in the project
- Elecrow Basic & Advance 3.5" memory issue: not enough memory for App Hub
- App Hub crashes if you close it while an app is being installed
- Calculator bugs (see GitHub issue)
- Try out speed optimizations: https://docs.espressif.com/projects/esp-faq/en/latest/software-framework/peripherals/lcd.html - Try out speed optimizations: https://docs.espressif.com/projects/esp-faq/en/latest/software-framework/peripherals/lcd.html
(relates to CONFIG_ESP32S3_DATA_CACHE_LINE_64B that is in use for RGB displays via the `device.properties` fix/workaround) (relates to CONFIG_ESP32S3_DATA_CACHE_LINE_64B that is in use for RGB displays via the `device.properties` fix/workaround)
## Higher Priority ## Higher Priority
- Add tests for app stdin/stdout - Add tests for app stdin/stdout
- AppHubApp: Prevent download callbacks from accessing a destroyed view. Create a "download task" concept that emits events.
- CrashDiagnostics shouldn't show a QR when there's no callstack - CrashDiagnostics shouldn't show a QR when there's no callstack
- Apps currently have a `Context` object with an `appInstanceId` in it, purely for being able to close the app. - Apps currently have a `Context` object with an `appInstanceId` in it, purely for being able to close the app.
Change it so that the app has its own termination signal that it waits for in the loop, it should subscribe to the event group. Change it so that the app has its own termination signal that it waits for in the loop, it should subscribe to the event group.
@@ -80,7 +75,6 @@
- Mutex: Implement give/take from ISR support (works only for non-recursive ones) - Mutex: Implement give/take from ISR support (works only for non-recursive ones)
- Show a warning screen if firmware encryption or secure boot are off when saving WiFi credentials. - Show a warning screen if firmware encryption or secure boot are off when saving WiFi credentials.
- Remove flex_flow from app_container in Gui.cpp - Remove flex_flow from app_container in Gui.cpp
- ElfAppManifest: change name (remove "manifest" as it's confusing), remove icon and title, publish snapshot SDK on CDN
- Bug: CYD 2432S032C screen rotation fails due to touch driver issue - Bug: CYD 2432S032C screen rotation fails due to touch driver issue
- Calculator app should show regular text input field on non-touch devices that have a keyboard (Cardputer, T-Lora Pager) - Calculator app should show regular text input field on non-touch devices that have a keyboard (Cardputer, T-Lora Pager)
- Allow for WSAD keys to navigate LVGL (this is extra nice for cardputer, but just handy in general) - Allow for WSAD keys to navigate LVGL (this is extra nice for cardputer, but just handy in general)
+1
View File
@@ -100,6 +100,7 @@ else ()
app-module app-module
crypt-module crypt-module
gps-module gps-module
http-module
gps-generic-module gps-generic-module
gps-meshtastic-module gps-meshtastic-module
service-module service-module
+25
View File
@@ -0,0 +1,25 @@
cmake_minimum_required(VERSION 3.20)
include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake")
file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
list(APPEND REQUIRES_LIST
TactilityKernel
)
if (DEFINED ENV{ESP_IDF_VERSION})
list(APPEND REQUIRES_LIST
esp_http_client
)
list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_mock\\.cpp$")
else ()
list(FILTER SOURCE_FILES EXCLUDE REGEX ".*download_esp\\.cpp$")
endif ()
tactility_add_module(http-module
SRCS ${SOURCE_FILES}
PRIV_INCLUDE_DIRS private/
INCLUDE_DIRS include/
REQUIRES ${REQUIRES_LIST}
)
+195
View File
@@ -0,0 +1,195 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### 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.
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/error.h>
#ifdef __cplusplus
extern "C" {
#endif
/** Identifies how a download finished, as delivered to http_download_poll(). */
enum HttpDownloadEventType {
HTTP_DOWNLOAD_EVENT_SUCCESS, // no data
HTTP_DOWNLOAD_EVENT_ERROR, // struct HttpDownloadErrorEvent
HTTP_DOWNLOAD_EVENT_CANCELLED, // no data - see http_download_cancel()
};
#define HTTP_DOWNLOAD_ERROR_MESSAGE_MAX_LEN 64
/** Data for HTTP_DOWNLOAD_EVENT_ERROR. */
struct HttpDownloadErrorEvent {
char message[HTTP_DOWNLOAD_ERROR_MESSAGE_MAX_LEN];
};
/** Event delivered through http_download_poll() once a download's task finishes. */
struct HttpDownloadEvent {
enum HttpDownloadEventType type;
/** Microseconds since boot, from get_micros_since_boot(). */
uint64_t timestamp;
/** The server's HTTP response status code, or 0 if no response was ever received
* (e.g. failed to open the connection, or platform doesn't support downloads). */
int32_t status_code;
/** Valid only when type == HTTP_DOWNLOAD_EVENT_ERROR. */
struct HttpDownloadErrorEvent error;
};
/** Internal, refcounted state shared between a subscription and its download task - lets
* http_download_unsubscribe() sever the link safely at any time, even mid-download (see its
* own doc). Defined only in download.cpp; opaque here like Module::internal (tactility/module.h). */
struct HttpDownloadLink;
/**
* Caller-owned subscription node for one download's outcome, registered with
* http_download_subscribe() and polled with http_download_poll() - same subscribe/await/poll
* shape as TactilityKernel's system_event and app-module's app_event. Unlike those (which
* multiplex many emitters/subscribers by type or app instance), one subscription always belongs
* to exactly one download and receives exactly one terminal event.
* @warning Fields other than `bit` are for internal use only; do not read or write them directly.
*/
struct HttpDownloadSubscription {
/** Set by http_download_subscribe(). Read-only for the caller: OR it into a
* task_event_group_wait() mask (alongside other subscriptions sharing the same
* `internal.event_group`) to block on this subscription and other event sources with one
* call. */
uint32_t bit;
struct {
/** Caller-owned, borrowed; set by http_download_subscribe(). */
struct TaskEventGroup* event_group;
struct HttpDownloadEvent event;
bool pending;
struct HttpDownloadLink* link;
} internal;
};
/**
* Register a poll subscription for one download's outcome.
* @warning Does not work in ISR context.
* @warning Must be called before http_download_start(), so the download task can never finish
* before a subscriber exists to notify.
* @param[in,out] sub subscription to register; caller owns the storage and must keep it alive
* (and stationary) until http_download_unsubscribe()
* @param[in] event_group caller-owned group to wait on; must outlive @a sub. To block for the
* outcome, call task_event_group_wait()/task_event_group_wait_any() on this group (OR sub->bit
* into the mask, or use _wait_any() to include every subscription sharing it), then poll with
* http_download_poll().
* @retval ERROR_NONE on success
* @retval ERROR_RESOURCE @a event_group has no free bits left to claim; @a sub was not registered
* @retval ERROR_OUT_OF_MEMORY failed to allocate @a sub's internal link; @a sub was not registered
*/
error_t http_download_subscribe(struct HttpDownloadSubscription* sub, struct TaskEventGroup* event_group);
/**
* Remove a previously registered subscription. Safe to call at any time, including while the
* download is still in flight (e.g. right after http_download_cancel()) - the download task
* never touches @a sub or its event_group again once this returns, so both may be destructed
* immediately afterward without waiting for the download to actually finish.
* @warning Does not work in ISR context.
* @return ERROR_NONE on success, ERROR_NOT_FOUND if @a sub isn't currently subscribed
*/
error_t http_download_unsubscribe(struct HttpDownloadSubscription* sub);
/**
* Non-blocking: check whether the download's terminal event has arrived.
* @warning Never blocks. To wait, block in task_event_group_wait()/task_event_group_wait_any()
* on @a sub's bit first (see http_download_subscribe()), then call this.
* @param[in,out] sub subscription to poll, as passed to http_download_subscribe()
* @param[out] out_event set to the download's outcome
* @retval ERROR_NONE the terminal event arrived - it is copied into @a out_event
* @retval ERROR_TIMEOUT the download hasn't finished yet
*/
error_t http_download_poll(struct HttpDownloadSubscription* sub, struct HttpDownloadEvent* out_event);
/**
* Starts a download on its own dedicated task: issues a GET request for @a url, verifying the
* server against the PEM certificate at @a cert_path, and writes the response body to
* @a target_path.
* @param[in] url the URL to download
* @param[in] cert_path path to a PEM certificate file used to verify the server
* @param[in] target_path path to write the downloaded file to
* @param[in] sub subscription to notify on completion, already registered via
* http_download_subscribe(); must stay alive (and stationary) at least until
* http_download_unsubscribe() is called
* @retval ERROR_NONE the download task was started
* @retval ERROR_INVALID_ARGUMENT @a sub is not currently subscribed
* @retval ERROR_OUT_OF_MEMORY failed to allocate the download task
*/
error_t http_download_start(
const char* url,
const char* cert_path,
const char* target_path,
struct HttpDownloadSubscription* sub
);
/**
* Request cancellation of @a sub's in-flight download. Best-effort and asynchronous: the
* download task only checks for this periodically (between I/O steps), so it keeps running for
* a short while after this returns. If you still want the outcome, poll as usual - it finishes
* with HTTP_DOWNLOAD_EVENT_CANCELLED. If you don't, http_download_unsubscribe() may be called
* right away instead of waiting for that - see its own doc.
* @param[in,out] sub subscription for the download to cancel, as passed to
* http_download_subscribe()
* @retval ERROR_NONE the cancellation request was recorded
* @retval ERROR_INVALID_STATE @a sub is not currently subscribed
*/
error_t http_download_cancel(struct HttpDownloadSubscription* sub);
#ifdef __cplusplus
}
#endif
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/module.h>
#ifdef __cplusplus
extern "C" {
#endif
extern struct Module http_module;
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <http/download.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/time.h>
#include <cstdio>
#include <string>
// Shared between a subscription and its download task, so http_download_unsubscribe() can sever
// the link at any time without racing the task's access to the subscription/event_group.
// Refcounted via `owners`: starts at 1 for the subscriber, gains 1 when http_download_start()
// spawns the task, and whichever side releases last deletes this.
// `mutex` also guards the subscription's `internal.event`/`pending`/`event_group`/`bit`. The task
// only touches those while `subscribed` is true, under this same lock, so that check is a hard
// guarantee those fields are still live.
struct HttpDownloadLink {
Mutex mutex {};
bool subscribed = true;
bool cancel_requested = false;
int owners = 1;
HttpDownloadLink() { mutex_construct(&mutex); }
~HttpDownloadLink() { mutex_destruct(&mutex); }
};
inline HttpDownloadEvent http_download_make_error_event(const char* message, int32_t status_code = 0) {
HttpDownloadEvent event {};
event.type = HTTP_DOWNLOAD_EVENT_ERROR;
event.timestamp = get_micros_since_boot();
event.status_code = status_code;
snprintf(event.error.message, sizeof(event.error.message), "%s", message);
return event;
}
inline HttpDownloadEvent http_download_make_success_event(int32_t status_code) {
HttpDownloadEvent event {};
event.type = HTTP_DOWNLOAD_EVENT_SUCCESS;
event.timestamp = get_micros_since_boot();
event.status_code = status_code;
return event;
}
inline HttpDownloadEvent http_download_make_cancelled_event(int32_t status_code = 0) {
HttpDownloadEvent event {};
event.type = HTTP_DOWNLOAD_EVENT_CANCELLED;
event.timestamp = get_micros_since_boot();
event.status_code = status_code;
return event;
}
inline bool http_download_is_cancelled(HttpDownloadLink* link) {
mutex_lock(&link->mutex);
bool cancelled = link->cancel_requested;
mutex_unlock(&link->mutex);
return cancelled;
}
/** Implemented in download_esp.cpp (ESP_PLATFORM) or download_mock.cpp (otherwise). */
HttpDownloadEvent http_download_run(
const std::string& url,
const std::string& certPath,
const std::string& targetPath,
HttpDownloadLink* link
);
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: Apache-2.0
#include <http/download.h>
#include <http/private/download.h>
#include <tactility/concurrent/mutex.h>
#include <tactility/freertos/task.h>
#include <tactility/log.h>
#include <new>
#include <string>
namespace {
constexpr auto* TAG = "http-download";
constexpr size_t DOWNLOAD_TASK_STACK_DEPTH = 4608 / sizeof(StackType_t);
struct DownloadContext {
std::string url;
std::string certPath;
std::string targetPath;
HttpDownloadSubscription* subscription;
HttpDownloadLink* link;
};
// Delivers `event` to `sub` if the subscriber hasn't unsubscribed in the meantime, then releases
// this task's share of `link`, deleting it if the subscriber already released theirs.
void finish(HttpDownloadLink* link, HttpDownloadSubscription* sub, const HttpDownloadEvent& event) {
mutex_lock(&link->mutex);
if (link->subscribed) {
sub->internal.event = event;
sub->internal.pending = true;
task_event_group_signal(sub->internal.event_group, sub->bit);
}
bool last = (--link->owners == 0);
mutex_unlock(&link->mutex);
if (last) {
delete link;
}
}
void download_task_main(void* raw_context) {
auto* context = static_cast<DownloadContext*>(raw_context);
LOG_I(TAG, "Downloading %s to %s", context->url.c_str(), context->targetPath.c_str());
auto event = http_download_run(context->url, context->certPath, context->targetPath, context->link);
if (event.type == HTTP_DOWNLOAD_EVENT_ERROR) {
LOG_E(TAG, "Download of %s failed: %s", context->url.c_str(), event.error.message);
} else if (event.type == HTTP_DOWNLOAD_EVENT_CANCELLED) {
LOG_I(TAG, "Download of %s cancelled", context->url.c_str());
} else {
LOG_I(TAG, "Downloaded %s to %s", context->url.c_str(), context->targetPath.c_str());
}
finish(context->link, context->subscription, event);
delete context;
vTaskDelete(nullptr);
}
}
extern "C" {
error_t http_download_subscribe(HttpDownloadSubscription* sub, TaskEventGroup* event_group) {
uint32_t bit;
error_t claim_result = task_event_group_claim_bit(event_group, &bit);
if (claim_result != ERROR_NONE) {
return claim_result;
}
auto* link = new (std::nothrow) HttpDownloadLink();
if (link == nullptr) {
task_event_group_release_bit(event_group, bit);
return ERROR_OUT_OF_MEMORY;
}
sub->bit = bit;
sub->internal.event_group = event_group;
sub->internal.pending = false;
sub->internal.link = link;
return ERROR_NONE;
}
error_t http_download_unsubscribe(HttpDownloadSubscription* sub) {
HttpDownloadLink* link = sub->internal.link;
if (link == nullptr) {
return ERROR_NOT_FOUND;
}
// Caller-exclusive: the task never reads this field, so it's safe to clear without the lock.
sub->internal.link = nullptr;
TaskEventGroup* event_group;
uint32_t bit;
mutex_lock(&link->mutex);
// Read/clear under the lock: finish() reads these same fields under that lock too,
// while `subscribed` is true, so this must not race it.
event_group = sub->internal.event_group;
bit = sub->bit;
sub->internal.event_group = nullptr;
link->subscribed = false;
bool last = (--link->owners == 0);
mutex_unlock(&link->mutex);
// Safe outside the lock: the critical section above already decided whether finish() will
// ever signal this bit, so nothing will touch it again from here on.
task_event_group_release_bit(event_group, bit);
if (last) {
delete link;
}
return ERROR_NONE;
}
error_t http_download_poll(HttpDownloadSubscription* sub, HttpDownloadEvent* out_event) {
HttpDownloadLink* link = sub->internal.link;
if (link == nullptr) {
return ERROR_TIMEOUT;
}
mutex_lock(&link->mutex);
bool pending = sub->internal.pending;
if (pending) {
*out_event = sub->internal.event;
sub->internal.pending = false;
}
mutex_unlock(&link->mutex);
return pending ? ERROR_NONE : ERROR_TIMEOUT;
}
error_t http_download_start(
const char* url,
const char* cert_path,
const char* target_path,
HttpDownloadSubscription* sub
) {
HttpDownloadLink* link = sub->internal.link;
if (link == nullptr) {
return ERROR_INVALID_ARGUMENT;
}
auto* context = new(std::nothrow) DownloadContext {
.url = url,
.certPath = cert_path,
.targetPath = target_path,
.subscription = sub,
.link = link
};
if (context == nullptr) {
return ERROR_OUT_OF_MEMORY;
}
mutex_lock(&link->mutex);
link->owners++; // the task's own share, released by finish()
mutex_unlock(&link->mutex);
TaskHandle_t task_handle;
if (xTaskCreate(download_task_main, "http-download", DOWNLOAD_TASK_STACK_DEPTH, context, tskIDLE_PRIORITY + 1, &task_handle) != pdPASS) {
delete context;
mutex_lock(&link->mutex);
bool last = (--link->owners == 0);
mutex_unlock(&link->mutex);
if (last) {
delete link;
}
return ERROR_OUT_OF_MEMORY;
}
return ERROR_NONE;
}
error_t http_download_cancel(HttpDownloadSubscription* sub) {
HttpDownloadLink* link = sub->internal.link;
if (link == nullptr) {
return ERROR_INVALID_STATE;
}
mutex_lock(&link->mutex);
link->cancel_requested = true;
mutex_unlock(&link->mutex);
return ERROR_NONE;
}
}
+187
View File
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: Apache-2.0
#include <http/private/download.h>
#include <esp_heap_caps.h>
#include <esp_http_client.h>
#include <tactility/freertos/task.h>
#include <tactility/log.h>
namespace {
constexpr auto* TAG = "http-download";
// RAII: guarantees esp_http_client_close()/_cleanup() run on every exit path below.
class EspDownloadClient {
esp_http_client_handle_t client = nullptr;
bool isOpen = false;
public:
~EspDownloadClient() {
if (isOpen) {
esp_http_client_close(client);
}
if (client != nullptr) {
esp_http_client_cleanup(client);
}
}
bool init(const esp_http_client_config_t& config) {
client = esp_http_client_init(&config);
return client != nullptr;
}
bool open() {
if (esp_http_client_open(client, 0) != ESP_OK) {
return false;
}
isOpen = true;
return true;
}
bool fetchHeaders() const { return esp_http_client_fetch_headers(client) >= 0; }
int getStatusCode() const { return esp_http_client_get_status_code(client); }
int getContentLength() const { return esp_http_client_get_content_length(client); }
int read(char* buffer, int size) const { return esp_http_client_read(client, buffer, size); }
bool isComplete() const { return esp_http_client_is_complete_data_received(client); }
};
// esp_http_client_config_t::cert_pem needs a NUL-terminated buffer.
bool read_certificate(const std::string& certPath, std::string& outCertificate) {
auto* file = fopen(certPath.c_str(), "rb");
if (file == nullptr) {
return false;
}
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0) {
fclose(file);
return false;
}
outCertificate.resize(static_cast<size_t>(size));
size_t read_bytes = fread(outCertificate.data(), 1, static_cast<size_t>(size), file);
fclose(file);
return read_bytes == static_cast<size_t>(size);
}
}
HttpDownloadEvent http_download_run(const std::string& url, const std::string& certPath, const std::string& targetPath, HttpDownloadLink* link) {
if (http_download_is_cancelled(link)) {
return http_download_make_cancelled_event();
}
std::string certificate;
if (!read_certificate(certPath, certificate)) {
return http_download_make_error_event("Failed to read certificate file");
}
esp_http_client_config_t config {};
config.url = url.c_str();
config.auth_type = HTTP_AUTH_TYPE_NONE;
config.cert_pem = certificate.c_str();
config.cert_len = certificate.size() + 1;
config.tls_version = ESP_HTTP_CLIENT_TLS_VER_TLS_1_2;
config.method = HTTP_METHOD_GET;
config.timeout_ms = 5000;
config.transport_type = HTTP_TRANSPORT_OVER_SSL;
// Total free can look fine while a fragmented heap still can't satisfy one large-enough
// allocation. Logging both makes a future ALLOC_FAILED here diagnosable from the log alone.
LOG_I(TAG, "Free internal heap before connecting: %u bytes (largest block: %u bytes)",
static_cast<unsigned>(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)),
static_cast<unsigned>(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)));
EspDownloadClient client;
if (!client.init(config)) {
return http_download_make_error_event("Failed to initialize HTTP client");
}
if (!client.open()) {
return http_download_make_error_event("Failed to open connection");
}
if (!client.fetchHeaders()) {
return http_download_make_error_event("Failed to fetch response headers");
}
auto status_code = client.getStatusCode();
if (status_code < 200 || status_code >= 300) {
return http_download_make_error_event("Server response is not OK", status_code);
}
if (http_download_is_cancelled(link)) {
return http_download_make_cancelled_event(status_code);
}
// Downloaded to a sibling ".tmp" file first and only renamed onto targetPath on success, so a
// failed/cancelled download (or a crash mid-write) never leaves a partial file at targetPath.
auto tempPath = targetPath + ".tmp";
auto* file = fopen(tempPath.c_str(), "wb");
if (file == nullptr) {
return http_download_make_error_event("Failed to open target file", status_code);
}
// -1 means the length is unknown (e.g. a chunked response). Read until the client reports
// the body done instead of counting down a length that was never given.
auto content_length = client.getContentLength();
bool length_known = content_length >= 0;
auto bytes_left = content_length;
char buffer[512];
while (!length_known || bytes_left > 0) {
if (http_download_is_cancelled(link)) {
fclose(file);
remove(tempPath.c_str());
return http_download_make_cancelled_event(status_code);
}
int data_read = client.read(buffer, sizeof(buffer));
if (data_read < 0) {
fclose(file);
remove(tempPath.c_str());
return http_download_make_error_event("Failed to read response data", status_code);
}
if (data_read == 0) {
break;
}
if (length_known) {
bytes_left -= data_read;
}
if (fwrite(buffer, 1, static_cast<size_t>(data_read), file) != static_cast<size_t>(data_read)) {
fclose(file);
remove(tempPath.c_str());
return http_download_make_error_event("Failed to write downloaded data", status_code);
}
taskYIELD();
}
// Distinguishes a clean end (chunked terminator seen, or a known length fully read) from a
// connection that just stopped producing data early.
if (!client.isComplete()) {
fclose(file);
remove(tempPath.c_str());
return http_download_make_error_event("Response body was incomplete", status_code);
}
if (fclose(file) != 0) {
remove(tempPath.c_str());
return http_download_make_error_event("Failed to finalize downloaded file", status_code);
}
// Some embedded filesystems (e.g. FATFS) reject rename() onto an existing path instead of
// replacing it like POSIX does. Only clear the way if the plain rename actually needed it, so
// targetPath is never removed unless the new file is confirmed ready to replace it.
if (rename(tempPath.c_str(), targetPath.c_str()) != 0) {
remove(targetPath.c_str());
if (rename(tempPath.c_str(), targetPath.c_str()) != 0) {
remove(tempPath.c_str());
return http_download_make_error_event("Failed to finalize downloaded file", status_code);
}
}
return http_download_make_success_event(status_code);
}
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
#include <http/private/download.h>
HttpDownloadEvent http_download_run(const std::string&, const std::string&, const std::string&, HttpDownloadLink* link) {
if (http_download_is_cancelled(link)) {
return http_download_make_cancelled_event();
}
return http_download_make_error_event("HTTP downloads are not supported on this platform");
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: Apache-2.0
#include <http/download.h>
#include <http/module.h>
extern "C" {
static const ModuleSymbol http_module_symbols[] = {
DEFINE_MODULE_SYMBOL(http_download_subscribe),
DEFINE_MODULE_SYMBOL(http_download_unsubscribe),
DEFINE_MODULE_SYMBOL(http_download_poll),
DEFINE_MODULE_SYMBOL(http_download_start),
DEFINE_MODULE_SYMBOL(http_download_cancel),
MODULE_SYMBOL_TERMINATOR
};
Module http_module = {
.name = "http",
.start = nullptr,
.stop = nullptr,
.drivers = nullptr,
.symbols = http_module_symbols,
.internal = nullptr,
};
}
+16
View File
@@ -0,0 +1,16 @@
project(HttpModuleTests)
enable_language(C CXX ASM)
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp)
add_executable(HttpModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES})
target_include_directories(HttpModuleTests PRIVATE ${DOCTESTINC})
add_test(NAME HttpModuleTests COMMAND HttpModuleTests)
target_link_libraries(HttpModuleTests PUBLIC
http-module
TactilityKernel
freertos_kernel
)
@@ -0,0 +1,90 @@
#include "doctest.h"
#include <http/download.h>
TEST_CASE("http_download_start requires a subscribed subscription") {
HttpDownloadSubscription sub {};
CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_INVALID_ARGUMENT);
}
TEST_CASE("http_download_poll times out before the download finishes") {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
HttpDownloadSubscription sub {};
CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE);
HttpDownloadEvent event {};
CHECK_EQ(http_download_poll(&sub, &event), ERROR_TIMEOUT);
http_download_unsubscribe(&sub);
task_event_group_destruct(&event_group);
}
TEST_CASE("http_download_start on this (non-ESP-IDF) platform always fails the download") {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
HttpDownloadSubscription sub {};
CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE);
CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE);
CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);
HttpDownloadEvent event {};
CHECK_EQ(http_download_poll(&sub, &event), ERROR_NONE);
CHECK_EQ(event.type, HTTP_DOWNLOAD_EVENT_ERROR);
http_download_unsubscribe(&sub);
task_event_group_destruct(&event_group);
}
TEST_CASE("http_download_unsubscribe reports ERROR_NOT_FOUND when not subscribed") {
HttpDownloadSubscription sub {};
CHECK_EQ(http_download_unsubscribe(&sub), ERROR_NOT_FOUND);
}
TEST_CASE("http_download_cancel reports ERROR_INVALID_STATE when not subscribed") {
HttpDownloadSubscription sub {};
CHECK_EQ(http_download_cancel(&sub), ERROR_INVALID_STATE);
}
TEST_CASE("http_download_unsubscribe is safe to call right after start, without waiting for the download to finish") {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
HttpDownloadSubscription sub {};
CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE);
CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE);
// No cancel, no poll for the terminal event - just unsubscribe and tear down immediately,
// racing whatever the download task is doing.
CHECK_EQ(http_download_unsubscribe(&sub), ERROR_NONE);
CHECK_EQ(http_download_unsubscribe(&sub), ERROR_NOT_FOUND);
// The bit is free again immediately, even though the download task may still be running.
uint32_t reclaimed_bit;
CHECK_EQ(task_event_group_claim_bit(&event_group, &reclaimed_bit), ERROR_NONE);
task_event_group_release_bit(&event_group, reclaimed_bit);
task_event_group_destruct(&event_group);
}
TEST_CASE("http_download_cancel before start makes the download finish as cancelled") {
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
HttpDownloadSubscription sub {};
CHECK_EQ(http_download_subscribe(&sub, &event_group), ERROR_NONE);
CHECK_EQ(http_download_cancel(&sub), ERROR_NONE);
CHECK_EQ(http_download_start("http://example.com/file", "/cert.pem", "/target.bin", &sub), ERROR_NONE);
CHECK_EQ(task_event_group_wait(&event_group, sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);
HttpDownloadEvent event {};
CHECK_EQ(http_download_poll(&sub, &event), ERROR_NONE);
CHECK_EQ(event.type, HTTP_DOWNLOAD_EVENT_CANCELLED);
http_download_unsubscribe(&sub);
task_event_group_destruct(&event_group);
}
+64
View File
@@ -0,0 +1,64 @@
#define DOCTEST_CONFIG_IMPLEMENT
#include "doctest.h"
#include <cstdio>
#include <cstdlib>
#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
);
if (task_result != pdPASS) {
return 1;
}
vTaskStartScheduler();
return data.result;
}
// NOTE: This is normally provided by the platform kernel module, but that's not loaded for http-module
extern "C" {
// Required for FreeRTOS
void vAssertCalled(unsigned long line, const char* const file) {
std::fprintf(stderr, "assert failed at %s:%lu\n", file, line);
std::abort();
}
}
+1
View File
@@ -13,6 +13,7 @@ list(APPEND REQUIRES_LIST
app-module app-module
crypt-module crypt-module
gps-module gps-module
http-module
gps-generic-module gps-generic-module
gps-meshtastic-module gps-meshtastic-module
service-module service-module
+1
View File
@@ -2,6 +2,7 @@
#include <Tactility/TactilityCore.h> #include <Tactility/TactilityCore.h>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <dirent.h> #include <dirent.h>
#include <functional> #include <functional>
+2
View File
@@ -41,6 +41,7 @@
#include <gps/module.h> #include <gps/module.h>
#include <gps_generic/module.h> #include <gps_generic/module.h>
#include <gps_meshtastic/module.h> #include <gps_meshtastic/module.h>
#include <http/module.h>
#include <crypt/module.h> #include <crypt/module.h>
#include <lvgl/devices/keyboard.h> #include <lvgl/devices/keyboard.h>
@@ -475,6 +476,7 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) {
check(module_ensure_started(&gps_module) == ERROR_NONE); check(module_ensure_started(&gps_module) == ERROR_NONE);
check(module_ensure_started(&gps_generic_module) == ERROR_NONE); check(module_ensure_started(&gps_generic_module) == ERROR_NONE);
check(module_ensure_started(&gps_meshtastic_module) == ERROR_NONE); check(module_ensure_started(&gps_meshtastic_module) == ERROR_NONE);
check(module_ensure_started(&http_module) == ERROR_NONE);
// Registers the APP_LOCATION_MEMORY app loader (boot/launcher need it below). // 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_module) == ERROR_NONE);
#ifdef ESP_PLATFORM #ifdef ESP_PLATFORM
+121 -28
View File
@@ -4,7 +4,6 @@
#include <Tactility/app/apphub/AppHubEntry.h> #include <Tactility/app/apphub/AppHubEntry.h>
#include <Tactility/app/apphubdetails/AppHubDetailsApp.h> #include <Tactility/app/apphubdetails/AppHubDetailsApp.h>
#include <Tactility/file/File.h> #include <Tactility/file/File.h>
#include <Tactility/network/Http.h>
#include <Tactility/service/wifi/Wifi.h> #include <Tactility/service/wifi/Wifi.h>
#include <app/event.h> #include <app/event.h>
@@ -12,6 +11,8 @@
#include <app/manifest.h> #include <app/manifest.h>
#include <app/scheduler.h> #include <app/scheduler.h>
#include <http/download.h>
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h> #include <tactility/check.h>
@@ -22,6 +23,7 @@
#include <lvgl/widgets/toolbar.h> #include <lvgl/widgets/toolbar.h>
#include <algorithm> #include <algorithm>
#include <atomic>
#include <format> #include <format>
namespace tt::app::apphub { namespace tt::app::apphub {
@@ -43,6 +45,20 @@ struct Context {
// Survives across a bury/resurface cycle (e.g. opening AppHubDetailsApp and returning), // Survives across a bury/resurface cycle (e.g. opening AppHubDetailsApp and returning),
int32_t scrollY = 0; int32_t scrollY = 0;
// Set by createWidgets(), consumed by the first showApps() after resurfacing. The refresh
// itself runs async (see requestRefresh() below), so when showApps() first populates the
// list, contentWrapper is still an empty spinner; scrollY must come from here instead of a
// live read off it.
bool restoreScrollOnNextShow = false;
// Only the first createWidgets() call triggers a network refresh. The rest uses the cached file.
bool needsInitialRefresh = true;
TaskEventGroup* eventGroup = nullptr;
uint32_t refreshRequestedBit = 0;
std::atomic<bool> refreshRequested {false};
HttpDownloadSubscription downloadSub {};
bool downloadInProgress = false;
}; };
@@ -66,9 +82,14 @@ void onAppPressed(lv_event_t* e) {
ctx->mutex.unlock(); ctx->mutex.unlock();
} }
void requestRefresh(Context* ctx) {
ctx->refreshRequested = true;
task_event_group_signal(ctx->eventGroup, ctx->refreshRequestedBit);
}
void onRefreshPressed(lv_event_t* e) { void onRefreshPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e)); auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
refresh(ctx); requestRefresh(ctx);
} }
void showRefreshFailedError(Context* ctx, const char* message) { void showRefreshFailedError(Context* ctx, const char* message) {
@@ -88,7 +109,13 @@ void showNoInternet(Context* ctx) {
void showApps(Context* ctx) { void showApps(Context* ctx) {
// Refresh rebuilds the list from scratch (cached copy, then again once the network fetch // Refresh rebuilds the list from scratch (cached copy, then again once the network fetch
// lands), which would otherwise reset the user's scroll position each time. // lands), which would otherwise reset the user's scroll position each time.
auto scrollY = lv_obj_get_scroll_y(ctx->contentWrapper); int32_t scrollY;
if (ctx->restoreScrollOnNextShow) {
scrollY = ctx->scrollY;
ctx->restoreScrollOnNextShow = false;
} else {
scrollY = lv_obj_get_scroll_y(ctx->contentWrapper);
}
lv_obj_clean(ctx->contentWrapper); lv_obj_clean(ctx->contentWrapper);
ctx->mutex.lock(); ctx->mutex.lock();
if (parseJson(ctx->cachedAppsJsonFile, ctx->entries)) { if (parseJson(ctx->cachedAppsJsonFile, ctx->entries)) {
@@ -130,42 +157,82 @@ void showApps(Context* ctx) {
ctx->mutex.unlock(); ctx->mutex.unlock();
} }
// Runs on appMain()'s own task (triggered via requestRefresh()), never directly from the LVGL task.
void refresh(Context* ctx) { void refresh(Context* ctx) {
// Buried (e.g. AppHubDetailsApp is open): destroyWidgets() already released these. A refresh
// request queued just before burying could still land here, so re-check rather than assume
// requestRefresh() and refresh() always run against a live window.
if (ctx->downloadInProgress || ctx->contentWrapper == nullptr) {
return;
}
lvgl_lock();
lv_obj_clean(ctx->contentWrapper); lv_obj_clean(ctx->contentWrapper);
auto* spinner = lvgl_spinner_create(ctx->contentWrapper); auto* spinner = lvgl_spinner_create(ctx->contentWrapper);
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0); lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
lvgl_unlock();
if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) { if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
lvgl_lock();
showNoInternet(ctx); showNoInternet(ctx);
lvgl_unlock();
return; return;
} }
if (file::isFile(ctx->cachedAppsJsonFile)) { if (file::isFile(ctx->cachedAppsJsonFile)) {
lvgl_lock();
showApps(ctx); showApps(ctx);
lvgl_unlock();
} }
// These callbacks run on a background network thread and reach back into this app's if (http_download_subscribe(&ctx->downloadSub, ctx->eventGroup) != ERROR_NONE) {
// widgets via the captured ctx pointer - same convention as AppHubDetailsApp.cpp's LOG_E(TAG, "Failed to subscribe to download events");
// download callback for the sibling "install/update" flow. lvgl_lock();
network::http::download( showRefreshFailedError(ctx, "Cannot reach server");
getAppsJsonUrl(), lvgl_unlock();
CERTIFICATE_PATH, return;
ctx->cachedAppsJsonFile, }
[ctx] {
LOG_I(TAG, "Request success"); auto url = getAppsJsonUrl();
lvgl_lock(); if (http_download_start(url.c_str(), CERTIFICATE_PATH, ctx->cachedAppsJsonFile.c_str(), &ctx->downloadSub) != ERROR_NONE) {
showApps(ctx); LOG_E(TAG, "Failed to start download");
lvgl_unlock(); http_download_unsubscribe(&ctx->downloadSub);
}, lvgl_lock();
[ctx](const char* error) { showRefreshFailedError(ctx, "Cannot reach server");
LOG_E(TAG, "Request failed: %s", error); lvgl_unlock();
lvgl_lock(); return;
showRefreshFailedError(ctx, "Cannot reach server"); }
lvgl_unlock();
} ctx->downloadInProgress = true;
); }
// Called from appMain()'s loop once http_download_poll() reports the download's terminal event.
void onDownloadFinished(Context* ctx, const HttpDownloadEvent& event) {
ctx->downloadInProgress = false;
http_download_unsubscribe(&ctx->downloadSub);
bool succeeded = event.type == HTTP_DOWNLOAD_EVENT_SUCCESS;
ctx->needsInitialRefresh = !succeeded;
if (succeeded) {
LOG_I(TAG, "Request success (status %d)", event.status_code);
} else {
LOG_E(TAG, "Request failed (status %d): %s", event.status_code, event.error.message);
}
if (ctx->contentWrapper == nullptr) {
// Buried (e.g. AppHubDetailsApp is open): destroyWidgets() already released the widgets
// above. createWidgets() picks this up via needsInitialRefresh on resurface instead.
return;
}
lvgl_lock();
if (succeeded) {
showApps(ctx);
} else {
showRefreshFailedError(ctx, "Cannot reach server");
}
lvgl_unlock();
} }
void createWidgets(lv_obj_t* parent, void* userData) { void createWidgets(lv_obj_t* parent, void* userData) {
@@ -186,9 +253,15 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_style_pad_all(ctx->contentWrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_all(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_ver(ctx->contentWrapper, 0, LV_STATE_DEFAULT); lv_obj_set_style_pad_ver(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
refresh(ctx); ctx->restoreScrollOnNextShow = true;
if (ctx->needsInitialRefresh) {
lv_obj_scroll_to_y(ctx->contentWrapper, ctx->scrollY, LV_ANIM_OFF); requestRefresh(ctx);
} else {
// Resurfacing (e.g. returning from AppHubDetailsApp): redisplay the cache already
// loaded this session instead of hitting the network again. window_manager calls
// createWidgets() with the LVGL lock already held, so this can touch widgets directly.
showApps(ctx);
}
} }
void destroyWidgets(void* userData) { void destroyWidgets(void* userData) {
@@ -205,6 +278,10 @@ int32_t appMain(int argc, char* argv[]) {
TaskEventGroup event_group {}; TaskEventGroup event_group {};
task_event_group_construct(&event_group); task_event_group_construct(&event_group);
ctx.eventGroup = &event_group;
if (task_event_group_claim_bit(&event_group, &ctx.refreshRequestedBit) != ERROR_NONE) {
LOG_W(TAG, "Failed to claim a refresh-requested bit; refresh button won't work");
}
AppEventSubscription sub {}; AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
@@ -224,8 +301,24 @@ int32_t appMain(int argc, char* argv[]) {
default: default:
break; break;
} }
if (shouldClose) break;
} }
if (ctx.downloadInProgress) {
HttpDownloadEvent download_event {};
if (http_download_poll(&ctx.downloadSub, &download_event) == ERROR_NONE) {
onDownloadFinished(&ctx, download_event);
}
}
if (!shouldClose && ctx.refreshRequested.exchange(false)) {
refresh(&ctx);
}
}
if (ctx.downloadInProgress) {
http_download_cancel(&ctx.downloadSub);
http_download_unsubscribe(&ctx.downloadSub);
ctx.downloadInProgress = false;
} }
window_manager_remove(window); window_manager_remove(window);
@@ -4,7 +4,6 @@
#include <Tactility/app/apphub/AppHub.h> #include <Tactility/app/apphub/AppHub.h>
#include <Tactility/app/apphub/AppHubEntry.h> #include <Tactility/app/apphub/AppHubEntry.h>
#include <Tactility/file/File.h> #include <Tactility/file/File.h>
#include <Tactility/network/Http.h>
#include <app/event.h> #include <app/event.h>
#include <app/install.h> #include <app/install.h>
@@ -13,6 +12,8 @@
#include <app/manifest.h> #include <app/manifest.h>
#include <app/scheduler.h> #include <app/scheduler.h>
#include <http/download.h>
#include <lvgl_window_manager/window_manager.h> #include <lvgl_window_manager/window_manager.h>
#include <lvgl/lvgl.h> #include <lvgl/lvgl.h>
@@ -51,6 +52,13 @@ struct Context {
std::atomic<uint32_t> installDialogId = 0; std::atomic<uint32_t> installDialogId = 0;
std::atomic<uint32_t> uninstallDialogId = 0; std::atomic<uint32_t> uninstallDialogId = 0;
std::atomic<uint32_t> updateDialogId = 0; std::atomic<uint32_t> updateDialogId = 0;
// doInstall()/onDownloadFinished() and the poll for them both run on appMain()'s own task
// (triggered via confirm-dialog APP_EVENT_RESULTs), so HttpDownloadSubscription's
// subscribe/start/poll are naturally all on one consistent task already.
TaskEventGroup* eventGroup = nullptr;
HttpDownloadSubscription downloadSub {};
bool downloadInProgress = false;
}; };
@@ -95,36 +103,68 @@ void uninstallApp(Context* ctx) {
lvgl_unlock(); lvgl_unlock();
} }
void doInstall(Context* ctx) { // Path doInstall() downloads to and onDownloadFinished() installs from - deterministic from ctx->entry,
auto url = apphub::getDownloadUrl(ctx->entry.file); // which doesn't change once this app instance is running, so it's recomputed at each use instead of stored.
std::string getTempFilePath(Context* ctx) {
auto file_name = file::getLastPathSegment(ctx->entry.file); auto file_name = file::getLastPathSegment(ctx->entry.file);
auto temp_file_path = std::format("{}/{}", getTempPath(), file_name); return 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)) { void doInstall(Context* ctx) {
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str()); if (ctx->downloadInProgress) {
} else { return;
LOG_I(TAG, "Deleted temporary file %s", temp_file_path.c_str()); }
}
lvgl_lock(); if (http_download_subscribe(&ctx->downloadSub, ctx->eventGroup) != ERROR_NONE) {
updateViews(ctx); LOG_E(TAG, "Failed to subscribe to download events");
lvgl_unlock(); alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
}, return;
[ctx, temp_file_path](const char* errorMessage) { }
LOG_E(TAG, "Download failed: %s", errorMessage);
auto url = apphub::getDownloadUrl(ctx->entry.file);
auto temp_file_path = getTempFilePath(ctx);
if (http_download_start(url.c_str(), apphub::CERTIFICATE_PATH, temp_file_path.c_str(), &ctx->downloadSub) != ERROR_NONE) {
LOG_E(TAG, "Failed to start download");
http_download_unsubscribe(&ctx->downloadSub);
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
return;
}
ctx->downloadInProgress = true;
}
// Called from appMain()'s loop once http_download_poll() reports the download's terminal event.
void onDownloadFinished(Context* ctx, const HttpDownloadEvent& event) {
ctx->downloadInProgress = false;
http_download_unsubscribe(&ctx->downloadSub);
auto temp_file_path = getTempFilePath(ctx);
if (event.type == HTTP_DOWNLOAD_EVENT_SUCCESS) {
error_t install_result = app_install(temp_file_path.c_str());
if (install_result != ERROR_NONE) {
LOG_E(TAG, "Install of %s failed", temp_file_path.c_str());
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app"); alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
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());
}
} }
);
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());
}
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
} else {
LOG_E(TAG, "Download failed (status %d): %s", event.status_code,
event.type == HTTP_DOWNLOAD_EVENT_ERROR ? event.error.message : "Cancelled");
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
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());
}
}
} }
void installApp(Context* ctx) { void installApp(Context* ctx) {
@@ -234,6 +274,7 @@ int32_t appMain(int argc, char* argv[]) {
TaskEventGroup event_group {}; TaskEventGroup event_group {};
task_event_group_construct(&event_group); task_event_group_construct(&event_group);
ctx.eventGroup = &event_group;
AppEventSubscription sub {}; AppEventSubscription sub {};
check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); check(app_event_subscribe(&sub, &event_group) == ERROR_NONE);
@@ -267,6 +308,21 @@ int32_t appMain(int argc, char* argv[]) {
} }
if (shouldClose) break; if (shouldClose) break;
} }
if (ctx.downloadInProgress) {
HttpDownloadEvent download_event {};
if (http_download_poll(&ctx.downloadSub, &download_event) == ERROR_NONE) {
onDownloadFinished(&ctx, download_event);
}
}
}
if (ctx.downloadInProgress) {
// Safe to call immediately, even mid-download - no need to wait for the terminal event
// first, so app close doesn't block on the network.
http_download_cancel(&ctx.downloadSub);
http_download_unsubscribe(&ctx.downloadSub);
ctx.downloadInProgress = false;
} }
window_manager_remove(window); window_manager_remove(window);
+1
View File
@@ -19,6 +19,7 @@ target_link_libraries(TactilityTests PRIVATE
app-module app-module
crypt-module crypt-module
gps-module gps-module
http-module
gps-generic-module gps-generic-module
gps-meshtastic-module gps-meshtastic-module
service-module service-module
+2
View File
@@ -9,6 +9,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/TactilityKernel/tests ${CMAKE_CURRENT_BINAR
add_subdirectory(${CMAKE_SOURCE_DIR}/Tactility/Tests ${CMAKE_CURRENT_BINARY_DIR}/Tactility) add_subdirectory(${CMAKE_SOURCE_DIR}/Tactility/Tests ${CMAKE_CURRENT_BINARY_DIR}/Tactility)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_BINARY_DIR}/crypt-module) add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_BINARY_DIR}/crypt-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module) add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module)
add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module)
add_custom_target(build-tests) add_custom_target(build-tests)
add_dependencies(build-tests ServiceModuleTests) add_dependencies(build-tests ServiceModuleTests)
@@ -17,3 +18,4 @@ add_dependencies(build-tests TactilityTests)
add_dependencies(build-tests TactilityKernelTests) add_dependencies(build-tests TactilityKernelTests)
add_dependencies(build-tests CryptModuleTests) add_dependencies(build-tests CryptModuleTests)
add_dependencies(build-tests AppModuleTests) add_dependencies(build-tests AppModuleTests)
add_dependencies(build-tests HttpModuleTests)