Implement http-module (#637)
Create http-module and implement it in App Hub and App Hub Details apps.
This commit is contained in:
committed by
GitHub
parent
d2442bedb4
commit
bcbf18e363
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
Apache License
|
||||||
|
==============
|
||||||
|
|
||||||
|
_Version 2.0, January 2004_
|
||||||
|
_<<http://www.apache.org/licenses/>>_
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)) {
|
||||||
showApps(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
lvgl_lock();
|
||||||
showApps(ctx);
|
showApps(ctx);
|
||||||
lvgl_unlock();
|
lvgl_unlock();
|
||||||
},
|
}
|
||||||
[ctx](const char* error) {
|
|
||||||
LOG_E(TAG, "Request failed: %s", error);
|
if (http_download_subscribe(&ctx->downloadSub, ctx->eventGroup) != ERROR_NONE) {
|
||||||
|
LOG_E(TAG, "Failed to subscribe to download events");
|
||||||
lvgl_lock();
|
lvgl_lock();
|
||||||
showRefreshFailedError(ctx, "Cannot reach server");
|
showRefreshFailedError(ctx, "Cannot reach server");
|
||||||
lvgl_unlock();
|
lvgl_unlock();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
auto url = getAppsJsonUrl();
|
||||||
|
if (http_download_start(url.c_str(), CERTIFICATE_PATH, ctx->cachedAppsJsonFile.c_str(), &ctx->downloadSub) != ERROR_NONE) {
|
||||||
|
LOG_E(TAG, "Failed to start download");
|
||||||
|
http_download_unsubscribe(&ctx->downloadSub);
|
||||||
|
lvgl_lock();
|
||||||
|
showRefreshFailedError(ctx, "Cannot reach server");
|
||||||
|
lvgl_unlock();
|
||||||
|
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);
|
||||||
|
|
||||||
|
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,16 +103,49 @@ 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,
|
void doInstall(Context* ctx) {
|
||||||
temp_file_path,
|
if (ctx->downloadInProgress) {
|
||||||
[ctx, temp_file_path] {
|
return;
|
||||||
app_install(temp_file_path.c_str());
|
}
|
||||||
|
|
||||||
|
if (http_download_subscribe(&ctx->downloadSub, ctx->eventGroup) != ERROR_NONE) {
|
||||||
|
LOG_E(TAG, "Failed to subscribe to download events");
|
||||||
|
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
if (!file::deleteFile(temp_file_path)) {
|
if (!file::deleteFile(temp_file_path)) {
|
||||||
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
|
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
|
||||||
@@ -115,16 +156,15 @@ void doInstall(Context* ctx) {
|
|||||||
lvgl_lock();
|
lvgl_lock();
|
||||||
updateViews(ctx);
|
updateViews(ctx);
|
||||||
lvgl_unlock();
|
lvgl_unlock();
|
||||||
},
|
} else {
|
||||||
[ctx, temp_file_path](const char* errorMessage) {
|
LOG_E(TAG, "Download failed (status %d): %s", event.status_code,
|
||||||
LOG_E(TAG, "Download failed: %s", errorMessage);
|
event.type == HTTP_DOWNLOAD_EVENT_ERROR ? event.error.message : "Cancelled");
|
||||||
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())) {
|
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());
|
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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user