Mesh Chat app(s)

This commit is contained in:
Ken Van Hoeylandt 2026-10-17 00:55:06 +02:00
parent 76434d5bcd
commit 40fab8df8c
30 changed files with 2582 additions and 38 deletions

View File

@ -52,6 +52,7 @@ list(APPEND REQUIRES_LIST
binfont-module
lodepng-module
lvgl
QRCode
)
if (DEFINED ENV{ESP_IDF_VERSION})

View File

@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#include <tactility/error.h>
#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

View File

@ -23,6 +23,7 @@
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/page_indicator.h>
#include <lvgl/widgets/step_indicator.h>
#include <lvgl/widgets/qr.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/statusbar.h>
#include <lvgl/widgets/text_button.h>
@ -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

View File

@ -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 <lvgl/widgets/qr.h>
#include <tactility/memory.h>
extern "C" {
#include <qrcode.h>
}
#include <algorithm>
#include <cstring>
// 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<uint8_t*>(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<QrView*>(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<QrView*>(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<QrView*>(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<uint8_t>(x), static_cast<uint8_t>(y))) {
x++;
continue;
}
int32_t end = x + 1;
while (end < view->code.size && qrcode_getModule(&view->code, static_cast<uint8_t>(end), static_cast<uint8_t>(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<QrView*>(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<uint8_t*>(const_cast<char*>(data)), static_cast<uint16_t>(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<QrView*>(obj);
view->dark = dark;
view->light = light;
lv_obj_invalidate(obj);
}

View File

@ -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
)

View File

@ -0,0 +1,25 @@
#include "doctest.h"
#include <lvgl.h>
#include <lvgl/widgets/qr.h>
#include <string>
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();
}

View File

@ -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=<name>&public_key=<hex>&type=<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]);

View File

@ -402,6 +402,24 @@ void onStart(void* context) {
sendAdvert(static_cast<Backend*>(context));
}
error_t getContactUri(void* context, const MeshAddress* contact, const char* name, char* uri, size_t uriSize) {
auto* backend = static_cast<Backend*>(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<Backend*>(context));
}
bool onReceive(void* context, const uint8_t* data, size_t length, float rssi, float snr) {
auto* backend = static_cast<Backend*>(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<Backend*>(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,
};

View File

@ -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<uint8_t>(*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;
}

View File

@ -3,7 +3,9 @@
#include <mesh_meshcore/meshcore_packet.h>
#include <mesh_meshcore/meshcore_protocol.h>
#include <cstdlib>
#include <cstring>
#include <string>
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));
}

View File

@ -369,6 +369,14 @@ void onStart(void* context) {
sendNodeInfo(static_cast<Backend*>(context));
}
bool isValidContactAddress(void*, const MeshAddress* address) {
return address->length == 4;
}
void onNodeNameChanged(void* context) {
sendNodeInfo(static_cast<Backend*>(context));
}
bool onReceive(void* context, const uint8_t* frame, size_t length, float rssi, float snr) {
auto* backend = static_cast<Backend*>(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<Backend*>(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,
};

View File

@ -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);
};
/**

View File

@ -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 {

View File

@ -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<uint32_t> findOutgoing(const char* backend, enum MeshDeliveryState delivery) const;

View File

@ -210,6 +210,8 @@ struct MeshHost {
MeshState state = MESH_STATE_OFF;
MeshStore store;
tt::std::vector<MeshNode> nodes;
/** The saved contacts of every backend */
tt::std::vector<MeshContact> contacts;
char activeBackend[MESH_BACKEND_ID_MAX_LENGTH + 1] = {};
tt::std::deque<std::function<void()>> 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(&copy);
}
@ -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

View File

@ -11,18 +11,31 @@
#include <cstdio>
#include <cstring>
#ifdef ESP_PLATFORM
#include <esp_random.h>
#else
#include <random>
#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];

View File

@ -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<uint8_t*>(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) {

View File

@ -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),

View File

@ -14,6 +14,7 @@
#include <tactility/filesystem/file_system.h>
#include <tactility/paths.h>
#include <atomic>
#include <cmath>
#include <cstdlib>
#include <cstring>
@ -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<int> 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);

View File

@ -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();
}
}

View File

@ -0,0 +1,52 @@
#pragma once
#if TT_FEATURE_MESH
#include <mesh/mesh.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
/** 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

View File

@ -0,0 +1,18 @@
#pragma once
#if TT_FEATURE_MESH
#include <tactility/error.h>
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

View File

@ -0,0 +1,18 @@
#pragma once
#include "app/instance.h"
#include <string>
/**
* 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);
}

View File

@ -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);
}

View File

@ -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},

View File

@ -0,0 +1,281 @@
#if TT_FEATURE_MESH
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/meshchat/MeshChatCommon.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <lvgl_window_manager/window_manager.h>
#include <TactilityCpp/std/vector.h>
#include <mesh/mesh.h>
#include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/toolbar.h>
#include <atomic>
#include <cstring>
#include <string>
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<int32_t> selectedIndex = 0;
/** The nodes that aren't contacts yet */
tt::std::vector<MeshNode> 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<MeshContact> 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<Context*>(lv_event_get_user_data(event));
ctx->selectedIndex = (int32_t)reinterpret_cast<intptr_t>(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<Context*>(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<void*>(static_cast<intptr_t>(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<Context*>(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<Context*>(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<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
.stack = {}
};
} // namespace tt::app::meshchat
#endif

View File

@ -0,0 +1,556 @@
#if TT_FEATURE_MESH
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/inputdialog/InputDialog.h>
#include <Tactility/app/meshchat/MeshChatCommon.h>
#include <Tactility/app/meshsettings/MeshChannels.h>
#include <Tactility/app/qrcode/QrCode.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <app/stream.h>
#include <lvgl_window_manager/window_manager.h>
#include <TactilityCpp/std/vector.h>
#include <mesh/mesh.h>
#include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <lvgl/theme.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/text_button.h>
#include <lvgl/widgets/toolbar.h>
#include <atomic>
#include <cstring>
#include <string>
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<bool> openIsContact = true;
::std::atomic<int32_t> 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<MeshContact> contacts;
tt::std::vector<MeshChannel> 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<void*>(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<Context*>(lv_event_get_user_data(event));
auto* button = lv_event_get_target_obj(event);
ctx->openIsContact = isContact;
ctx->openIndex = (int32_t)reinterpret_cast<intptr_t>(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<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->addContactBit);
}
void onShareQrPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->shareBit);
}
void onAddByKeyPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->addByKeyBit);
}
void onJoinChannelPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->joinChannelBit);
}
void onSettingsPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->settingsBit);
}
void onTabChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(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<Context*>(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<int32_t>(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<Context*>(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<Context*>(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<void*>(appMain) },
.flags = 0,
.stack = {}
};
} // namespace tt::app::meshchat
#endif

View File

@ -0,0 +1,459 @@
#if TT_FEATURE_MESH
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/meshchat/MeshChatCommon.h>
#include <Tactility/app/qrcode/QrCode.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <lvgl_window_manager/window_manager.h>
#include <TactilityCpp/std/deque.h>
#include <TactilityCpp/std/vector.h>
#include <mesh/mesh.h>
#include <tactility/check.h>
#include <tactility/concurrent/task_event_group.h>
#include <tactility/log.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <cstring>
#include <ctime>
#include <string>
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<MeshMessage> 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<void*>(static_cast<uintptr_t>(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<Context*>(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<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->qrBit);
}
void onRemovePressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
task_event_group_signal(ctx->eventGroup, ctx->removeBit);
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
app_event_emit_close(ctx->appInstanceId);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(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<Context*>(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<MeshMessage> 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<uint32_t>(reinterpret_cast<uintptr_t>(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<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
.stack = {}
};
} // namespace tt::app::meshchat
#endif

View File

@ -1,6 +1,7 @@
#if TT_FEATURE_MESH
#include <Tactility/app/inputdialog/InputDialog.h>
#include <Tactility/app/meshsettings/MeshChannels.h>
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <Tactility/settings/RadioSettings.h>
@ -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<std::string> 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<Context*>(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<uint8_t>(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<Context*>(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<uint8_t>(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",

View File

@ -0,0 +1,129 @@
#include <Tactility/app/qrcode/QrCode.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/log.h>
#include <tactility/memory.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/qr.h>
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<Context*>(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<Context*>(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<Context*>(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<void*>(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 }
};
}