diff --git a/Modules/lvgl-module/CMakeLists.txt b/Modules/lvgl-module/CMakeLists.txt index 9eefa04aa..10c87f7a7 100644 --- a/Modules/lvgl-module/CMakeLists.txt +++ b/Modules/lvgl-module/CMakeLists.txt @@ -52,6 +52,7 @@ list(APPEND REQUIRES_LIST binfont-module lodepng-module lvgl + QRCode ) if (DEFINED ENV{ESP_IDF_VERSION}) diff --git a/Modules/lvgl-module/include/lvgl/widgets/qr.h b/Modules/lvgl-module/include/lvgl/widgets/qr.h new file mode 100644 index 000000000..44a167e65 --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/widgets/qr.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Creates a QR code view. It draws the code with square modules of a whole number of pixels, + * centered in the object and surrounded by the quiet zone that scanners need. + * Give it a size, and set the data with lvgl_qr_set_data(). + * @param[in] parent the parent object for the new view + * @return the created view + */ +lv_obj_t* lvgl_qr_create(lv_obj_t* parent); + +/** + * @brief Sets the text or bytes to encode, in the smallest QR version that fits (low error correction). + * The view keeps showing the previous code when this fails. + * @param[in] obj a view created with lvgl_qr_create() + * @param[in] data the NUL-terminated data, at most 1273 bytes + * @retval ERROR_NONE on success + * @retval ERROR_OUT_OF_RANGE when the data doesn't fit in a QR code + * @retval ERROR_OUT_OF_MEMORY when the code couldn't be allocated + */ +error_t lvgl_qr_set_data(lv_obj_t* obj, const char* data); + +/** + * @brief Sets the colors of the modules and of the background, including the quiet zone. + * Scanners need a dark code on a light background, so these are black and white by default. + */ +void lvgl_qr_set_colors(lv_obj_t* obj, lv_color_t dark, lv_color_t light); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index c7729380e..41f70c9f1 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -148,6 +149,11 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_list_create), DEFINE_MODULE_SYMBOL(lvgl_list_add_text), DEFINE_MODULE_SYMBOL(lvgl_list_add_button), + // lvgl_qr + DEFINE_MODULE_SYMBOL(lvgl_qr_create), + DEFINE_MODULE_SYMBOL(lvgl_qr_set_data), + DEFINE_MODULE_SYMBOL(lvgl_qr_set_colors), + // lvgl_spinner DEFINE_MODULE_SYMBOL(lvgl_spinner_create), // lvgl_toolbar diff --git a/Modules/lvgl-module/source/widgets/qr.cpp b/Modules/lvgl-module/source/widgets/qr.cpp new file mode 100644 index 000000000..9fcc04ffb --- /dev/null +++ b/Modules/lvgl-module/source/widgets/qr.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +#define LV_USE_PRIVATE_API 1 // For the lv_obj_t and lv_obj_class_t declarations + +#include + +#include + +extern "C" { +#include +} + +#include +#include + +// Bounds the stack that qrcode_initBytes() uses: its buffers are sized by the QR version +#define QR_MAX_VERSION 25 +// The code needs this many modules of empty space on every side +#define QR_QUIET_ZONE_MODULES 4 + +// Bytes that fit in each version (starting at version 1) with low error correction. +// The QR library doesn't check the length, so the version has to be big enough before encoding. +static const uint16_t QR_VERSION_BYTE_CAPACITY[QR_MAX_VERSION] = { + 17, 32, 53, 78, 106, 134, 154, 192, 230, 271, + 321, 367, 425, 458, 520, 586, 644, 718, 792, 858, + 929, 1003, 1091, 1171, 1273 +}; + +struct QrView { + lv_obj_t obj; + QRCode code; + uint8_t* modules; + lv_color_t dark; + lv_color_t light; +}; + +static void qr_constructor(const lv_obj_class_t* object_class, lv_obj_t* object); +static void qr_destructor(const lv_obj_class_t* object_class, lv_obj_t* object); +static void qr_event(const lv_obj_class_t* object_class, lv_event_t* event); + +static const lv_obj_class_t lvgl_qr_class = { + .base_class = &lv_obj_class, + .constructor_cb = qr_constructor, + .destructor_cb = qr_destructor, + .event_cb = qr_event, + .user_data = nullptr, + .name = "qr", + .width_def = 0, + .height_def = 0, + .editable = LV_OBJ_CLASS_EDITABLE_FALSE, + .group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE, + .instance_size = sizeof(QrView), + .theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE +}; + +static uint8_t* allocate_modules(uint8_t version) { + constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + return static_cast(memory_alloc_with_policy(qrcode_getBufferSize(version), &policy)); +} + +static void qr_constructor(const lv_obj_class_t* object_class, lv_obj_t* object) { + auto* view = reinterpret_cast(object); + view->modules = nullptr; + view->dark = lv_color_black(); + view->light = lv_color_white(); + // The view draws its own background and has nothing to scroll + lv_obj_set_scrollable(object, false); + lv_obj_set_style_pad_all(object, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(object, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(object, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(object, LV_OPA_TRANSP, LV_STATE_DEFAULT); +} + +static void qr_destructor(const lv_obj_class_t* object_class, lv_obj_t* object) { + auto* view = reinterpret_cast(object); + if (view->modules != nullptr) { + memory_free(view->modules); + view->modules = nullptr; + } +} + +static void qr_draw(lv_obj_t* object, lv_layer_t* layer) { + auto* view = reinterpret_cast(object); + lv_area_t area; + lv_obj_get_content_coords(object, &area); + + lv_draw_rect_dsc_t rect; + lv_draw_rect_dsc_init(&rect); + rect.bg_opa = LV_OPA_COVER; + rect.bg_color = view->light; + lv_draw_rect(layer, &rect, &area); + + if (view->modules == nullptr) { + return; + } + + const int32_t modules_with_quiet_zone = view->code.size + 2 * QR_QUIET_ZONE_MODULES; + const int32_t module_size = std::min(lv_area_get_width(&area), lv_area_get_height(&area)) / modules_with_quiet_zone; + if (module_size < 1) { + return; + } + const int32_t origin_x = area.x1 + (lv_area_get_width(&area) - modules_with_quiet_zone * module_size) / 2 + QR_QUIET_ZONE_MODULES * module_size; + const int32_t origin_y = area.y1 + (lv_area_get_height(&area) - modules_with_quiet_zone * module_size) / 2 + QR_QUIET_ZONE_MODULES * module_size; + + rect.bg_color = view->dark; + // Neighbouring dark modules in a row are drawn as one rectangle + for (int32_t y = 0; y < view->code.size; y++) { + int32_t x = 0; + while (x < view->code.size) { + if (!qrcode_getModule(&view->code, static_cast(x), static_cast(y))) { + x++; + continue; + } + int32_t end = x + 1; + while (end < view->code.size && qrcode_getModule(&view->code, static_cast(end), static_cast(y))) { + end++; + } + lv_area_t run = { + .x1 = origin_x + x * module_size, + .y1 = origin_y + y * module_size, + .x2 = origin_x + end * module_size - 1, + .y2 = origin_y + (y + 1) * module_size - 1, + }; + lv_draw_rect(layer, &rect, &run); + x = end; + } + } +} + +static void qr_event(const lv_obj_class_t* object_class, lv_event_t* event) { + if (lv_obj_event_base(&lvgl_qr_class, event) != LV_RESULT_OK) { + return; + } + if (lv_event_get_code(event) == LV_EVENT_DRAW_MAIN) { + qr_draw(lv_event_get_current_target_obj(event), lv_event_get_layer(event)); + } +} + +lv_obj_t* lvgl_qr_create(lv_obj_t* parent) { + lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_qr_class, parent); + lv_obj_class_init_obj(obj); + return obj; +} + +error_t lvgl_qr_set_data(lv_obj_t* obj, const char* data) { + auto* view = reinterpret_cast(obj); + const size_t length = strlen(data); + if (length > QR_VERSION_BYTE_CAPACITY[QR_MAX_VERSION - 1]) { + return ERROR_OUT_OF_RANGE; + } + + uint8_t version = 1; + while (length > QR_VERSION_BYTE_CAPACITY[version - 1]) { + version++; + } + uint8_t* modules = allocate_modules(version); + if (modules == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + QRCode code; + if (qrcode_initBytes(&code, modules, version, ECC_LOW, reinterpret_cast(const_cast(data)), static_cast(length)) != 0) { + memory_free(modules); + return ERROR_OUT_OF_RANGE; + } + if (view->modules != nullptr) { + memory_free(view->modules); + } + view->modules = modules; + view->code = code; + lv_obj_invalidate(obj); + return ERROR_NONE; +} + +void lvgl_qr_set_colors(lv_obj_t* obj, lv_color_t dark, lv_color_t light) { + auto* view = reinterpret_cast(obj); + view->dark = dark; + view->light = light; + lv_obj_invalidate(obj); +} diff --git a/Modules/lvgl-module/tests/CMakeLists.txt b/Modules/lvgl-module/tests/CMakeLists.txt index 961d96265..66417dffa 100644 --- a/Modules/lvgl-module/tests/CMakeLists.txt +++ b/Modules/lvgl-module/tests/CMakeLists.txt @@ -6,6 +6,9 @@ file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*. add_executable(LvglModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES} ${PROJECT_SOURCE_DIR}/../source/insets_math.cpp + ${PROJECT_SOURCE_DIR}/../source/widgets/qr.cpp + # LVGL allocates through the kernel + ${PROJECT_SOURCE_DIR}/../source/lv_mem_custom.c ) target_include_directories(LvglModuleTests PRIVATE @@ -19,4 +22,5 @@ add_test(NAME LvglModuleTests COMMAND LvglModuleTests) target_link_libraries(LvglModuleTests PUBLIC TactilityKernel lvgl + QRCode ) diff --git a/Modules/lvgl-module/tests/source/qr_test.cpp b/Modules/lvgl-module/tests/source/qr_test.cpp new file mode 100644 index 000000000..7c50ee025 --- /dev/null +++ b/Modules/lvgl-module/tests/source/qr_test.cpp @@ -0,0 +1,25 @@ +#include "doctest.h" + +#include +#include + +#include + +TEST_CASE("lvgl_qr_set_data accepts data that fits in a QR code and rejects data that doesn't") { + lv_init(); + lv_display_create(320, 240); + lv_obj_t* qr = lvgl_qr_create(lv_screen_active()); + + CHECK_EQ(lvgl_qr_set_data(qr, "hello"), ERROR_NONE); + // Longer data needs a larger version + CHECK_EQ(lvgl_qr_set_data(qr, std::string(17, 'a').c_str()), ERROR_NONE); + CHECK_EQ(lvgl_qr_set_data(qr, std::string(18, 'a').c_str()), ERROR_NONE); + CHECK_EQ(lvgl_qr_set_data(qr, std::string(1273, 'a').c_str()), ERROR_NONE); + CHECK_EQ(lvgl_qr_set_data(qr, std::string(1274, 'a').c_str()), ERROR_OUT_OF_RANGE); + CHECK_EQ(lvgl_qr_set_data(qr, std::string(5000, 'a').c_str()), ERROR_OUT_OF_RANGE); + // A failure keeps the previous code + CHECK_EQ(lvgl_qr_set_data(qr, ""), ERROR_NONE); + + lv_obj_delete(qr); + lv_deinit(); +} diff --git a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h index 53ffbcb8a..cfbccb6de 100644 --- a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h +++ b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h @@ -52,6 +52,14 @@ void meshcore_channel_from_key(const uint8_t key[16], MeshCoreChannel* channel); /** Key of a hashtag channel (e.g. "#test"): the first 16 bytes of the SHA-256 of its name */ void meshcore_hashtag_channel_key(const char* name, uint8_t key[16]); +/** + * Build the URI that MeshCore apps scan as a QR code to add a contact: + * meshcore://contact/add?name=&public_key=&type= + * @param[in] type the contact type: 1 for a companion, 2 for a repeater, 3 for a room server, 4 for a sensor + * @return false when it doesn't fit in @a uri + */ +bool meshcore_build_contact_uri(const char* name, const uint8_t publicKey[MESHCORE_PUBLIC_KEY_SIZE], uint8_t type, char* uri, size_t uriSize); + /** Shared secret between our identity and a peer's public key */ void meshcore_shared_secret(const MeshCoreIdentity& identity, const uint8_t peerPublicKey[MESHCORE_PUBLIC_KEY_SIZE], uint8_t secret[MESHCORE_SECRET_SIZE]); diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index d969551fd..5b161e9b0 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -402,6 +402,24 @@ void onStart(void* context) { sendAdvert(static_cast(context)); } +error_t getContactUri(void* context, const MeshAddress* contact, const char* name, char* uri, size_t uriSize) { + auto* backend = static_cast(context); + if (contact != nullptr && contact->length != MESHCORE_PUBLIC_KEY_SIZE) { + return ERROR_INVALID_ARGUMENT; + } + const uint8_t* publicKey = (contact != nullptr) ? contact->bytes : backend->identity.publicKey; + // Contacts are shown as companions, as the type of a contact isn't stored + return meshcore_build_contact_uri(name, publicKey, 1, uri, uriSize) ? ERROR_NONE : ERROR_BUFFER_OVERFLOW; +} + +bool isValidContactAddress(void*, const MeshAddress* address) { + return address->length == MESHCORE_PUBLIC_KEY_SIZE; +} + +void onNodeNameChanged(void* context) { + sendAdvert(static_cast(context)); +} + bool onReceive(void* context, const uint8_t* data, size_t length, float rssi, float snr) { auto* backend = static_cast(context); MeshCorePacket packet; @@ -441,7 +459,7 @@ void onTick(void* context) { } for (auto iterator = backend->pendingAcks.begin(); iterator != backend->pendingAcks.end();) { if (nowMs >= iterator->second.deadlineMs) { - // Sent but never acknowledged: it stays "sent" + mesh_host_set_delivery(backend->host, iterator->second.seq, MESH_DELIVERY_FAILED); iterator = backend->pendingAcks.erase(iterator); } else { ++iterator; @@ -520,6 +538,16 @@ error_t setChannel(void* context, const MeshChannel* channel) { return ERROR_NONE; } +error_t removeChannel(void* context, uint8_t index) { + auto* backend = static_cast(context); + const size_t removed = std::erase_if(backend->channels, [index](const Channel& existing) { return existing.index == index; }); + if (removed == 0) { + return ERROR_NOT_FOUND; + } + saveChannels(backend); + return ERROR_NONE; +} + // endregion } // namespace @@ -536,4 +564,8 @@ const MeshBackendApi meshcore_backend_api = { .send_text = sendText, .get_channels = getChannels, .set_channel = setChannel, + .get_contact_uri = getContactUri, + .is_valid_contact_address = isValidContactAddress, + .on_node_name_changed = onNodeNameChanged, + .remove_channel = removeChannel, }; diff --git a/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp b/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp index a61565263..d980f7428 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp @@ -252,3 +252,53 @@ bool meshcore_parse_ack(const MeshCorePacket& packet, uint32_t* ackHash) { *ackHash = readU32(packet.payload); return true; } + +bool meshcore_build_contact_uri(const char* name, const uint8_t publicKey[MESHCORE_PUBLIC_KEY_SIZE], uint8_t type, char* uri, size_t uriSize) { + size_t length = 0; + const auto append = [&](char character) { + if (length + 1 >= uriSize) { + return false; + } + uri[length++] = character; + return true; + }; + const auto appendText = [&](const char* text) { + for (; *text != '\0'; text++) { + if (!append(*text)) { + return false; + } + } + return true; + }; + + static const char HEX_DIGITS[] = "0123456789abcdef"; + if (!appendText("meshcore://contact/add?name=")) { + return false; + } + // Percent-encoded, with a plus for a space like the MeshCore app does + for (const char* character = name; *character != '\0'; character++) { + const auto byte = static_cast(*character); + const bool unreserved = (byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') || (byte >= '0' && byte <= '9') || + byte == '-' || byte == '.' || byte == '_' || byte == '~'; + const bool ok = unreserved ? append(*character) + : (byte == ' ' ? append('+') : (append('%') && append(HEX_DIGITS[byte >> 4]) && append(HEX_DIGITS[byte & 0x0F]))); + if (!ok) { + return false; + } + } + if (!appendText("&public_key=")) { + return false; + } + for (size_t i = 0; i < MESHCORE_PUBLIC_KEY_SIZE; i++) { + if (!append(HEX_DIGITS[publicKey[i] >> 4]) || !append(HEX_DIGITS[publicKey[i] & 0x0F])) { + return false; + } + } + char suffix[16]; + snprintf(suffix, sizeof(suffix), "&type=%u", (unsigned)type); + if (!appendText(suffix)) { + return false; + } + uri[length] = '\0'; + return true; +} diff --git a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp index 6b8816096..bb4497f01 100644 --- a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp +++ b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp @@ -3,7 +3,9 @@ #include #include +#include #include +#include TEST_CASE("MeshCore packets survive a serialize and parse round trip") { MeshCorePacket packet; @@ -138,3 +140,22 @@ TEST_CASE("MeshCore direct messages decrypt at the recipient and produce matchin REQUIRE(meshcore_parse_ack(ack, &parsedAckHash)); CHECK_EQ(parsedAckHash, sentAckHash); } + +TEST_CASE("MeshCore contact URIs match the format of the MeshCore app") { + uint8_t key[MESHCORE_PUBLIC_KEY_SIZE]; + const char* hex = "9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1"; + for (size_t i = 0; i < sizeof(key); i++) { + char pair[3] = { hex[i * 2], hex[i * 2 + 1], '\0' }; + key[i] = (uint8_t)strtoul(pair, nullptr, 16); + } + + char uri[256]; + REQUIRE(meshcore_build_contact_uri("Example Contact", key, 1, uri, sizeof(uri))); + CHECK_EQ(std::string(uri), "meshcore://contact/add?name=Example+Contact&public_key=9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1&type=1"); + + REQUIRE(meshcore_build_contact_uri("Ken's deck&co", key, 2, uri, sizeof(uri))); + CHECK_EQ(std::string(uri).substr(0, 52), "meshcore://contact/add?name=Ken%27s+deck%26co&public"); + + // Too small for the URI + CHECK_FALSE(meshcore_build_contact_uri("Example Contact", key, 1, uri, 60)); +} diff --git a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp index 4b4e0eb5b..7499f6f2b 100644 --- a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp +++ b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp @@ -369,6 +369,14 @@ void onStart(void* context) { sendNodeInfo(static_cast(context)); } +bool isValidContactAddress(void*, const MeshAddress* address) { + return address->length == 4; +} + +void onNodeNameChanged(void* context) { + sendNodeInfo(static_cast(context)); +} + bool onReceive(void* context, const uint8_t* frame, size_t length, float rssi, float snr) { auto* backend = static_cast(context); MeshtasticHeader header; @@ -489,6 +497,16 @@ error_t setChannel(void* context, const MeshChannel* channel) { return ERROR_NONE; } +error_t removeChannel(void* context, uint8_t index) { + auto* backend = static_cast(context); + const size_t removed = std::erase_if(backend->channels, [index](const Channel& existing) { return existing.index == index; }); + if (removed == 0) { + return ERROR_NOT_FOUND; + } + saveChannels(backend); + return ERROR_NONE; +} + // endregion } // namespace @@ -505,4 +523,7 @@ const MeshBackendApi meshtastic_backend_api = { .send_text = sendText, .get_channels = getChannels, .set_channel = setChannel, + .is_valid_contact_address = isValidContactAddress, + .on_node_name_changed = onNodeNameChanged, + .remove_channel = removeChannel, }; diff --git a/Modules/mesh-module/include/mesh/backend.h b/Modules/mesh-module/include/mesh/backend.h index 037f74ab8..90b855791 100644 --- a/Modules/mesh-module/include/mesh/backend.h +++ b/Modules/mesh-module/include/mesh/backend.h @@ -71,6 +71,26 @@ struct MeshBackendApi { /** Optional */ error_t (*set_channel)(void* backend, const struct MeshChannel* channel); + + /** + * Optional. Write a URI that other apps turn into a contact, to show as a QR code. + * @param[in] contact the contact's address, or NULL for this node + * @param[in] name the name of the contact, or of this node + * @retval ERROR_BUFFER_OVERFLOW when the URI doesn't fit + */ + error_t (*get_contact_uri)(void* backend, const struct MeshAddress* contact, const char* name, char* uri, size_t uri_size); + + /** Optional. Whether @a address can be the address of a contact, e.g. a public key of the right length. */ + bool (*is_valid_contact_address)(void* backend, const struct MeshAddress* address); + + /** Optional. The name this node announces changed, so the backend can announce it again. */ + void (*on_node_name_changed)(void* backend); + + /** + * Optional. The service doesn't call it for channel 0. + * @retval ERROR_NOT_FOUND when there is no such channel + */ + error_t (*remove_channel)(void* backend, uint8_t index); }; /** diff --git a/Modules/mesh-module/include/mesh/mesh.h b/Modules/mesh-module/include/mesh/mesh.h index 506c6216f..ae2b04ee6 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -21,7 +21,7 @@ extern "C" { */ /** Incremented when the API changes in a way that matters to apps */ -#define MESH_API_VERSION 1 +#define MESH_API_VERSION 2 #define MESH_BACKEND_ID_MAX_LENGTH 15 #define MESH_ADDRESS_MAX_LENGTH 32 @@ -108,6 +108,13 @@ struct MeshNode { float snr; }; +/** A node that the user saved for direct messages */ +struct MeshContact { + char backend[MESH_BACKEND_ID_MAX_LENGTH + 1]; + struct MeshAddress address; + char name[MESH_NAME_MAX_LENGTH + 1]; +}; + /** * A channel of the active backend. How the name and key combine is backend-specific: Meshtastic derives * the channel identity from both, while for MeshCore the name is only a local label. @@ -212,6 +219,18 @@ error_t mesh_raw_send(const char* backend, const uint8_t* data, size_t length); /** @retval ERROR_NOT_FOUND when there is no such message */ error_t mesh_message_get(uint32_t seq, struct MeshMessage* message); +/** + * Read the messages of one conversation of the active backend, newest first. + * @param[in] peer the peer of a direct conversation, or NULL for the conversation of @a channel + * @param[in] channel the channel index, ignored when @a peer is set + * @param[in] before_seq only return messages older than this, or 0 for the newest + * @param[out] messages buffer for the messages + * @param[in] max_count capacity of @a messages + * @param[out] count the number of messages returned + * @retval ERROR_INVALID_STATE when the service is not running + */ +error_t mesh_conversation_get(const struct MeshAddress* peer, uint8_t channel, uint32_t before_seq, struct MeshMessage* messages, size_t max_count, size_t* count); + /** * Read stored messages, newest first. * @param[in] before_seq only return messages older than this, or 0 for the newest @@ -223,6 +242,40 @@ error_t mesh_history_get(uint32_t before_seq, struct MeshMessage* messages, size // endregion +// region Contacts + +/** + * Get the saved contacts of the active backend. A node that sends a direct message is saved automatically. + * @retval ERROR_INVALID_STATE when the service is not running + */ +error_t mesh_get_contacts(struct MeshContact* contacts, size_t max_count, size_t* count); + +/** + * Save a contact for the active backend, or rename it when the address is saved already. The choice is persisted. + * @param[in] name the name to show, or NULL to take it from the nodes that were heard + * @retval ERROR_INVALID_STATE when the service is not running + * @retval ERROR_INVALID_ARGUMENT when the active backend doesn't accept the address, e.g. a public key of the wrong length + */ +error_t mesh_add_contact(const struct MeshAddress* address, const char* name); + +/** + * Remove a contact of the active backend. The stored messages remain. + * @retval ERROR_NOT_FOUND when there is no such contact + */ +error_t mesh_remove_contact(const struct MeshAddress* address); + +/** + * Get a URI of a contact of the active backend that other mesh apps can scan as a QR code to add it. + * @param[in] contact the contact's address, or NULL for this node + * @param[out] uri the URI + * @retval ERROR_NOT_SUPPORTED when the backend has no such URI + * @retval ERROR_NOT_FOUND when @a contact isn't a saved contact + * @retval ERROR_BUFFER_OVERFLOW when the URI doesn't fit + */ +error_t mesh_get_contact_uri(const struct MeshAddress* contact, char* uri, size_t uri_size); + +// endregion + // region Nodes and channels /** Get the nodes heard by the active backend since it started */ @@ -238,6 +291,14 @@ error_t mesh_get_channels(struct MeshChannel* channels, size_t max_count, size_t */ error_t mesh_set_channel(const struct MeshChannel* channel); +/** + * Leave a channel. The stored messages remain. Channel 0 is the primary channel and can't be removed. + * @retval ERROR_NOT_FOUND when there is no such channel + * @retval ERROR_INVALID_ARGUMENT for channel 0 + * @retval ERROR_NOT_SUPPORTED when the backend has no channels + */ +error_t mesh_remove_channel(uint8_t index); + // endregion // region Events @@ -253,6 +314,8 @@ enum MeshEventType { MESH_EVENT_NODE, /** The active backend received a frame that isn't a message, see MeshEvent.frame. It isn't stored. */ MESH_EVENT_FRAME, + /** A contact was added or removed */ + MESH_EVENT_CONTACT, }; enum MeshFrameKind { diff --git a/Modules/mesh-module/private/mesh/mesh_store.h b/Modules/mesh-module/private/mesh/mesh_store.h index 7b4852db4..1925a49de 100644 --- a/Modules/mesh-module/private/mesh/mesh_store.h +++ b/Modules/mesh-module/private/mesh/mesh_store.h @@ -33,6 +33,10 @@ private: uint32_t segment; uint32_t offset; enum MeshDeliveryState delivery; + /** Channel index, meaningful for messages without a peer */ + uint8_t channel; + /** Hash of the peer address, 0 for channel messages */ + uint32_t peerHash; }; static constexpr uint32_t PENDING = UINT32_MAX; @@ -50,6 +54,7 @@ private: tt::std::string getSegmentPath(uint32_t segment) const; /** @return the end offset of the last valid record */ uint32_t scanSegment(uint32_t segment); + static IndexEntry makeIndexEntry(const MeshMessage& message, uint32_t segment, uint32_t offset); void applyRecord(uint8_t type, const uint8_t* payload, size_t length, uint32_t segment, uint32_t offset); error_t appendRecord(uint8_t type, const uint8_t* payload, size_t length, uint32_t seq); IndexEntry* findEntry(uint32_t seq); @@ -80,6 +85,12 @@ public: /** Newest first, see mesh_history_get() */ size_t getHistory(uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const; + /** + * Newest first, like getHistory(), limited to the messages of one conversation of @a backend. + * @param[in] peer the peer of a direct conversation, or nullptr for the conversation of @a channel + */ + size_t getConversation(const char* backend, const MeshAddress* peer, uint8_t channel, uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const; + /** Sequence numbers of outgoing messages of @a backend in the given delivery state, oldest first */ tt::std::vector findOutgoing(const char* backend, enum MeshDeliveryState delivery) const; diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 61dd96a47..5805f71cc 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -210,6 +210,8 @@ struct MeshHost { MeshState state = MESH_STATE_OFF; MeshStore store; tt::std::vector nodes; + /** The saved contacts of every backend */ + tt::std::vector contacts; char activeBackend[MESH_BACKEND_ID_MAX_LENGTH + 1] = {}; tt::std::deque> calls; @@ -261,6 +263,120 @@ void setStateLocked(MeshHost* host, MeshState state) { unlock(); } +// region Contacts + +constexpr size_t MAX_CONTACTS = 64; +constexpr auto* CONTACTS_FILE_NAME = "contacts.bin"; + +bool isSameAddress(const MeshAddress& a, const MeshAddress& b) { + return a.length == b.length && memcmp(a.bytes, b.bytes, a.length) == 0; +} + +/** Requires the global lock */ +MeshContact* findContact(MeshHost* host, const MeshAddress& address) { + for (auto& contact : host->contacts) { + if (strncmp(contact.backend, host->activeBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0 && isSameAddress(contact.address, address)) { + return &contact; + } + } + return nullptr; +} + +void loadContacts(MeshHost* host) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (service_paths_get_user_data_path(MESH_SERVICE_ID, CONTACTS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { + return; + } + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return; + } + MeshContact contact; + while (host->contacts.size() < MAX_CONTACTS && fread(&contact, sizeof(contact), 1, file) == 1) { + contact.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; + contact.name[MESH_NAME_MAX_LENGTH] = '\0'; + if (contact.address.length > 0 && contact.address.length <= MESH_ADDRESS_MAX_LENGTH) { + host->contacts.push_back(contact); + } + } + fclose(file); +} + +/** Requires the global lock */ +void saveContacts(MeshHost* host) { + char directory[FILE_MAX_PATH_STRING_LENGTH]; + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (service_paths_get_user_data_directory(MESH_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE || + service_paths_get_user_data_path(MESH_SERVICE_ID, CONTACTS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { + return; + } + const error_t made = directory_make(directory, true); + if (made != ERROR_NONE && made != ERROR_ALREADY_EXISTS) { + LOG_E(TAG, "Failed to create %s", directory); + return; + } + FILE* file = fopen(path, "wb"); + if (file == nullptr) { + LOG_E(TAG, "Failed to save the contacts"); + return; + } + if (!host->contacts.empty() && fwrite(host->contacts.data(), sizeof(MeshContact), host->contacts.size(), file) != host->contacts.size()) { + LOG_E(TAG, "Failed to save the contacts"); + } + fclose(file); +} + +/** + * Save a contact of the active backend. An existing contact is renamed when @a rename is set. + * Requires the global lock. + */ +error_t addContact(MeshHost* host, const MeshAddress& address, const char* name, bool rename) { + if (address.length == 0 || address.length > MESH_ADDRESS_MAX_LENGTH) { + return ERROR_INVALID_ARGUMENT; + } + + char resolvedName[MESH_NAME_MAX_LENGTH + 1] = {}; + if (name != nullptr) { + strncpy(resolvedName, name, MESH_NAME_MAX_LENGTH); + } + if (resolvedName[0] == '\0') { + for (const auto& node : host->nodes) { + if (isSameAddress(node.address, address) && strncmp(node.backend, host->activeBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0) { + strncpy(resolvedName, node.name, MESH_NAME_MAX_LENGTH); + break; + } + } + } + if (resolvedName[0] == '\0') { + // The first bytes identify the node well enough to tell nodes apart + for (size_t i = 0; i < address.length && i < 4; i++) { + snprintf(resolvedName + i * 2, 3, "%02x", address.bytes[i]); + } + } + + auto* existing = findContact(host, address); + if (existing != nullptr) { + if (!rename || name == nullptr || name[0] == '\0') { + return ERROR_NONE; + } + strncpy(existing->name, resolvedName, MESH_NAME_MAX_LENGTH); + } else { + if (host->contacts.size() >= MAX_CONTACTS) { + return ERROR_RESOURCE; + } + MeshContact contact {}; + strncpy(contact.backend, host->activeBackend, MESH_BACKEND_ID_MAX_LENGTH); + contact.address = address; + strncpy(contact.name, resolvedName, MESH_NAME_MAX_LENGTH); + host->contacts.push_back(contact); + } + saveContacts(host); + emitEvent(MESH_EVENT_CONTACT, host->state, 0, MESH_DELIVERY_RECEIVED); + return ERROR_NONE; +} + +// endregion + /** Requires the global lock */ void setDelivery(MeshHost* host, uint32_t seq, MeshDeliveryState delivery) { if (host->store.setDelivery(seq, delivery) == ERROR_NONE) { @@ -711,6 +827,7 @@ error_t onStart(ServiceInstance*, void* data) { releaseResources(host); return ERROR_RESOURCE; } + loadContacts(host); if (!attachRadio(host)) { LOG_W(TAG, "No LoRa radio found yet"); @@ -883,6 +1000,10 @@ uint32_t mesh_host_receive_text( uint32_t seq = 0; if (host->store.add(&message) == ERROR_NONE) { seq = message.seq; + // A sender of a direct message is saved, so the conversation can be found and answered + if (from != nullptr && from->length > 0) { + addContact(host, *from, sender_name, false); + } emitEvent(MESH_EVENT_MESSAGE, host->state, seq, MESH_DELIVERY_RECEIVED); } unlock(); @@ -1022,6 +1143,14 @@ error_t mesh_set_node_name(const char* name) { strncpy(globals().settings.node_name, name, MESH_NAME_MAX_LENGTH); globals().settings.node_name[MESH_NAME_MAX_LENGTH] = '\0'; const MeshSettings copy = globals().settings; + // The backend announces the new name + if (service != nullptr) { + post(service, [host = service] { + if (host->api != nullptr && host->api->on_node_name_changed != nullptr) { + host->api->on_node_name_changed(host->backend); + } + }); + } unlock(); return mesh_settings_save(©); } @@ -1254,6 +1383,108 @@ error_t mesh_history_get(uint32_t before_seq, MeshMessage* messages, size_t max_ return result; } +error_t mesh_conversation_get(const MeshAddress* peer, uint8_t channel, uint32_t before_seq, MeshMessage* messages, size_t max_count, size_t* count) { + lock(); + error_t result = ERROR_INVALID_STATE; + if (service != nullptr) { + *count = service->store.getConversation(service->activeBackend, peer, channel, before_seq, messages, max_count); + result = ERROR_NONE; + } + unlock(); + return result; +} + +error_t mesh_get_contacts(MeshContact* contacts, size_t max_count, size_t* count) { + lock(); + error_t result = ERROR_INVALID_STATE; + if (service != nullptr) { + *count = 0; + for (const auto& contact : service->contacts) { + if (*count < max_count && strncmp(contact.backend, service->activeBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0) { + contacts[(*count)++] = contact; + } + } + result = ERROR_NONE; + } + unlock(); + return result; +} + +error_t mesh_add_contact(const MeshAddress* address, const char* name) { + if (address->length == 0 || address->length > MESH_ADDRESS_MAX_LENGTH) { + return ERROR_INVALID_ARGUMENT; + } + bool valid = true; + const error_t callResult = callOnServiceThread([&](MeshHost* host) { + if (host->api != nullptr && host->api->is_valid_contact_address != nullptr) { + valid = host->api->is_valid_contact_address(host->backend, address); + } + }); + if (callResult != ERROR_NONE) { + return callResult; + } + if (!valid) { + return ERROR_INVALID_ARGUMENT; + } + + lock(); + error_t result = ERROR_INVALID_STATE; + if (service != nullptr) { + result = addContact(service, *address, name, true); + } + unlock(); + return result; +} + +error_t mesh_remove_contact(const MeshAddress* address) { + lock(); + error_t result = ERROR_INVALID_STATE; + if (service != nullptr) { + auto* contact = findContact(service, *address); + if (contact == nullptr) { + result = ERROR_NOT_FOUND; + } else { + service->contacts.erase(service->contacts.begin() + (contact - service->contacts.data())); + saveContacts(service); + emitEvent(MESH_EVENT_CONTACT, service->state, 0, MESH_DELIVERY_RECEIVED); + result = ERROR_NONE; + } + } + unlock(); + return result; +} + +error_t mesh_get_contact_uri(const MeshAddress* contact, char* uri, size_t uri_size) { + char name[MESH_NAME_MAX_LENGTH + 1] = {}; + lock(); + if (service == nullptr) { + unlock(); + return ERROR_INVALID_STATE; + } + if (contact == nullptr) { + strncpy(name, globals().settings.node_name, MESH_NAME_MAX_LENGTH); + } else { + const auto* saved = findContact(service, *contact); + if (saved == nullptr) { + unlock(); + return ERROR_NOT_FOUND; + } + strncpy(name, saved->name, MESH_NAME_MAX_LENGTH); + } + unlock(); + + error_t result = ERROR_INVALID_STATE; + const error_t callResult = callOnServiceThread([&](MeshHost* host) { + if (host->api == nullptr) { + return; + } + result = (host->api->get_contact_uri != nullptr) + ? host->api->get_contact_uri(host->backend, contact, name, uri, uri_size) + : ERROR_NOT_SUPPORTED; + }); + return (callResult != ERROR_NONE) ? callResult : result; +} + error_t mesh_get_nodes(MeshNode* nodes, size_t max_count, size_t* count) { lock(); error_t result = ERROR_INVALID_STATE; @@ -1292,6 +1523,22 @@ error_t mesh_set_channel(const MeshChannel* channel) { return (callResult != ERROR_NONE) ? callResult : result; } +error_t mesh_remove_channel(uint8_t index) { + if (index == 0) { + return ERROR_INVALID_ARGUMENT; + } + error_t result = ERROR_INVALID_STATE; + const error_t callResult = callOnServiceThread([&](MeshHost* host) { + if (host->api == nullptr) { + return; + } + result = (host->api->remove_channel != nullptr) + ? host->api->remove_channel(host->backend, index) + : ERROR_NOT_SUPPORTED; + }); + return (callResult != ERROR_NONE) ? callResult : result; +} + // endregion // region Events diff --git a/Modules/mesh-module/source/mesh_settings.cpp b/Modules/mesh-module/source/mesh_settings.cpp index 609bc1d63..5e100a2e8 100644 --- a/Modules/mesh-module/source/mesh_settings.cpp +++ b/Modules/mesh-module/source/mesh_settings.cpp @@ -11,18 +11,31 @@ #include #include +#ifdef ESP_PLATFORM +#include +#else +#include +#endif + constexpr auto* TAG = "mesh_settings"; constexpr auto* SETTINGS_FILE_NAME = "settings.properties"; constexpr auto* KEY_ENABLED = "enabled"; constexpr auto* KEY_BACKEND = "backend"; constexpr auto* KEY_PRESET = "preset"; constexpr auto* KEY_NODE_NAME = "nodeName"; -constexpr auto* DEFAULT_NODE_NAME = "Tactility"; +// Earlier versions stored this as the name when the user hadn't chosen one +constexpr auto* LEGACY_DEFAULT_NODE_NAME = "Tactility"; -void mesh_settings_load(MeshSettings* settings) { - *settings = {}; - strncpy(settings->node_name, DEFAULT_NODE_NAME, MESH_NAME_MAX_LENGTH); +static uint32_t random_u32() { +#ifdef ESP_PLATFORM + return esp_random(); +#else + std::random_device device; + return device(); +#endif +} +static void read_settings_file(MeshSettings* settings) { char path[FILE_MAX_PATH_STRING_LENGTH]; if (service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { return; @@ -49,6 +62,19 @@ void mesh_settings_load(MeshSettings* settings) { properties_file_close(file); } +void mesh_settings_load(MeshSettings* settings) { + *settings = {}; + read_settings_file(settings); + + // A name that the user didn't choose is made up once and then kept + if (settings->node_name[0] == '\0' || strcmp(settings->node_name, LEGACY_DEFAULT_NODE_NAME) == 0) { + snprintf(settings->node_name, sizeof(settings->node_name), "TactilityUser-%08x", (unsigned)random_u32()); + if (mesh_settings_save(settings) != ERROR_NONE) { + LOG_E(TAG, "Failed to save the generated name"); + } + } +} + error_t mesh_settings_save(const MeshSettings* settings) { char directory[FILE_MAX_PATH_STRING_LENGTH]; char path[FILE_MAX_PATH_STRING_LENGTH]; diff --git a/Modules/mesh-module/source/mesh_store.cpp b/Modules/mesh-module/source/mesh_store.cpp index 3daa92d88..675fd7434 100644 --- a/Modules/mesh-module/source/mesh_store.cpp +++ b/Modules/mesh-module/source/mesh_store.cpp @@ -29,6 +29,22 @@ constexpr size_t MESSAGE_PAYLOAD_MAX_SIZE = 512; constexpr auto* SEGMENT_PREFIX = "seg-"; constexpr auto* SEGMENT_SUFFIX = ".log"; +bool isSameAddress(const MeshAddress& a, const MeshAddress& b) { + return a.length == b.length && memcmp(a.bytes, b.bytes, a.length) == 0; +} + +/** FNV-1a, never 0 for an address so that 0 can mean "no peer" */ +uint32_t hashPeer(const MeshAddress& address) { + if (address.length == 0) { + return 0; + } + uint32_t hash = 2166136261u; + for (size_t i = 0; i < address.length; i++) { + hash = (hash ^ address.bytes[i]) * 16777619u; + } + return hash | 1; +} + uint8_t* allocateBuffer(size_t size) { constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; return static_cast(memory_alloc_with_policy(size, &policy)); @@ -277,11 +293,22 @@ uint32_t MeshStore::scanSegment(uint32_t segment) { return offset; } +MeshStore::IndexEntry MeshStore::makeIndexEntry(const MeshMessage& message, uint32_t segment, uint32_t offset) { + return { + .seq = message.seq, + .segment = segment, + .offset = offset, + .delivery = message.delivery, + .channel = message.channel, + .peerHash = hashPeer(message.peer), + }; +} + void MeshStore::applyRecord(uint8_t type, const uint8_t* payload, size_t length, uint32_t segment, uint32_t offset) { if (type == RECORD_TYPE_MESSAGE) { MeshMessage message; if (deserializeMessage(payload, length, &message)) { - index.push_back({ .seq = message.seq, .segment = segment, .offset = offset, .delivery = message.delivery }); + index.push_back(makeIndexEntry(message, segment, offset)); nextSeq = std::max(nextSeq, message.seq + 1); } } else if (type == RECORD_TYPE_DELIVERY && length >= 5) { @@ -318,7 +345,7 @@ error_t MeshStore::appendRecord(uint8_t type, const uint8_t* payload, size_t len if (type == RECORD_TYPE_MESSAGE) { MeshMessage message; deserializeMessage(payload, length, &message); - index.push_back({ .seq = seq, .segment = PENDING, .offset = (uint32_t)bufferUsed, .delivery = message.delivery }); + index.push_back(makeIndexEntry(message, PENDING, (uint32_t)bufferUsed)); } if (bufferUsed == 0) { @@ -400,6 +427,29 @@ error_t MeshStore::get(uint32_t seq, MeshMessage* message) const { return readEntry(*entry, message); } +size_t MeshStore::getConversation(const char* backend, const MeshAddress* peer, uint8_t channel, uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const { + const uint32_t peerHash = (peer != nullptr) ? hashPeer(*peer) : 0; + size_t count = 0; + for (auto iterator = index.rbegin(); iterator != index.rend() && count < maxCount; ++iterator) { + if (beforeSeq != 0 && iterator->seq >= beforeSeq) { + continue; + } + if (iterator->peerHash != peerHash || (peer == nullptr && iterator->channel != channel)) { + continue; + } + MeshMessage* message = &messages[count]; + // The hash can collide, so the stored message decides + if (readEntry(*iterator, message) != ERROR_NONE || strncmp(message->backend, backend, MESH_BACKEND_ID_MAX_LENGTH) != 0) { + continue; + } + if (peer != nullptr ? !isSameAddress(message->peer, *peer) : message->peer.length != 0) { + continue; + } + count++; + } + return count; +} + size_t MeshStore::getHistory(uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const { size_t count = 0; for (auto iterator = index.rbegin(); iterator != index.rend() && count < maxCount; ++iterator) { diff --git a/Modules/mesh-module/source/module.cpp b/Modules/mesh-module/source/module.cpp index 0f6c5a9cc..a0d863e96 100644 --- a/Modules/mesh-module/source/module.cpp +++ b/Modules/mesh-module/source/module.cpp @@ -56,9 +56,15 @@ static const ModuleSymbol mesh_module_symbols[] = { DEFINE_MODULE_SYMBOL(mesh_raw_send), DEFINE_MODULE_SYMBOL(mesh_message_get), DEFINE_MODULE_SYMBOL(mesh_history_get), + DEFINE_MODULE_SYMBOL(mesh_conversation_get), + DEFINE_MODULE_SYMBOL(mesh_get_contacts), + DEFINE_MODULE_SYMBOL(mesh_add_contact), + DEFINE_MODULE_SYMBOL(mesh_remove_contact), + DEFINE_MODULE_SYMBOL(mesh_get_contact_uri), DEFINE_MODULE_SYMBOL(mesh_get_nodes), DEFINE_MODULE_SYMBOL(mesh_get_channels), DEFINE_MODULE_SYMBOL(mesh_set_channel), + DEFINE_MODULE_SYMBOL(mesh_remove_channel), DEFINE_MODULE_SYMBOL(mesh_event_subscribe), DEFINE_MODULE_SYMBOL(mesh_event_unsubscribe), DEFINE_MODULE_SYMBOL(mesh_event_poll), diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index a6d90277a..9fa9c2d74 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -101,6 +102,16 @@ bool loopbackOnReceive(void* backend, const uint8_t* data, size_t length, float void loopbackOnTick(void*) {} +error_t loopbackGetContactUri(void*, const MeshAddress* contact, const char* name, char* uri, size_t uriSize) { + snprintf(uri, uriSize, "loopback://%s/%02x", name, contact != nullptr ? contact->bytes[0] : 0); + return ERROR_NONE; +} + +bool loopbackIsValidContactAddress(void*, const MeshAddress* address) { return address->length == 1; } + +std::atomic nameChangeCount = 0; +void loopbackOnNodeNameChanged(void*) { nameChangeCount++; } + error_t loopbackSendText(void* backend, uint32_t seq, const MeshAddress*, uint8_t channel, const char* text) { uint8_t frame[1 + MESH_TEXT_MAX_LENGTH]; frame[0] = channel; @@ -121,6 +132,9 @@ const MeshBackendApi loopbackApi = { .send_text = loopbackSendText, .get_channels = nullptr, .set_channel = nullptr, + .get_contact_uri = loopbackGetContactUri, + .is_valid_contact_address = loopbackIsValidContactAddress, + .on_node_name_changed = loopbackOnNodeNameChanged, }; // endregion @@ -209,6 +223,35 @@ TEST_CASE("mesh service sends and receives through the active backend") { REQUIRE_EQ(mesh_set_preset("Default"), ERROR_NONE); } + SUBCASE("a new node name is announced by the backend") { + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + // Without a chosen name, a random one is made up once + char generated[MESH_NAME_MAX_LENGTH + 1]; + CHECK_EQ(mesh_get_node_name(generated, sizeof(generated)), ERROR_NONE); + const std::string generatedName = generated; + REQUIRE_EQ(generatedName.size(), std::string("TactilityUser-").size() + 8); + CHECK_EQ(generatedName.substr(0, 14), "TactilityUser-"); + CHECK_EQ(generatedName.substr(14).find_first_not_of("0123456789abcdef"), std::string::npos); + // The service keeps its settings for the whole process, so the data folder of this test is read directly + MeshSettings first; + MeshSettings second; + mesh_settings_load(&first); + mesh_settings_load(&second); + CHECK_EQ(std::string(first.node_name).substr(0, 14), "TactilityUser-"); + CHECK_EQ(std::string(first.node_name), std::string(second.node_name)); + + const int before = nameChangeCount; + CHECK_EQ(mesh_set_node_name(""), ERROR_INVALID_ARGUMENT); + CHECK_EQ(mesh_set_node_name("Ken's deck"), ERROR_NONE); + char name[MESH_NAME_MAX_LENGTH + 1]; + CHECK_EQ(mesh_get_node_name(name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "Ken's deck"); + CHECK(waitFor([before] { return nameChangeCount > before; })); + } + SUBCASE("concurrent synchronous calls finish") { REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); @@ -325,6 +368,69 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK(mesh_history_get(0, history, 4, &count) == ERROR_NONE); CHECK_EQ(count, 2); + // The sender of the incoming message was saved as a contact + MeshContact contacts[4]; + REQUIRE_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 1); + CHECK_EQ(std::string(contacts[0].name), "peer"); + CHECK_EQ(std::string(contacts[0].backend), "loopback"); + + // Contacts can be added, renamed and removed + const MeshAddress alice = { .length = 1, .bytes = { 0x50 } }; + const MeshAddress unknown = { .length = 1, .bytes = { 0x51 } }; + const MeshAddress tooLong = { .length = 2, .bytes = { 0x52, 0x53 } }; + CHECK_EQ(mesh_add_contact(&tooLong, "bad"), ERROR_INVALID_ARGUMENT); + CHECK_EQ(mesh_add_contact(&alice, "alice"), ERROR_NONE); + CHECK_EQ(mesh_add_contact(&alice, nullptr), ERROR_NONE); + CHECK_EQ(mesh_add_contact(&unknown, nullptr), ERROR_NONE); + REQUIRE_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 3); + CHECK_EQ(std::string(contacts[1].name), "alice"); + // Without a name the address identifies the contact + CHECK_EQ(std::string(contacts[2].name), "51"); + + // Conversations only hold their own messages + uint32_t directSeq = 0; + uint32_t channelSeq = 0; + REQUIRE_EQ(mesh_send_text(&alice, 0, "hi alice", &directSeq), ERROR_NONE); + REQUIRE_EQ(mesh_send_text(nullptr, 5, "hello channel", &channelSeq), ERROR_NONE); + MeshMessage conversation[4]; + CHECK_EQ(mesh_conversation_get(&alice, 0, 0, conversation, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 1); + CHECK_EQ(conversation[0].seq, directSeq); + CHECK_EQ(std::string(conversation[0].text), "hi alice"); + CHECK_EQ(mesh_conversation_get(nullptr, 5, 0, conversation, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 1); + CHECK_EQ(conversation[0].seq, channelSeq); + CHECK_EQ(mesh_conversation_get(nullptr, 6, 0, conversation, 4, &count), ERROR_NONE); + CHECK_EQ(count, 0); + CHECK_EQ(mesh_conversation_get(&unknown, 0, 0, conversation, 4, &count), ERROR_NONE); + CHECK_EQ(count, 0); + // The delivery state shows how far the message got + CHECK(waitFor([directSeq] { return getDelivery(directSeq) == MESH_DELIVERY_SENT; })); + + // The URI of a saved contact and of this node come from the backend + char uri[64]; + CHECK_EQ(mesh_get_contact_uri(&alice, uri, sizeof(uri)), ERROR_NONE); + CHECK_EQ(std::string(uri), "loopback://alice/50"); + CHECK_EQ(mesh_get_contact_uri(nullptr, uri, sizeof(uri)), ERROR_NONE); + CHECK_EQ(std::string(uri).substr(0, 11), "loopback://"); + CHECK_EQ(mesh_get_contact_uri(&unknown, uri, sizeof(uri)), ERROR_NONE); + const MeshAddress stranger = { .length = 1, .bytes = { 0x77 } }; + CHECK_EQ(mesh_get_contact_uri(&stranger, uri, sizeof(uri)), ERROR_NOT_FOUND); + + CHECK_EQ(mesh_remove_contact(&alice), ERROR_NONE); + CHECK_EQ(mesh_remove_contact(&alice), ERROR_NOT_FOUND); + REQUIRE_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_NONE); + CHECK_EQ(count, 2); + // The messages remain + CHECK_EQ(mesh_conversation_get(&alice, 0, 0, conversation, 4, &count), ERROR_NONE); + CHECK_EQ(count, 1); + + // The primary channel stays, and the loopback backend has no channels + CHECK_EQ(mesh_remove_channel(0), ERROR_INVALID_ARGUMENT); + CHECK_EQ(mesh_remove_channel(2), ERROR_NOT_SUPPORTED); + CHECK_EQ(mesh_cancel(seq), ERROR_INVALID_STATE); CHECK_EQ(mesh_raw_send("other", reply, sizeof(reply)), ERROR_INVALID_STATE); CHECK_EQ(mesh_raw_send("loopback", reply, sizeof(reply)), ERROR_NONE); diff --git a/Modules/mesh-module/tests/source/store_test.cpp b/Modules/mesh-module/tests/source/store_test.cpp index ab77535a9..b875e967f 100644 --- a/Modules/mesh-module/tests/source/store_test.cpp +++ b/Modules/mesh-module/tests/source/store_test.cpp @@ -220,3 +220,51 @@ TEST_CASE("MeshStore keeps a full budget of segments when it recovers from a tor CHECK_EQ(reopened.get(message.seq, &read), ERROR_NONE); CHECK_EQ(std::string(read.text), "after"); } + +TEST_CASE("MeshStore reads the messages of one conversation") { + const std::string directory = makeTempDirectory(); + const MeshAddress alice = { .length = 4, .bytes = { 1, 2, 3, 4 } }; + const MeshAddress bob = { .length = 4, .bytes = { 5, 6, 7, 8 } }; + + const auto add = [](MeshStore& store, const char* backend, const MeshAddress* peer, uint8_t channel, const char* text) { + auto message = makeMessage(text, false, MESH_DELIVERY_RECEIVED); + strcpy(message.backend, backend); + message.peer = (peer != nullptr) ? *peer : MeshAddress {}; + message.channel = channel; + REQUIRE_EQ(store.add(&message), ERROR_NONE); + }; + + // The index is built from the log after a reopen, and from the buffer before it + for (int round = 0; round < 2; round++) { + MeshStore store; + REQUIRE_EQ(store.open(directory.c_str()), ERROR_NONE); + if (round == 0) { + add(store, "test", &alice, 0, "alice 1"); + add(store, "test", nullptr, 0, "channel 0"); + add(store, "test", &bob, 0, "bob 1"); + add(store, "test", &alice, 0, "alice 2"); + add(store, "other", &alice, 0, "other backend"); + add(store, "test", nullptr, 3, "channel 3"); + } + + MeshMessage messages[8]; + REQUIRE_EQ(store.getConversation("test", &alice, 0, 0, messages, 8), 2); + CHECK_EQ(std::string(messages[0].text), "alice 2"); + CHECK_EQ(std::string(messages[1].text), "alice 1"); + + REQUIRE_EQ(store.getConversation("test", nullptr, 3, 0, messages, 8), 1); + CHECK_EQ(std::string(messages[0].text), "channel 3"); + REQUIRE_EQ(store.getConversation("test", nullptr, 0, 0, messages, 8), 1); + CHECK_EQ(std::string(messages[0].text), "channel 0"); + CHECK_EQ(store.getConversation("test", nullptr, 7, 0, messages, 8), 0); + + REQUIRE_EQ(store.getConversation("other", &alice, 0, 0, messages, 8), 1); + CHECK_EQ(std::string(messages[0].text), "other backend"); + + // Paging back from a message + REQUIRE_EQ(store.getConversation("test", &alice, 0, 4, messages, 8), 1); + CHECK_EQ(std::string(messages[0].text), "alice 1"); + CHECK_EQ(store.getConversation("test", &alice, 0, 0, messages, 1), 1); + store.close(); + } +} diff --git a/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h b/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h new file mode 100644 index 000000000..7f3d63e11 --- /dev/null +++ b/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h @@ -0,0 +1,52 @@ +#pragma once + +#if TT_FEATURE_MESH + +#include + +#include +#include +#include +#include + +/** A macro, because the manifest's id is a char array that needs a string literal */ +#define CONVERSATION_APP_ID "tactility.meshconversation" +#define ADD_CONTACT_APP_ID "tactility.meshaddcontact" + +namespace tt::app::meshchat { + +/** Arguments of the conversation app: [0] kind, [1] contact address in hex or channel index, [2] title */ +constexpr auto* KIND_CONTACT = "contact"; +constexpr auto* KIND_CHANNEL = "channel"; + +inline ::std::string addressToHex(const MeshAddress& address) { + ::std::string text; + char hex[3]; + for (size_t i = 0; i < address.length; i++) { + snprintf(hex, sizeof(hex), "%02x", address.bytes[i]); + text += hex; + } + return text; +} + +inline bool hexToAddress(const char* hex, MeshAddress* address) { + const size_t length = strlen(hex); + if (length == 0 || length % 2 != 0 || length / 2 > MESH_ADDRESS_MAX_LENGTH) { + return false; + } + *address = {}; + for (size_t i = 0; i < length / 2; i++) { + char pair[3] = { hex[i * 2], hex[i * 2 + 1], '\0' }; + char* end = nullptr; + address->bytes[i] = (uint8_t)strtoul(pair, &end, 16); + if (end != pair + 2) { + return false; + } + } + address->length = (uint8_t)(length / 2); + return true; +} + +} + +#endif diff --git a/Tactility/Include/Tactility/app/meshsettings/MeshChannels.h b/Tactility/Include/Tactility/app/meshsettings/MeshChannels.h new file mode 100644 index 000000000..008cdeffb --- /dev/null +++ b/Tactility/Include/Tactility/app/meshsettings/MeshChannels.h @@ -0,0 +1,18 @@ +#pragma once + +#if TT_FEATURE_MESH + +#include + +namespace tt::app::meshsettings { + +/** + * Join a hashtag channel (e.g. "#test") of the active mesh backend on the next free channel index. + * Must not be called with the LVGL lock held. + * @retval ERROR_INVALID_ARGUMENT for an empty name, or a name the backend can't derive a key for + */ +error_t joinHashtagChannel(const char* name); + +} + +#endif diff --git a/Tactility/Include/Tactility/app/qrcode/QrCode.h b/Tactility/Include/Tactility/app/qrcode/QrCode.h new file mode 100644 index 000000000..5c83d6aae --- /dev/null +++ b/Tactility/Include/Tactility/app/qrcode/QrCode.h @@ -0,0 +1,18 @@ +#pragma once + +#include "app/instance.h" + +#include + +/** + * Show a QR code over the whole app area. + */ +namespace tt::app::qrcode { + +/** + * Show a QR code of @a text. It closes on a touch, a key press or encoder input. + * @return the new app's instance id, or 0 when it couldn't be started + */ +AppInstanceId start(const std::string& text); + +} diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp index 02b138b93..49125e70f 100644 --- a/Tactility/Source/InitApps.cpp +++ b/Tactility/Source/InitApps.cpp @@ -60,6 +60,7 @@ namespace app { namespace notes { extern const ::AppManifest manifest; } namespace power { extern const ::AppManifest manifest; } namespace poweroff { extern const ::AppManifest manifest; } + namespace qrcode { extern const ::AppManifest manifest; } namespace selectiondialog { extern const ::AppManifest manifest; } namespace setup { extern const ::AppManifest manifest; } namespace shell { extern const ::AppManifest manifest; extern const ::AppManifest sh_manifest; } @@ -67,6 +68,11 @@ namespace app { namespace terminal { extern const ::AppManifest manifest; } namespace timedatesettings { extern const ::AppManifest manifest; } #if TT_FEATURE_MESH + namespace meshchat { + extern const ::AppManifest manifest; + extern const ::AppManifest conversationManifest; + extern const ::AppManifest addContactManifest; + } namespace meshinspect { extern const ::AppManifest manifest; } namespace meshsettings { extern const ::AppManifest manifest; } #endif @@ -132,6 +138,7 @@ static void registerInternalApps() { if (device_exists_of_type(&POWER_SUPPLY_TYPE)) { app_manager_add(&app::poweroff::manifest); } + app_manager_add(&app::qrcode::manifest); app_manager_add(&app::selectiondialog::manifest); app_manager_add(&app::setup::manifest); app_manager_add(&app::shell::manifest); @@ -166,6 +173,9 @@ static void registerInternalApps() { #if TT_FEATURE_MESH if (settings::radio::hasRadio()) { + app_manager_add(&app::meshchat::manifest); + app_manager_add(&app::meshchat::conversationManifest); + app_manager_add(&app::meshchat::addContactManifest); app_manager_add(&app::meshinspect::manifest); app_manager_add(&app::meshsettings::manifest); } diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index c38bba5cf..28ae956e1 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -31,6 +31,7 @@ constexpr IconEntry ICONS[] = { {"tactility.chat", LVGL_ICON_SHARED_FORUM}, {"tactility.files", LVGL_ICON_SHARED_FOLDER}, {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, + {"tactility.meshchat", LVGL_ICON_SHARED_HUB}, {"tactility.meshinspect", LVGL_ICON_SHARED_LISTS}, {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, diff --git a/Tactility/Source/app/meshchat/MeshAddContact.cpp b/Tactility/Source/app/meshchat/MeshAddContact.cpp new file mode 100644 index 000000000..605d4ada6 --- /dev/null +++ b/Tactility/Source/app/meshchat/MeshAddContact.cpp @@ -0,0 +1,281 @@ +#if TT_FEATURE_MESH + +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tt::app::meshchat { + +extern const ::AppManifest addContactManifest; + +namespace { + +constexpr auto* TAG = "MeshAddContact"; +constexpr size_t CONTACT_LIMIT = 64; +constexpr size_t NODE_LIMIT = 128; + +struct Context { + uint32_t appInstanceId; + TaskEventGroup* eventGroup = nullptr; + uint32_t addBit = 0; + uint32_t detailsBit = 0; + uint32_t dialogId = 0; + /** Written by the LVGL thread before it signals addBit or detailsBit */ + ::std::atomic selectedIndex = 0; + /** The nodes that aren't contacts yet */ + tt::std::vector candidates; + // Valid while the window is shown + lv_obj_t* list = nullptr; +}; + +::std::string getNodeName(const MeshNode& node) { + return node.name[0] != '\0' ? ::std::string(node.name) : addressToHex(node.address); +} + +/** Queries the service thread, so it's called without the LVGL lock */ +void loadCandidates(Context* ctx) { + tt::std::vector contacts(CONTACT_LIMIT); + size_t contactCount = 0; + if (mesh_get_contacts(contacts.data(), CONTACT_LIMIT, &contactCount) != ERROR_NONE) { + contactCount = 0; + } + + ctx->candidates.assign(NODE_LIMIT, MeshNode {}); + size_t count = 0; + if (mesh_get_nodes(ctx->candidates.data(), NODE_LIMIT, &count) != ERROR_NONE) { + count = 0; + } + ctx->candidates.resize(count); + for (size_t i = ctx->candidates.size(); i > 0; i--) { + const auto& node = ctx->candidates[i - 1]; + for (size_t c = 0; c < contactCount; c++) { + if (contacts[c].address.length == node.address.length && memcmp(contacts[c].address.bytes, node.address.bytes, node.address.length) == 0) { + ctx->candidates.erase(ctx->candidates.begin() + (i - 1)); + break; + } + } + } +} + +void onSelected(lv_event_t* event, uint32_t Context::*bit) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->selectedIndex = (int32_t)reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))); + task_event_group_signal(ctx->eventGroup, ctx->*bit); +} + +// Short click, so that the end of a long press doesn't add the contact +void onNodeClicked(lv_event_t* event) { + onSelected(event, &Context::addBit); +} + +void onNodeLongPressed(lv_event_t* event) { + onSelected(event, &Context::detailsBit); +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +/** Requires the LVGL lock */ +void updateWidgets(Context* ctx) { + if (ctx->list == nullptr) { + return; + } + lv_obj_clean(ctx->list); + + auto* card = lvgl_card_create(ctx->list); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT); + // The list items' pressed and focused backgrounds follow the card's rounded corners + lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT); + + // The card provides the background + auto* list = lvgl_list_create(card); + lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT); + + if (ctx->candidates.empty()) { + lvgl_list_add_text(list, "No nodes heard yet. Nodes that you hear on the mesh are listed here."); + return; + } + for (size_t i = 0; i < ctx->candidates.size(); i++) { + const auto& node = ctx->candidates[i]; + auto* button = lvgl_list_add_button(list, nullptr, getNodeName(node).c_str()); + lv_obj_set_flex_flow(button, LV_FLEX_FLOW_COLUMN); + lv_obj_set_user_data(button, reinterpret_cast(static_cast(i))); + lv_obj_add_event_cb(button, onNodeClicked, LV_EVENT_SHORT_CLICKED, ctx); + lv_obj_add_event_cb(button, onNodeLongPressed, LV_EVENT_LONG_PRESSED, ctx); + + auto* name = lv_obj_get_child(button, lv_obj_get_child_count(button) - 1); + // The button stacks the name and the hash, so the name doesn't grow along the row + lv_obj_set_flex_grow(name, 0); + lv_obj_set_width(name, LV_PCT(100)); + lv_label_set_long_mode(name, LV_LABEL_LONG_MODE_DOTS); + + // The remaining data is cut off with an ellipsis, and shown completely on a long press + auto* hash = lv_label_create(button); + lv_obj_set_width(hash, LV_PCT(100)); + lv_obj_set_style_text_font(hash, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); + lv_label_set_long_mode(hash, LV_LABEL_LONG_MODE_DOTS); + lv_label_set_text(hash, addressToHex(node.address).c_str()); + } +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Add contact"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + ctx->list = lv_obj_create(parent); + lv_obj_set_width(ctx->list, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->list, 1); + lv_obj_set_flex_flow(ctx->list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(ctx->list, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(ctx->list, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->list, 0, LV_STATE_DEFAULT); + + updateWidgets(ctx); +} + +void destroyWidgets(void* userData) { + static_cast(userData)->list = nullptr; +} + +/** @return true when the contact was added */ +bool addSelected(Context* ctx) { + const auto index = (size_t)ctx->selectedIndex.load(); + if (index >= ctx->candidates.size()) { + return false; + } + const auto& node = ctx->candidates[index]; + const error_t error = mesh_add_contact(&node.address, node.name); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to add contact: %s", error_to_string(error)); + ctx->dialogId = alertdialog::start( + ctx->appInstanceId, + "Couldn't add contact", + error == ERROR_RESOURCE ? "There are too many contacts." : error_to_string(error) + ); + return false; + } + return true; +} + +void showDetails(Context* ctx) { + const auto index = (size_t)ctx->selectedIndex.load(); + if (ctx->dialogId == 0 && index < ctx->candidates.size()) { + const auto& node = ctx->candidates[index]; + ctx->dialogId = alertdialog::start(ctx->appInstanceId, getNodeName(node), addressToHex(node.address)); + } +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + ctx.eventGroup = &eventGroup; + check(task_event_group_claim_bit(&eventGroup, &ctx.addBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.detailsBit) == ERROR_NONE); + + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + MeshEventSubscription meshEvents {}; + check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); + + loadCandidates(&ctx); + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + + if (flags & ctx.addBit) { + shouldClose |= addSelected(&ctx); + } + if (flags & ctx.detailsBit) { + showDetails(&ctx); + } + + // Nodes that are heard while the list is empty show up without reopening it + bool changed = false; + MeshEvent meshEvent {}; + while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { + changed |= meshEvent.type == MESH_EVENT_NODE && ctx.candidates.empty(); + } + + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + if (event.type == APP_EVENT_RESULT) { + if (event.result.launch_id == ctx.dialogId) { + ctx.dialogId = 0; + } + app_manager_stop(event.result.launch_id); + } + } + + if (changed && !shouldClose) { + loadCandidates(&ctx); + lvgl_lock(); + updateWidgets(&ctx); + lvgl_unlock(); + } + } + + window_manager_remove(window); + check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +extern const ::AppManifest addContactManifest = { + .id = ADD_CONTACT_APP_ID, + .name = "Add Contact", + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, + .stack = {} +}; + +} // namespace tt::app::meshchat + +#endif diff --git a/Tactility/Source/app/meshchat/MeshChat.cpp b/Tactility/Source/app/meshchat/MeshChat.cpp new file mode 100644 index 000000000..109dcd29c --- /dev/null +++ b/Tactility/Source/app/meshchat/MeshChat.cpp @@ -0,0 +1,556 @@ +#if TT_FEATURE_MESH + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tt::app::meshchat { + +extern const ::AppManifest manifest; + +namespace { + +constexpr auto* TAG = "MeshChat"; +constexpr size_t CONTACT_LIMIT = 64; +constexpr size_t CHANNEL_LIMIT = 8; + +struct Context { + uint32_t appInstanceId; + TaskEventGroup* eventGroup = nullptr; + uint32_t openBit = 0; + uint32_t settingsBit = 0; + uint32_t addContactBit = 0; + uint32_t joinChannelBit = 0; + uint32_t shareBit = 0; + uint32_t addByKeyBit = 0; + /** Written by the LVGL thread before it signals openBit */ + ::std::atomic openIsContact = true; + ::std::atomic openIndex = 0; + uint32_t joinChannelDialogId = 0; + uint32_t contactNameDialogId = 0; + uint32_t contactKeyDialogId = 0; + uint32_t messageDialogId = 0; + AppStream channelStream {}; + char channelBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream contactNameStream {}; + char contactNameBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream contactKeyStream {}; + char contactKeyBuffer[MESH_ADDRESS_MAX_LENGTH * 2 + 1] = {}; + /** The name that was entered while the key is asked */ + ::std::string pendingContactName; + tt::std::vector contacts; + tt::std::vector channels; + /** The active tab, kept for when the window is rebuilt. Only the LVGL thread uses it. */ + uint32_t activeTab = 0; + // Valid while the window is shown + lv_obj_t* statusLabel = nullptr; + lv_obj_t* contactsTab = nullptr; + lv_obj_t* channelsTab = nullptr; +}; + +bool isMeshOn() { + return mesh_get_state() == MESH_STATE_ON; +} + +::std::string getStatusText() { + return ::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state()); +} + +/** Queries the service thread, so it's called without the LVGL lock */ +void loadData(Context* ctx) { + ctx->contacts.assign(CONTACT_LIMIT, MeshContact {}); + size_t count = 0; + if (mesh_get_contacts(ctx->contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { + count = 0; + } + ctx->contacts.resize(count); + + ctx->channels.assign(CHANNEL_LIMIT, MeshChannel {}); + count = 0; + if (mesh_get_channels(ctx->channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) { + count = 0; + } + ctx->channels.resize(count); +} + +/** Adds a card that holds a list to a tab. @return the list */ +lv_obj_t* createTabList(lv_obj_t* tab) { + auto* card = lvgl_card_create(tab); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT); + // The list items' pressed and focused backgrounds follow the card's rounded corners + lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT); + + // The card provides the background + auto* list = lvgl_list_create(card); + lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT); + return list; +} + +/** A row of buttons that continues on the next line when they don't fit */ +lv_obj_t* createButtonRow(lv_obj_t* parent) { + auto* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(row, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(row, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_scrollable(row, false); + return row; +} + +lv_obj_t* addActionButton(lv_obj_t* parent, const char* text, lv_event_cb_t callback, void* callbackData) { + auto* button = lvgl_text_button_create(parent); + // As big as the text, so that as many as possible fit on a line + lv_obj_set_size(button, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_grow(button, 0); + lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, callbackData); + lv_label_set_text(lv_label_create(button), text); + return button; +} + +lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback, void* callbackData, intptr_t index) { + auto* button = lvgl_list_add_button(list, nullptr, text); + lv_obj_set_user_data(button, reinterpret_cast(index)); + lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, callbackData); + return button; +} + +void onOpenPressed(lv_event_t* event, bool isContact) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* button = lv_event_get_target_obj(event); + ctx->openIsContact = isContact; + ctx->openIndex = (int32_t)reinterpret_cast(lv_obj_get_user_data(button)); + task_event_group_signal(ctx->eventGroup, ctx->openBit); +} + +void onContactPressed(lv_event_t* event) { + onOpenPressed(event, true); +} + +void onChannelPressed(lv_event_t* event) { + onOpenPressed(event, false); +} + +void onAddContactPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->addContactBit); +} + +void onShareQrPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->shareBit); +} + +void onAddByKeyPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->addByKeyBit); +} + +void onJoinChannelPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->joinChannelBit); +} + +void onSettingsPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->settingsBit); +} + +void onTabChanged(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->activeTab = lv_tabview_get_tab_active(lv_event_get_target_obj(event)); +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +/** Requires the LVGL lock */ +void updateWidgets(Context* ctx) { + if (ctx->contactsTab == nullptr) { + return; + } + lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); + lv_obj_set_hidden(ctx->statusLabel, isMeshOn()); + + lv_obj_clean(ctx->contactsTab); + auto* buttonRow = createButtonRow(ctx->contactsTab); + addActionButton(buttonRow, LV_SYMBOL_PLUS " Add by key", onAddByKeyPressed, ctx); + addActionButton(buttonRow, LV_SYMBOL_PLUS " Add seen", onAddContactPressed, ctx); + addActionButton(buttonRow, "Show my QR", onShareQrPressed, ctx); + auto* contactList = createTabList(ctx->contactsTab); + for (size_t i = 0; i < ctx->contacts.size(); i++) { + addListButton(contactList, ctx->contacts[i].name, onContactPressed, ctx, (intptr_t)i); + } + if (ctx->contacts.empty()) { + lvgl_list_add_text(contactList, "No contacts yet. Add a node that you've heard, or wait for a direct message."); + } + + lv_obj_clean(ctx->channelsTab); + auto* channelList = createTabList(ctx->channelsTab); + addListButton(channelList, LV_SYMBOL_PLUS " Join channel", onJoinChannelPressed, ctx, 0); + for (size_t i = 0; i < ctx->channels.size(); i++) { + addListButton(channelList, ctx->channels[i].name, onChannelPressed, ctx, (intptr_t)i); + } +} + +/** Adds a tab that shows an icon in the vertical tab bar */ +lv_obj_t* createTab(lv_obj_t* tabview, const char* icon) { + auto* tab = lv_tabview_add_tab(tabview, icon); + auto* tabBar = lv_tabview_get_tab_bar(tabview); + auto* tabButton = lv_obj_get_child(tabBar, static_cast(lv_obj_get_child_count(tabBar)) - 1); + auto* iconFont = lvgl_theme_is_compact() ? lvgl_get_shared_icon_default_font() : lvgl_get_shared_icon_large_font(); + lv_obj_set_style_text_font(tabButton, iconFont, LV_STATE_DEFAULT); + // Square buttons, so the theme's round selection indicator fits the icon + const int32_t buttonSize = lv_font_get_line_height(iconFont) + 2 * lv_obj_get_style_pad_top(tabButton, LV_PART_MAIN); + lv_obj_set_flex_grow(tabButton, 0); + lv_obj_set_size(tabButton, buttonSize, buttonSize); + lv_obj_set_flex_flow(tab, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(tab, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(tab, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(tab, 0, LV_STATE_DEFAULT); + return tab; +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Mesh Chat"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx); + + ctx->statusLabel = lv_label_create(parent); + lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); + lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT); + + auto* wrapper = lv_obj_create(parent); + lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT); + // Space between the screen edges and the rounded tab bar and content + const int32_t edgeSpace = lvgl_theme_is_compact() ? 2 : LV_DPX(8); + lv_obj_set_style_pad_hor(wrapper, edgeSpace, LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(wrapper, edgeSpace, LV_STATE_DEFAULT); + + auto* tabview = lv_tabview_create(wrapper); + lv_tabview_set_tab_bar_position(tabview, LV_DIR_LEFT); + // The first tab is the default one + ctx->contactsTab = createTab(tabview, LVGL_ICON_SHARED_MAIL); + ctx->channelsTab = createTab(tabview, LVGL_ICON_SHARED_FORUM); + + // As wide as the buttons, which are spread over the bar's height + auto* tabBar = lv_tabview_get_tab_bar(tabview); + lv_obj_set_width(tabBar, LV_SIZE_CONTENT); + lv_obj_set_style_pad_hor(tabBar, lvgl_theme_is_compact() ? 2 : LV_DPX(12), LV_STATE_DEFAULT); + lv_obj_set_flex_align(tabBar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + + // Comes back on the tab it was left on + lv_tabview_set_active(tabview, ctx->activeTab, LV_ANIM_OFF); + lv_obj_add_event_cb(tabview, onTabChanged, LV_EVENT_VALUE_CHANGED, ctx); + + updateWidgets(ctx); +} + +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->statusLabel = nullptr; + ctx->contactsTab = nullptr; + ctx->channelsTab = nullptr; +} + +void startApp(const char* id, int argc, const char* const argv[]) { + AppStartContext context; + if (app_start_context_from_id(id, &context) != ERROR_NONE) { + LOG_E(TAG, "Failed to find %s", id); + return; + } + app_start_context_set_arguments_ext(&context, argc, argv); + AppInstanceId instanceId = 0; + if (app_start_with_context(&context, &instanceId) != ERROR_NONE) { + LOG_E(TAG, "Failed to start %s", id); + } +} + +void startConversation(Context* ctx) { + const auto index = (size_t)ctx->openIndex.load(); + if (ctx->openIsContact) { + if (index < ctx->contacts.size()) { + const ::std::string address = addressToHex(ctx->contacts[index].address); + const char* argv[] = { KIND_CONTACT, address.c_str(), ctx->contacts[index].name }; + startApp(CONVERSATION_APP_ID, 3, argv); + } + } else if (index < ctx->channels.size()) { + const ::std::string channelIndex = ::std::to_string(ctx->channels[index].index); + const char* argv[] = { KIND_CHANNEL, channelIndex.c_str(), ctx->channels[index].name }; + startApp(CONVERSATION_APP_ID, 3, argv); + } +} + +void showMessage(Context* ctx, const char* title, const char* message) { + if (ctx->messageDialogId == 0) { + ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, title, message); + } +} + +void startAddContact() { + startApp(ADD_CONTACT_APP_ID, 0, nullptr); +} + +/** Shows this node as a QR code that other mesh apps can scan */ +void showOwnQr(Context* ctx) { + char uri[256]; + const error_t error = mesh_get_contact_uri(nullptr, uri, sizeof(uri)); + if (error == ERROR_NONE) { + qrcode::start(uri); + } else if (error == ERROR_NOT_SUPPORTED) { + showMessage(ctx, "Not available", "This mesh network can't share a contact as a QR code."); + } else { + LOG_E(TAG, "Failed to get the contact: %s", error_to_string(error)); + showMessage(ctx, "Couldn't create QR code", error == ERROR_INVALID_STATE ? "Turn mesh on first." : error_to_string(error)); + } +} + +void startContactNameDialog(Context* ctx) { + if (ctx->contactNameDialogId != 0 || ctx->contactKeyDialogId != 0) { + return; + } + ctx->contactNameDialogId = inputdialog::start( + ctx->appInstanceId, + "Add contact", + "Name of the contact", + "", + ctx->contactNameStream, + ctx->contactNameBuffer, + sizeof(ctx->contactNameBuffer), + ctx->eventGroup + ); +} + +void onContactNameResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::contactNameBuffer)] = {}; + const size_t length = app_stream_read(&ctx->contactNameStream, name, sizeof(name) - 1); + name[length] = '\0'; + ctx->pendingContactName = name; + } + app_stream_unsubscribe(&ctx->contactNameStream); + if (result != 0 || ctx->pendingContactName.empty()) { + ctx->pendingContactName.clear(); + return; + } + ctx->contactKeyDialogId = inputdialog::start( + ctx->appInstanceId, + "Public key", + "Public key of the contact in hexadecimal", + "", + ctx->contactKeyStream, + ctx->contactKeyBuffer, + sizeof(ctx->contactKeyBuffer), + ctx->eventGroup + ); +} + +void onContactKeyResult(Context* ctx, int32_t result) { + if (result == 0) { + char key[sizeof(Context::contactKeyBuffer)] = {}; + const size_t length = app_stream_read(&ctx->contactKeyStream, key, sizeof(key) - 1); + key[length] = '\0'; + MeshAddress address; + if (!hexToAddress(key, &address)) { + showMessage(ctx, "Invalid key", "Enter the public key as hexadecimal characters, two for every byte."); + } else { + const error_t error = mesh_add_contact(&address, ctx->pendingContactName.c_str()); + if (error == ERROR_INVALID_ARGUMENT) { + showMessage(ctx, "Invalid key", "This mesh network expects a public key of a different length."); + } else if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to add contact: %s", error_to_string(error)); + showMessage(ctx, "Couldn't add contact", error == ERROR_RESOURCE ? "There are too many contacts." : error_to_string(error)); + } + } + } + app_stream_unsubscribe(&ctx->contactKeyStream); + ctx->pendingContactName.clear(); +} + +void startJoinChannel(Context* ctx) { + if (ctx->joinChannelDialogId != 0) { + return; + } + ctx->joinChannelDialogId = inputdialog::start( + ctx->appInstanceId, + "Join channel", + "Hashtag channel name, e.g. #test", + "#", + ctx->channelStream, + ctx->channelBuffer, + sizeof(ctx->channelBuffer), + ctx->eventGroup + ); +} + +void onJoinChannelResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::channelBuffer)] = {}; + const size_t length = app_stream_read(&ctx->channelStream, name, sizeof(name) - 1); + name[length] = '\0'; + const error_t error = meshsettings::joinHashtagChannel(name); + if (error == ERROR_INVALID_ARGUMENT) { + showMessage(ctx, "Couldn't join channel", "Use a channel name that starts with #. This mesh network can't join a channel by name."); + } else if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to join channel %s: %s", name, error_to_string(error)); + showMessage(ctx, "Couldn't join channel", error_to_string(error)); + } + } + app_stream_unsubscribe(&ctx->channelStream); +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + ctx.eventGroup = &eventGroup; + check(task_event_group_claim_bit(&eventGroup, &ctx.openBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.settingsBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.addContactBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.joinChannelBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.shareBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.addByKeyBit) == ERROR_NONE); + + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + MeshEventSubscription meshEvents {}; + check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); + + loadData(&ctx); + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + + if (flags & ctx.openBit) { + startConversation(&ctx); + } + if (flags & ctx.settingsBit) { + startApp("tactility.meshsettings", 0, nullptr); + } + if (flags & ctx.addContactBit) { + startAddContact(); + } + if (flags & ctx.addByKeyBit) { + startContactNameDialog(&ctx); + } + if (flags & ctx.shareBit) { + showOwnQr(&ctx); + } + if (flags & ctx.joinChannelBit) { + startJoinChannel(&ctx); + } + + bool changed = false; + MeshEvent meshEvent {}; + while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { + changed |= meshEvent.type == MESH_EVENT_STATE || meshEvent.type == MESH_EVENT_CONTACT; + } + + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + if (event.type == APP_EVENT_RESULT) { + if (event.result.launch_id == ctx.contactNameDialogId) { + ctx.contactNameDialogId = 0; + onContactNameResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.contactKeyDialogId) { + ctx.contactKeyDialogId = 0; + onContactKeyResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.joinChannelDialogId) { + ctx.joinChannelDialogId = 0; + onJoinChannelResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.messageDialogId) { + ctx.messageDialogId = 0; + } + app_manager_stop(event.result.launch_id); + // The channels and contacts may have changed, and the window may have been rebuilt + changed = true; + } + } + + if (changed) { + loadData(&ctx); + lvgl_lock(); + updateWidgets(&ctx); + lvgl_unlock(); + } + } + + window_manager_remove(window); + check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +extern const ::AppManifest manifest = { + .id = "tactility.meshchat", + .name = "Mesh Chat", + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = 0, + .stack = {} +}; + +} // namespace tt::app::meshchat + +#endif diff --git a/Tactility/Source/app/meshchat/MeshConversation.cpp b/Tactility/Source/app/meshchat/MeshConversation.cpp new file mode 100644 index 000000000..2639bc6d1 --- /dev/null +++ b/Tactility/Source/app/meshchat/MeshConversation.cpp @@ -0,0 +1,459 @@ +#if TT_FEATURE_MESH + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace tt::app::meshchat { + +extern const ::AppManifest conversationManifest; + +namespace { + +constexpr auto* TAG = "MeshConversation"; +constexpr size_t MESSAGE_LIMIT = 30; + +struct Context { + uint32_t appInstanceId; + TaskEventGroup* eventGroup = nullptr; + uint32_t removeBit = 0; + uint32_t qrBit = 0; + uint32_t removeDialogId = 0; + uint32_t messageDialogId = 0; + bool isContact = true; + MeshAddress peer {}; + uint8_t channel = 0; + ::std::string title; + /** Oldest first */ + tt::std::deque messages; + // Valid while the window is shown + lv_obj_t* statusLabel = nullptr; + lv_obj_t* list = nullptr; + lv_obj_t* input = nullptr; +}; + +const MeshAddress* getPeer(const Context* ctx) { + return ctx->isContact ? &ctx->peer : nullptr; +} + +/** How far a message got. Channel messages are never acknowledged by their recipients. */ +const char* getReceiptText(MeshDeliveryState delivery) { + switch (delivery) { + case MESH_DELIVERY_QUEUED: + return "Sending..."; + case MESH_DELIVERY_BLOCKED: + return "Waiting"; + case MESH_DELIVERY_SENT: + return "Sent"; + case MESH_DELIVERY_DELIVERED: + return LV_SYMBOL_OK " Delivered"; + case MESH_DELIVERY_FAILED: + return LV_SYMBOL_CLOSE " Not delivered"; + case MESH_DELIVERY_CANCELLED: + return "Cancelled"; + default: + return ""; + } +} + +::std::string getHeaderText(const MeshMessage& message) { + ::std::string header = message.outgoing ? "You" : (message.sender_name[0] != '\0' ? message.sender_name : "?"); + if (message.wall_time > 0) { + const time_t time = (time_t)message.wall_time; + struct tm local {}; + char text[8]; + if (localtime_r(&time, &local) != nullptr && strftime(text, sizeof(text), "%H:%M", &local) > 0) { + header += " "; + header += text; + } + } + if (message.outgoing) { + header += " "; + header += getReceiptText(message.delivery); + } + return header; +} + +bool belongsToConversation(const Context* ctx, const MeshMessage& message) { + char backend[MESH_BACKEND_ID_MAX_LENGTH + 1]; + if (mesh_get_backend(backend, sizeof(backend)) != ERROR_NONE || strncmp(message.backend, backend, MESH_BACKEND_ID_MAX_LENGTH) != 0) { + return false; + } + if (ctx->isContact) { + return message.peer.length == ctx->peer.length && memcmp(message.peer.bytes, ctx->peer.bytes, ctx->peer.length) == 0; + } + return message.peer.length == 0 && message.channel == ctx->channel; +} + +/** Requires the LVGL lock */ +void createMessageItem(Context* ctx, const MeshMessage& message) { + auto* item = lv_obj_create(ctx->list); + lv_obj_set_size(item, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(item, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(item, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(item, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(item, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(item, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_scrollable(item, false); + lv_obj_set_user_data(item, reinterpret_cast(static_cast(message.seq))); + const auto align = message.outgoing ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT; + + auto* header = lv_label_create(item); + lv_obj_set_width(header, LV_PCT(100)); + lv_obj_set_style_text_align(header, align, LV_STATE_DEFAULT); + lv_obj_set_style_text_font(header, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); + lv_label_set_text(header, getHeaderText(message).c_str()); + + auto* text = lv_label_create(item); + lv_obj_set_width(text, LV_PCT(100)); + lv_obj_set_style_text_align(text, align, LV_STATE_DEFAULT); + lv_label_set_long_mode(text, LV_LABEL_LONG_MODE_WRAP); + lv_label_set_text(text, message.text); +} + +/** Requires the LVGL lock */ +void scrollToNewest(Context* ctx) { + lv_obj_update_layout(ctx->list); + lv_obj_scroll_to_y(ctx->list, LV_COORD_MAX, LV_ANIM_OFF); +} + +/** Requires the LVGL lock */ +void updateStatus(Context* ctx) { + if (ctx->statusLabel == nullptr) { + return; + } + const bool on = mesh_get_state() == MESH_STATE_ON; + lv_label_set_text(ctx->statusLabel, (::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state())).c_str()); + lv_obj_set_hidden(ctx->statusLabel, on); +} + +void onSendPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + const char* text = lv_textarea_get_text(ctx->input); + if (text[0] == '\0') { + return; + } + const error_t error = mesh_send_text(getPeer(ctx), ctx->channel, text, nullptr); + if (error == ERROR_NONE) { + lv_textarea_set_text(ctx->input, ""); + } else { + LOG_W(TAG, "Failed to send: %s", error_to_string(error)); + lv_label_set_text(ctx->statusLabel, error == ERROR_INVALID_STATE ? "Turn mesh on to send messages" : error_to_string(error)); + lv_obj_set_hidden(ctx->statusLabel, false); + } +} + +void onQrPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->qrBit); +} + +void onRemovePressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->removeBit); +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, ctx->title.c_str()); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + if (ctx->isContact) { + lvgl_toolbar_add_text_button_action(toolbar, "QR", onQrPressed, ctx); + } + // The primary channel can't be left + if (ctx->isContact || ctx->channel != 0) { + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_TRASH, onRemovePressed, ctx); + } + + ctx->statusLabel = lv_label_create(parent); + lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); + lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT); + updateStatus(ctx); + + ctx->list = lv_obj_create(parent); + lv_obj_set_width(ctx->list, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->list, 1); + lv_obj_set_flex_flow(ctx->list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->list, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->list, 0, LV_STATE_DEFAULT); + // Lets the keys scroll the history + if (auto* group = lv_group_get_default()) { + lv_group_add_obj(group, ctx->list); + } + for (const auto& message : ctx->messages) { + createMessageItem(ctx, message); + } + scrollToNewest(ctx); + + auto* inputBar = lv_obj_create(parent); + lv_obj_set_size(inputBar, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(inputBar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(inputBar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(inputBar, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(inputBar, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(inputBar, 0, LV_STATE_DEFAULT); + lv_obj_set_scrollable(inputBar, false); + + ctx->input = lv_textarea_create(inputBar); + lv_obj_set_flex_grow(ctx->input, 1); + lv_textarea_set_one_line(ctx->input, true); + lv_textarea_set_max_length(ctx->input, MESH_TEXT_MAX_LENGTH); + lv_textarea_set_placeholder_text(ctx->input, "Type a message..."); + lv_obj_add_event_cb(ctx->input, onSendPressed, LV_EVENT_READY, ctx); + + auto* sendButton = lv_button_create(inputBar); + lv_obj_add_event_cb(sendButton, onSendPressed, LV_EVENT_CLICKED, ctx); + lv_label_set_text(lv_label_create(sendButton), "Send"); +} + +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->statusLabel = nullptr; + ctx->list = nullptr; + ctx->input = nullptr; +} + +/** Queries the service, so it's called without the LVGL lock */ +void loadMessages(Context* ctx) { + tt::std::vector history(MESSAGE_LIMIT); + size_t count = 0; + if (mesh_conversation_get(getPeer(ctx), ctx->channel, 0, history.data(), MESSAGE_LIMIT, &count) != ERROR_NONE) { + return; + } + // History is newest first + for (size_t i = count; i > 0; i--) { + ctx->messages.push_back(history[i - 1]); + } +} + +void onMessage(Context* ctx, uint32_t seq) { + for (const auto& known : ctx->messages) { + if (known.seq == seq) { + return; + } + } + MeshMessage message; + if (mesh_message_get(seq, &message) != ERROR_NONE || !belongsToConversation(ctx, message)) { + return; + } + ctx->messages.push_back(message); + const bool trimmed = ctx->messages.size() > MESSAGE_LIMIT; + if (trimmed) { + ctx->messages.pop_front(); + } + + lvgl_lock(); + if (ctx->list != nullptr) { + if (trimmed) { + lv_obj_delete(lv_obj_get_child(ctx->list, 0)); + } + createMessageItem(ctx, message); + scrollToNewest(ctx); + } + lvgl_unlock(); +} + +void onDelivery(Context* ctx, uint32_t seq, MeshDeliveryState delivery) { + for (auto& message : ctx->messages) { + if (message.seq != seq) { + continue; + } + message.delivery = delivery; + lvgl_lock(); + if (ctx->list != nullptr) { + const uint32_t count = lv_obj_get_child_count(ctx->list); + for (uint32_t i = 0; i < count; i++) { + auto* item = lv_obj_get_child(ctx->list, (int32_t)i); + if (static_cast(reinterpret_cast(lv_obj_get_user_data(item))) == seq) { + lv_label_set_text(lv_obj_get_child(item, 0), getHeaderText(message).c_str()); + break; + } + } + } + lvgl_unlock(); + return; + } +} + +void showContactQr(Context* ctx) { + char uri[256]; + const error_t error = mesh_get_contact_uri(&ctx->peer, uri, sizeof(uri)); + if (error == ERROR_NONE) { + qrcode::start(uri); + } else if (ctx->messageDialogId == 0) { + LOG_W(TAG, "Failed to get the contact: %s", error_to_string(error)); + ctx->messageDialogId = alertdialog::start( + ctx->appInstanceId, + "Couldn't create QR code", + error == ERROR_NOT_SUPPORTED ? "This mesh network can't share a contact as a QR code." : error_to_string(error) + ); + } +} + +void startRemoveDialog(Context* ctx) { + if (ctx->removeDialogId != 0) { + return; + } + const char* title = ctx->isContact ? "Remove contact" : "Leave channel"; + const char* message = ctx->isContact ? "The messages are kept." : "The messages are kept, but new messages in this channel aren't received anymore."; + ctx->removeDialogId = alertdialog::start(ctx->appInstanceId, title, message, { "Remove", "Cancel" }); +} + +/** @return true when the conversation is gone and the app can close */ +bool onRemoveResult(Context* ctx, int32_t result) { + if (result != 0) { + return false; + } + const error_t error = ctx->isContact ? mesh_remove_contact(&ctx->peer) : mesh_remove_channel(ctx->channel); + if (error != ERROR_NONE && error != ERROR_NOT_FOUND) { + LOG_E(TAG, "Failed to remove: %s", error_to_string(error)); + ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, "Couldn't remove", error_to_string(error)); + return false; + } + return true; +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + if (argc < 3) { + return 1; + } + ctx.isContact = strcmp(argv[0], KIND_CONTACT) == 0; + if (ctx.isContact) { + if (!hexToAddress(argv[1], &ctx.peer)) { + return 1; + } + } else { + ctx.channel = (uint8_t)atoi(argv[1]); + } + ctx.title = argv[2]; + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + ctx.eventGroup = &eventGroup; + check(task_event_group_claim_bit(&eventGroup, &ctx.removeBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.qrBit) == ERROR_NONE); + + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + MeshEventSubscription meshEvents {}; + check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); + + // The window shows up first, and the history follows + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + loadMessages(&ctx); + lvgl_lock(); + if (ctx.list != nullptr) { + // The window may have been rebuilt from the messages meanwhile + lv_obj_clean(ctx.list); + for (const auto& message : ctx.messages) { + createMessageItem(&ctx, message); + } + scrollToNewest(&ctx); + } + lvgl_unlock(); + + bool shouldClose = false; + while (!shouldClose) { + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + + if (flags & ctx.removeBit) { + startRemoveDialog(&ctx); + } + if (flags & ctx.qrBit) { + showContactQr(&ctx); + } + + MeshEvent meshEvent {}; + while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { + switch (meshEvent.type) { + case MESH_EVENT_MESSAGE: + onMessage(&ctx, meshEvent.seq); + break; + case MESH_EVENT_DELIVERY: + onDelivery(&ctx, meshEvent.seq, meshEvent.delivery); + break; + case MESH_EVENT_STATE: + lvgl_lock(); + updateStatus(&ctx); + lvgl_unlock(); + break; + default: + break; + } + } + + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + if (event.type == APP_EVENT_RESULT) { + if (event.result.launch_id == ctx.removeDialogId) { + ctx.removeDialogId = 0; + shouldClose |= onRemoveResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.messageDialogId) { + ctx.messageDialogId = 0; + } + app_manager_stop(event.result.launch_id); + } + } + } + + window_manager_remove(window); + check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +extern const ::AppManifest conversationManifest = { + .id = CONVERSATION_APP_ID, + .name = "Mesh Conversation", + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, + .stack = {} +}; + +} // namespace tt::app::meshchat + +#endif diff --git a/Tactility/Source/app/meshsettings/MeshSettings.cpp b/Tactility/Source/app/meshsettings/MeshSettings.cpp index b223db06a..7e9ca2104 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -1,6 +1,7 @@ #if TT_FEATURE_MESH #include +#include #include #include @@ -44,12 +45,16 @@ struct Context { uint32_t selectRegionBit = 0; uint32_t selectPresetBit = 0; uint32_t addChannelBit = 0; + uint32_t changeNameBit = 0; uint32_t serviceDialogId = 0; uint32_t regionDialogId = 0; uint32_t presetDialogId = 0; uint32_t channelDialogId = 0; + uint32_t nameDialogId = 0; AppStream channelStream {}; char channelBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream nameStream {}; + char nameBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; /** Channel names of the active backend, empty when it has no channels */ std::string channelsText; /** Index 0 is "Disabled", the rest are backend ids */ @@ -57,6 +62,7 @@ struct Context { std::vector presetNames; // Valid while the window is shown lv_obj_t* serviceLabel = nullptr; + lv_obj_t* nameLabel = nullptr; lv_obj_t* regionLabel = nullptr; lv_obj_t* presetRow = nullptr; lv_obj_t* presetLabel = nullptr; @@ -93,6 +99,9 @@ void updateLabels(Context* ctx) { return; } lv_label_set_text(ctx->serviceLabel, getCurrentService().c_str()); + char name[MESH_NAME_MAX_LENGTH + 1]; + mesh_get_node_name(name, sizeof(name)); + lv_label_set_text(ctx->nameLabel, name); lv_label_set_text(ctx->regionLabel, settings::radio::getRegionLabel(settings::radio::loadRegion())); char preset[MESH_NAME_MAX_LENGTH + 1]; const bool hasPresets = mesh_is_enabled() && mesh_get_preset(preset, sizeof(preset)) == ERROR_NONE; @@ -126,6 +135,42 @@ void onAddChannelPressed(lv_event_t* event) { task_event_group_signal(ctx->eventGroup, ctx->addChannelBit); } +void onChangeNamePressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->changeNameBit); +} + +void startNameDialog(Context* ctx) { + if (ctx->nameDialogId != 0) { + return; + } + char name[MESH_NAME_MAX_LENGTH + 1]; + mesh_get_node_name(name, sizeof(name)); + ctx->nameDialogId = inputdialog::start( + ctx->appInstanceId, + "Name", + "The name that other nodes see", + name, + ctx->nameStream, + ctx->nameBuffer, + sizeof(ctx->nameBuffer), + ctx->eventGroup + ); +} + +void onNameDialogResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::nameBuffer)] = {}; + const size_t length = app_stream_read(&ctx->nameStream, name, sizeof(name) - 1); + name[length] = '\0'; + const error_t error = mesh_set_node_name(name); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to set the name: %s", error_to_string(error)); + } + } + app_stream_unsubscribe(&ctx->nameStream); +} + void startChannelDialog(Context* ctx) { if (ctx->channelDialogId != 0) { return; @@ -142,41 +187,15 @@ void startChannelDialog(Context* ctx) { ); } -void joinChannel(const char* name) { - if (name[name[0] == '#' ? 1 : 0] == '\0') { - return; - } - - // A new channel gets the next free index - MeshChannel channels[8]; - size_t count = 0; - uint8_t index = 0; - if (mesh_get_channels(channels, 8, &count) == ERROR_NONE) { - for (size_t i = 0; i < count; i++) { - if (strcmp(channels[i].name, name) == 0) { - index = channels[i].index; - break; - } - index = std::max(index, channels[i].index + 1); - } - } - - MeshChannel channel {}; - channel.index = index; - strncpy(channel.name, name, MESH_NAME_MAX_LENGTH); - channel.key_length = 0; - const error_t error = mesh_set_channel(&channel); - if (error != ERROR_NONE) { - LOG_E(TAG, "Failed to join channel %s: %s", name, error_to_string(error)); - } -} - void onChannelDialogResult(Context* ctx, int32_t result) { if (result == 0) { char name[sizeof(Context::channelBuffer)] = {}; const size_t length = app_stream_read(&ctx->channelStream, name, sizeof(name) - 1); name[length] = '\0'; - joinChannel(name); + const error_t error = joinHashtagChannel(name); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to join channel %s: %s", name, error_to_string(error)); + } } app_stream_unsubscribe(&ctx->channelStream); } @@ -337,6 +356,10 @@ void createWidgets(lv_obj_t* parent, void* userData) { ctx->serviceLabel = createTitleAndValue(serviceRow, "Mesh network"); createChangeButton(serviceRow, onSelectServicePressed, ctx); + auto* nameRow = createRow(card); + ctx->nameLabel = createTitleAndValue(nameRow, "Name"); + createChangeButton(nameRow, onChangeNamePressed, ctx); + auto* regionRow = createRow(card); ctx->regionLabel = createTitleAndValue(regionRow, "Region"); createChangeButton(regionRow, onSelectRegionPressed, ctx); @@ -360,6 +383,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); ctx->serviceLabel = nullptr; + ctx->nameLabel = nullptr; ctx->regionLabel = nullptr; ctx->presetRow = nullptr; ctx->presetLabel = nullptr; @@ -379,6 +403,7 @@ int32_t appMain(int argc, char* argv[]) { check(task_event_group_claim_bit(&eventGroup, &ctx.selectRegionBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.selectPresetBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.addChannelBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.changeNameBit) == ERROR_NONE); loadChannels(&ctx); AppEventSubscription appEvents {}; @@ -405,6 +430,9 @@ int32_t appMain(int argc, char* argv[]) { if (flags & ctx.addChannelBit) { startChannelDialog(&ctx); } + if (flags & ctx.changeNameBit) { + startNameDialog(&ctx); + } bool changed = false; MeshEvent meshEvent {}; @@ -431,6 +459,9 @@ int32_t appMain(int argc, char* argv[]) { } else if (event.result.launch_id == ctx.channelDialogId) { ctx.channelDialogId = 0; onChannelDialogResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.nameDialogId) { + ctx.nameDialogId = 0; + onNameDialogResult(&ctx, event.result.result); } app_manager_stop(event.result.launch_id); changed = true; @@ -454,6 +485,32 @@ int32_t appMain(int argc, char* argv[]) { } // namespace +error_t joinHashtagChannel(const char* name) { + if (name[name[0] == '#' ? 1 : 0] == '\0') { + return ERROR_INVALID_ARGUMENT; + } + + // A new channel gets the next free index + MeshChannel channels[8]; + size_t count = 0; + uint8_t index = 0; + if (mesh_get_channels(channels, 8, &count) == ERROR_NONE) { + for (size_t i = 0; i < count; i++) { + if (strcmp(channels[i].name, name) == 0) { + index = channels[i].index; + break; + } + index = std::max(index, channels[i].index + 1); + } + } + + MeshChannel channel {}; + channel.index = index; + strncpy(channel.name, name, MESH_NAME_MAX_LENGTH); + channel.key_length = 0; + return mesh_set_channel(&channel); +} + extern const ::AppManifest manifest = { .id = "tactility.meshsettings", .name = "Mesh", diff --git a/Tactility/Source/app/qrcode/QrCode.cpp b/Tactility/Source/app/qrcode/QrCode.cpp new file mode 100644 index 000000000..b63478939 --- /dev/null +++ b/Tactility/Source/app/qrcode/QrCode.cpp @@ -0,0 +1,129 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include +#include + +namespace tt::app::qrcode { + +constexpr auto* TAG = "QrCode"; + +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; + // Set once in appMain() from its own argc/argv parameters, read by createWidgets() + int argc = 0; + char** argv = nullptr; +}; + +// A touch, a key press or a step of an encoder that is handled as keys +void onInput(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +void forEachInputDevice(void (*action)(lv_indev_t* indev, Context* ctx), Context* ctx) { + for (lv_indev_t* indev = lv_indev_get_next(nullptr); indev != nullptr; indev = lv_indev_get_next(indev)) { + action(indev, ctx); + } +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_scrollable(parent, false); + lv_obj_set_style_pad_all(parent, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT); + + auto* qr = lvgl_qr_create(parent); + lv_obj_set_size(qr, LV_PCT(100), LV_PCT(100)); + const char* text = (ctx->argc > 0) ? ctx->argv[0] : ""; + const error_t error = lvgl_qr_set_data(qr, text); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to create the QR code: %s", error_to_string(error)); + auto* label = lv_label_create(parent); + lv_label_set_text(label, "The data doesn't fit in a QR code"); + lv_obj_center(label); + } + + // Any input device can close it, whichever widget has the focus + forEachInputDevice([](lv_indev_t* indev, Context* ctx) { + lv_indev_add_event_cb(indev, onInput, LV_EVENT_PRESSED, ctx); + }, ctx); +} + +void destroyWidgets(void* userData) { + forEachInputDevice([](lv_indev_t* indev, Context* ctx) { + lv_indev_remove_event_cb_with_user_data(indev, onInput, ctx); + }, static_cast(userData)); +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + ctx.argc = argc; + ctx.argv = argv; + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + task_event_group_wait_any(&eventGroup, nullptr, portMAX_DELAY); + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + } + } + + window_manager_remove(window); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +AppInstanceId start(const std::string& text) { + const char* argv[] = { text.c_str() }; + AppInstanceId instanceId = 0; + AppStartContext context = app_start_context_for_manifest(&manifest); + app_start_context_set_arguments_ext(&context, 1, argv); + if (app_start_with_context(&context, &instanceId) != ERROR_NONE) { + return 0; + } + return instanceId; +} + +extern const ::AppManifest manifest = { + .id = "tactility.qrcode", + .name = "QR Code", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, + // The app task does no file operations, so its stack can be in external RAM + .stack = { .depth = 0, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL } +}; + +}