feat(ui): add app launcher and NomadNet MVP

This commit is contained in:
torlando-agent[bot] committed 2026-08-05 00:22:59 +00:00
1 parent bf7f326d15
commit d0e1ce273c
26 files changed
+2464 -247

No files matched your search

+1 -1
View File
@@ -145,7 +145,7 @@
#define LV_USE_MENU 0
#define LV_USE_METER 0
#define LV_USE_MSGBOX 1
#define LV_USE_SPAN 0
#define LV_USE_SPAN 1
#define LV_USE_SPINBOX 0
#define LV_USE_SPINNER 1
#define LV_USE_TABVIEW 0
+31 -40
View File
@@ -30,7 +30,7 @@ namespace LXMF {
ConversationListScreen::ConversationListScreen(lv_obj_t* parent)
: _screen(nullptr), _header(nullptr), _list(nullptr), _bottom_nav(nullptr),
_btn_new(nullptr), _btn_settings(nullptr), _label_wifi(nullptr), _label_lora(nullptr),
_btn_new(nullptr), _btn_home(nullptr), _btn_compose(nullptr), _label_wifi(nullptr), _label_lora(nullptr),
_label_gps(nullptr), _label_ble(nullptr), _battery_container(nullptr),
_label_battery_icon(nullptr), _label_battery_pct(nullptr),
_lora_interface(nullptr), _ble_interface(nullptr), _gps(nullptr),
@@ -164,21 +164,23 @@ void ConversationListScreen::create_bottom_nav() {
lv_obj_set_flex_flow(_bottom_nav, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(_bottom_nav, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Bottom navigation buttons: Messages, Announces, Status, Settings
const char* icons[] = {LV_SYMBOL_ENVELOPE, LV_SYMBOL_BELL, LV_SYMBOL_BARS, LV_SYMBOL_SETTINGS};
// Messages is a child of the launcher: only Home and Compose belong here.
const char* labels[] = {LV_SYMBOL_HOME " Home", LV_SYMBOL_EDIT " Compose"};
for (int i = 0; i < 4; i++) {
for (int i = 0; i < 2; i++) {
lv_obj_t* btn = lv_btn_create(_bottom_nav);
lv_obj_set_size(btn, 65, 28);
lv_obj_set_size(btn, 145, 30);
lv_obj_set_user_data(btn, (void*)(intptr_t)i);
lv_obj_set_style_bg_color(btn, Theme::surfaceInput(), 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(0x3a3a3a), LV_STATE_PRESSED);
lv_obj_add_event_cb(btn, on_bottom_nav_clicked, LV_EVENT_CLICKED, this);
lv_obj_t* label = lv_label_create(btn);
lv_label_set_text(label, icons[i]);
lv_label_set_text(label, labels[i]);
lv_obj_center(label);
lv_obj_set_style_text_color(label, Theme::textTertiary(), 0);
if (i == 0) _btn_home = btn;
else _btn_compose = btn;
}
}
@@ -280,6 +282,17 @@ void ConversationListScreen::refresh() {
_conversations.push_back(item);
create_conversation_item(item);
}
// refresh() may run while Messages remains active. Newly created rows are
// not automatically restored to the encoder group after lv_obj_clean().
if (_visible) {
lv_group_t* group = LVGL::LVGLInit::get_default_group();
if (group) {
for (lv_obj_t* container : _conversation_containers) {
lv_group_add_obj(group, container);
}
}
}
}
void ConversationListScreen::flush_pending_name_writes() {
@@ -385,20 +398,13 @@ void ConversationListScreen::set_sync_callback(SyncCallback callback) {
_sync_callback = callback;
}
void ConversationListScreen::set_settings_callback(SettingsCallback callback) {
_settings_callback = callback;
}
void ConversationListScreen::set_announces_callback(AnnouncesCallback callback) {
_announces_callback = callback;
}
void ConversationListScreen::set_status_callback(StatusCallback callback) {
_status_callback = callback;
void ConversationListScreen::set_home_callback(HomeCallback callback) {
_home_callback = callback;
}
void ConversationListScreen::show() {
LVGL_LOCK();
_visible = true;
lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(_screen); // Bring to front for touch events
@@ -414,6 +420,8 @@ void ConversationListScreen::show() {
if (_btn_new) {
lv_group_add_obj(group, _btn_new);
}
if (_btn_home) lv_group_add_obj(group, _btn_home);
if (_btn_compose) lv_group_add_obj(group, _btn_compose);
// Focus first conversation if available, otherwise New button
if (!_conversation_containers.empty()) {
@@ -426,6 +434,7 @@ void ConversationListScreen::show() {
void ConversationListScreen::hide() {
LVGL_LOCK();
_visible = false;
// Remove from focus group when hiding
lv_group_t* group = LVGL::LVGLInit::get_default_group();
if (group) {
@@ -437,6 +446,8 @@ void ConversationListScreen::hide() {
if (_btn_new) {
lv_group_remove_obj(_btn_new);
}
if (_btn_home) lv_group_remove_obj(_btn_home);
if (_btn_compose) lv_group_remove_obj(_btn_compose);
}
lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN);
@@ -652,40 +663,20 @@ void ConversationListScreen::on_sync_clicked(lv_event_t* event) {
}
}
void ConversationListScreen::on_settings_clicked(lv_event_t* event) {
ConversationListScreen* screen = (ConversationListScreen*)lv_event_get_user_data(event);
if (screen->_settings_callback) {
screen->_settings_callback();
}
}
void ConversationListScreen::on_bottom_nav_clicked(lv_event_t* event) {
ConversationListScreen* screen = (ConversationListScreen*)lv_event_get_user_data(event);
lv_obj_t* target = lv_event_get_target(event);
int btn_index = (int)(intptr_t)lv_obj_get_user_data(target);
switch (btn_index) {
case 0: // Compose new message
case 0: // Launcher home
if (screen->_home_callback) screen->_home_callback();
break;
case 1: // Compose new message
if (screen->_compose_callback) {
screen->_compose_callback();
}
break;
case 1: // Announces
if (screen->_announces_callback) {
screen->_announces_callback();
}
break;
case 2: // Status
if (screen->_status_callback) {
screen->_status_callback();
}
break;
case 3: // Settings
if (screen->_settings_callback) {
screen->_settings_callback();
}
break;
default:
break;
}
+6 -21
View File
@@ -71,9 +71,7 @@ public:
using ConversationSelectedCallback = std::function<void(const RNS::Bytes& peer_hash)>;
using ComposeCallback = std::function<void()>;
using SyncCallback = std::function<void()>;
using SettingsCallback = std::function<void()>;
using AnnouncesCallback = std::function<void()>;
using StatusCallback = std::function<void()>;
using HomeCallback = std::function<void()>;
/**
* Create conversation list screen
@@ -131,19 +129,7 @@ public:
* Set callback for settings button
* @param callback Function to call when settings button is pressed
*/
void set_settings_callback(SettingsCallback callback);
/**
* Set callback for announces button
* @param callback Function to call when announces button is pressed
*/
void set_announces_callback(AnnouncesCallback callback);
/**
* Set callback for status button
* @param callback Function to call when status button is pressed
*/
void set_status_callback(StatusCallback callback);
void set_home_callback(HomeCallback callback);
/**
* Show the screen
@@ -205,7 +191,9 @@ private:
lv_obj_t* _list;
lv_obj_t* _bottom_nav;
lv_obj_t* _btn_new;
lv_obj_t* _btn_settings;
lv_obj_t* _btn_home;
lv_obj_t* _btn_compose;
bool _visible = false;
lv_obj_t* _label_wifi;
lv_obj_t* _label_lora;
lv_obj_t* _label_gps;
@@ -234,9 +222,7 @@ private:
ConversationSelectedCallback _conversation_selected_callback;
ComposeCallback _compose_callback;
SyncCallback _sync_callback;
SettingsCallback _settings_callback;
AnnouncesCallback _announces_callback;
StatusCallback _status_callback;
HomeCallback _home_callback;
// UI construction
void create_header();
@@ -249,7 +235,6 @@ private:
static void on_conversation_long_pressed(lv_event_t* event);
static void on_delete_confirmed(lv_event_t* event);
static void on_sync_clicked(lv_event_t* event);
static void on_settings_clicked(lv_event_t* event);
static void on_bottom_nav_clicked(lv_event_t* event);
static void msgbox_close_cb(lv_event_t* event);
+62
View File
@@ -0,0 +1,62 @@
#include "HomeScreen.h"
#ifdef ARDUINO
#include "Theme.h"
#include "../LVGL/LVGLInit.h"
#include "../LVGL/LVGLLock.h"
namespace UI::LXMF {
HomeScreen::HomeScreen() {
_screen = lv_obj_create(lv_scr_act());
lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_screen, Theme::surface(), 0);
lv_obj_set_style_border_width(_screen, 0, 0);
lv_obj_set_style_pad_all(_screen, 10, 0);
lv_obj_set_style_pad_row(_screen, 6, 0);
lv_obj_set_style_pad_column(_screen, 6, 0);
lv_obj_set_layout(_screen, LV_LAYOUT_GRID);
static lv_coord_t cols[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};
static lv_coord_t rows[] = {30, LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};
lv_obj_set_grid_dsc_array(_screen, cols, rows);
lv_obj_t* title = lv_label_create(_screen);
lv_label_set_text(title, "Pyxis Home");
lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(title, Theme::textPrimary(), 0);
lv_obj_set_grid_cell(title, LV_GRID_ALIGN_CENTER, 0, 2, LV_GRID_ALIGN_CENTER, 0, 1);
static const char* labels[] = {"Messages", "NomadNet", "Network", "Settings"};
for (int i = 0; i < 4; ++i) {
_buttons[i] = lv_btn_create(_screen);
lv_obj_set_grid_cell(_buttons[i], LV_GRID_ALIGN_STRETCH, i % 2, 1,
LV_GRID_ALIGN_STRETCH, 1 + i / 2, 1);
lv_obj_set_style_bg_color(_buttons[i], Theme::surfaceContainer(), 0);
lv_obj_set_style_bg_color(_buttons[i], Theme::surfaceElevated(), LV_STATE_FOCUSED);
lv_obj_set_style_border_color(_buttons[i], Theme::info(), LV_STATE_FOCUSED);
lv_obj_set_style_border_width(_buttons[i], 2, LV_STATE_FOCUSED);
lv_obj_add_event_cb(_buttons[i], clicked, LV_EVENT_CLICKED, this);
lv_obj_t* label = lv_label_create(_buttons[i]);
lv_label_set_text(label, labels[i]);
lv_obj_set_style_text_color(label, Theme::textPrimary(), 0);
lv_obj_center(label);
}
hide();
}
HomeScreen::~HomeScreen() { if (_screen) lv_obj_del(_screen); }
void HomeScreen::show() {
lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(_screen);
auto* group = LVGL::LVGLInit::get_default_group();
if (group) { for (auto* button : _buttons) lv_group_add_obj(group, button); lv_group_focus_obj(_buttons[0]); }
}
void HomeScreen::hide() {
auto* group = LVGL::LVGLInit::get_default_group();
if (group) for (auto* button : _buttons) lv_group_remove_obj(button);
lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN);
}
void HomeScreen::clicked(lv_event_t* event) {
auto* self = static_cast<HomeScreen*>(lv_event_get_user_data(event));
auto* target = lv_event_get_target(event);
if (target == self->_buttons[0] && self->_messages) self->_messages();
else if (target == self->_buttons[1] && self->_nomadnet) self->_nomadnet();
else if (target == self->_buttons[2] && self->_network) self->_network();
else if (target == self->_buttons[3] && self->_settings) self->_settings();
}
}
#endif
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#ifdef ARDUINO
#include <functional>
#include <lvgl.h>
namespace UI::LXMF {
class HomeScreen {
public:
using Callback = std::function<void()>;
HomeScreen();
~HomeScreen();
void set_messages_callback(Callback cb) { _messages = std::move(cb); }
void set_nomadnet_callback(Callback cb) { _nomadnet = std::move(cb); }
void set_network_callback(Callback cb) { _network = std::move(cb); }
void set_settings_callback(Callback cb) { _settings = std::move(cb); }
void show();
void hide();
private:
lv_obj_t* _screen = nullptr;
lv_obj_t* _buttons[4]{};
Callback _messages, _nomadnet, _network, _settings;
static void clicked(lv_event_t* event);
};
}
#endif
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <array>
#include <cstddef>
namespace UI::LXMF {
enum class Route {
HOME,
MESSAGES,
CHAT,
COMPOSE,
NETWORK,
ANNOUNCES,
STATUS,
RADIO_ACTIVITY,
QR,
PROPAGATION_NODES,
NOMADNET,
SETTINGS,
CALL,
};
class NavigationStack {
public:
static constexpr std::size_t MAX_DEPTH = 12;
Route current() const { return _current; }
std::size_t depth() const { return _depth; }
void navigate(Route route) {
if (route == _current) return;
if (_depth == MAX_DEPTH) {
// Preserve the root entry so a saturated stack still unwinds Home.
for (std::size_t i = 2; i < MAX_DEPTH; ++i) _stack[i - 1] = _stack[i];
--_depth;
}
_stack[_depth++] = _current;
_current = route;
}
bool back() {
if (_depth == 0) return false;
_current = _stack[--_depth];
return true;
}
void home() {
_depth = 0;
_current = Route::HOME;
}
// Replace is for restoring a route after a transient call without adding
// an artificial history entry.
void replace(Route route) { _current = route; }
private:
Route _current = Route::HOME;
std::array<Route, MAX_DEPTH> _stack{};
std::size_t _depth = 0;
};
} // namespace UI::LXMF
+38
View File
@@ -0,0 +1,38 @@
#include "NetworkScreen.h"
#ifdef ARDUINO
#include "Theme.h"
#include "../LVGL/LVGLInit.h"
namespace UI::LXMF {
NetworkScreen::NetworkScreen() {
_screen = lv_obj_create(lv_scr_act()); lv_obj_set_size(_screen, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_screen, Theme::surface(), 0); lv_obj_set_style_border_width(_screen, 0, 0);
lv_obj_set_style_pad_all(_screen, 8, 0);
lv_obj_t* header = lv_obj_create(_screen); lv_obj_set_size(header, LV_PCT(100), 34); lv_obj_align(header, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_bg_color(header, Theme::surfaceHeader(), 0); lv_obj_set_style_border_width(header, 0, 0); lv_obj_set_style_pad_all(header, 2, 0);
_back_button = lv_btn_create(header); lv_obj_set_size(_back_button, 44, 28); lv_obj_align(_back_button, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_t* back = lv_label_create(_back_button); lv_label_set_text(back, LV_SYMBOL_LEFT); lv_obj_center(back);
_home_button = lv_btn_create(header); lv_obj_set_size(_home_button, 44, 28); lv_obj_align(_home_button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_t* home = lv_label_create(_home_button); lv_label_set_text(home, LV_SYMBOL_HOME); lv_obj_center(home);
lv_obj_t* title = lv_label_create(header); lv_label_set_text(title, "Network"); lv_obj_center(title);
lv_obj_add_event_cb(_back_button, clicked, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(_home_button, clicked, LV_EVENT_CLICKED, this);
lv_obj_t* content = lv_obj_create(_screen); lv_obj_set_size(content, LV_PCT(100), 190); lv_obj_align(content, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(content, 0, 0);
lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_gap(content, 6, 0);
static const char* labels[] = {"Announces", "Status", "Radio Activity", "Propagation Nodes"};
for (int i = 0; i < 4; ++i) {
_buttons[i] = lv_btn_create(content); lv_obj_set_size(_buttons[i], LV_PCT(100), 38);
lv_obj_set_style_bg_color(_buttons[i], Theme::surfaceContainer(), 0);
lv_obj_set_style_bg_color(_buttons[i], Theme::surfaceElevated(), LV_STATE_FOCUSED);
lv_obj_set_style_border_color(_buttons[i], Theme::info(), LV_STATE_FOCUSED);
lv_obj_set_style_border_width(_buttons[i], 2, LV_STATE_FOCUSED);
lv_obj_add_event_cb(_buttons[i], clicked, LV_EVENT_CLICKED, this);
lv_obj_t* label = lv_label_create(_buttons[i]); lv_label_set_text(label, labels[i]); lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0);
}
hide();
}
NetworkScreen::~NetworkScreen() { if (_screen) lv_obj_del(_screen); }
void NetworkScreen::show() { lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(_screen); auto* g=LVGL::LVGLInit::get_default_group(); if(g){lv_group_add_obj(g,_back_button);lv_group_add_obj(g,_home_button);for(auto* b:_buttons)lv_group_add_obj(g,b);lv_group_focus_obj(_buttons[0]);}}
void NetworkScreen::hide() { auto* g=LVGL::LVGLInit::get_default_group(); if(g){lv_group_remove_obj(_back_button);lv_group_remove_obj(_home_button);for(auto* b:_buttons)lv_group_remove_obj(b);} lv_obj_add_flag(_screen, LV_OBJ_FLAG_HIDDEN); }
void NetworkScreen::clicked(lv_event_t* e) { auto* s=static_cast<NetworkScreen*>(lv_event_get_user_data(e)); auto* t=lv_event_get_target(e); if(t==s->_back_button&&s->_back)s->_back(); else if(t==s->_home_button&&s->_home)s->_home(); else for(int i=0;i<4;++i)if(t==s->_buttons[i]&&s->_callbacks[i])s->_callbacks[i](); }
}
#endif
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#ifdef ARDUINO
#include <functional>
#include <lvgl.h>
namespace UI::LXMF {
class NetworkScreen {
public:
using Callback = std::function<void()>;
NetworkScreen(); ~NetworkScreen();
void set_back_callback(Callback cb) { _back = std::move(cb); }
void set_home_callback(Callback cb) { _home = std::move(cb); }
void set_announces_callback(Callback cb) { _callbacks[0] = std::move(cb); }
void set_status_callback(Callback cb) { _callbacks[1] = std::move(cb); }
void set_radio_activity_callback(Callback cb) { _callbacks[2] = std::move(cb); }
void set_propagation_nodes_callback(Callback cb) { _callbacks[3] = std::move(cb); }
void show(); void hide();
private:
lv_obj_t* _screen = nullptr; lv_obj_t* _back_button = nullptr; lv_obj_t* _home_button = nullptr;
lv_obj_t* _buttons[4]{}; Callback _back, _home, _callbacks[4];
static void clicked(lv_event_t* event);
};
}
#endif
+279
View File
@@ -0,0 +1,279 @@
#include "NomadNetDocument.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
namespace UI::LXMF::NomadNet {
namespace {
bool valid_utf8(const std::string& value) {
for (std::size_t i = 0; i < value.size();) {
const uint8_t lead = static_cast<uint8_t>(value[i]);
if (lead <= 0x7f) { ++i; continue; }
std::size_t continuation = 0;
uint32_t codepoint = 0;
if (lead >= 0xc2 && lead <= 0xdf) { continuation = 1; codepoint = lead & 0x1f; }
else if (lead >= 0xe0 && lead <= 0xef) { continuation = 2; codepoint = lead & 0x0f; }
else if (lead >= 0xf0 && lead <= 0xf4) { continuation = 3; codepoint = lead & 0x07; }
else return false;
if (i + continuation >= value.size()) return false;
for (std::size_t j = 1; j <= continuation; ++j) {
const uint8_t byte = static_cast<uint8_t>(value[i + j]);
if ((byte & 0xc0) != 0x80) return false;
codepoint = (codepoint << 6) | (byte & 0x3f);
}
if ((continuation == 2 && codepoint < 0x800) ||
(continuation == 3 && codepoint < 0x10000) ||
(codepoint >= 0xd800 && codepoint <= 0xdfff) || codepoint > 0x10ffff) return false;
i += continuation + 1;
}
return true;
}
bool color(const std::string& value, uint32_t& result) {
if (value.size() != 3 && value.size() != 6) return false;
if (!std::all_of(value.begin(), value.end(), [](unsigned char c) { return std::isxdigit(c); })) return false;
char* end = nullptr;
result = static_cast<uint32_t>(std::strtoul(value.c_str(), &end, 16));
if (value.size() == 3) {
const uint32_t r = (result >> 8) & 0xf, g = (result >> 4) & 0xf, b = result & 0xf;
result = (r * 17 << 16) | (g * 17 << 8) | b * 17;
}
return end && *end == '\0';
}
struct Style {
bool bold = false;
bool italic = false;
bool underline = false;
bool has_foreground = false;
uint32_t foreground = 0;
bool has_background = false;
uint32_t background = 0;
Alignment alignment = Alignment::LEFT;
};
void add_run(Document& doc, Block& block, std::string& text, const Style& style, int link = -1) {
if (text.empty()) return;
if (block.runs.size() >= DocumentParser::MAX_RUNS_PER_LINE) {
doc.truncated = true;
text.clear();
return;
}
Run run;
run.text.swap(text);
run.bold = style.bold;
run.italic = style.italic;
run.underline = style.underline;
run.has_foreground = style.has_foreground;
run.foreground = style.foreground;
run.has_background = style.has_background;
run.background = style.background;
run.link_index = link;
block.runs.push_back(std::move(run));
}
void parse_inline(Document& doc, Block& block, const std::string& line, Style& style) {
std::string text;
for (std::size_t i = 0; i < line.size();) {
if (line[i] == '\\' && i + 1 < line.size()) {
text.push_back(line[i + 1]); i += 2; continue;
}
if (line[i] != '`') { text.push_back(line[i++]); continue; }
if (i + 1 >= line.size()) { text.push_back('`'); ++i; continue; }
add_run(doc, block, text, style);
const char command = line[i + 1];
i += 2;
if (command == '!') style.bold = !style.bold;
else if (command == '*') style.italic = !style.italic;
else if (command == '_') style.underline = !style.underline;
else if (command == 'c') style.alignment = Alignment::CENTER;
else if (command == 'l') style.alignment = Alignment::LEFT;
else if (command == 'r') style.alignment = Alignment::RIGHT;
else if (command == 'a') style.alignment = Alignment::LEFT;
else if (command == 'f') style.has_foreground = false;
else if (command == 'b') style.has_background = false;
else if (command == 'F') {
std::size_t count = 3;
if (i < line.size() && line[i] == 'T') { ++i; count = 6; }
if (i + count <= line.size()) {
uint32_t value = 0;
if (color(line.substr(i, count), value)) {
style.has_foreground = true;
style.foreground = value;
i += count;
} else doc.malformed = true;
} else doc.malformed = true;
} else if (command == 'B') {
std::size_t count = 3;
if (i < line.size() && line[i] == 'T') { ++i; count = 6; }
if (i + count <= line.size()) {
uint32_t value = 0;
if (color(line.substr(i, count), value)) {
style.has_background = true;
style.background = value;
i += count;
} else doc.malformed = true;
} else doc.malformed = true;
} else if (command == '`') {
style = Style{};
} else if (command == '[') {
const auto close = line.find(']', i);
if (close == std::string::npos) { doc.malformed = true; text += "`["; continue; }
const std::string value = line.substr(i, close - i);
const auto separator = value.find('`');
std::string label = separator == std::string::npos ? value : value.substr(0, separator);
const auto fields_separator = separator == std::string::npos
? std::string::npos : value.find('`', separator + 1);
std::string target = separator == std::string::npos ? value :
value.substr(separator + 1, fields_separator == std::string::npos
? std::string::npos : fields_separator - separator - 1);
std::string fields = fields_separator == std::string::npos
? std::string() : value.substr(fields_separator + 1);
if (target.empty()) { doc.malformed = true; }
else if (doc.links.size() < DocumentParser::MAX_LINKS) {
if (label.empty()) label = target;
doc.links.push_back({label, target, fields});
std::string link_text = label;
add_run(doc, block, link_text, style, static_cast<int>(doc.links.size() - 1));
} else doc.truncated = true;
i = close + 1;
} else {
// Unknown modifiers are harmless and visible rather than commands.
text.push_back('`'); text.push_back(command);
}
}
add_run(doc, block, text, style);
block.alignment = style.alignment;
}
} // namespace
Document DocumentParser::parse(const std::string& source) const {
return parse(source.data(), source.size());
}
Document DocumentParser::parse(const char* source, std::size_t size) const {
Document doc;
if (!source && size != 0) {
doc.malformed = true;
return doc;
}
std::size_t retained = std::min(size, MAX_DOCUMENT_BYTES);
while (retained > 0 && retained < size &&
(static_cast<uint8_t>(source[retained]) & 0xc0) == 0x80) --retained;
doc.source_bytes = retained;
doc.truncated = size > retained;
// Own an exact retained prefix so no find/substr operation can inspect
// attacker-controlled bytes beyond the documented source cap.
const std::string input(source ? source : "", retained);
if (!valid_utf8(input)) {
doc.malformed = true;
return doc;
}
Style style;
bool literal = false;
uint8_t section_depth = 0;
std::size_t total_runs = 0;
std::size_t offset = 0;
while (offset <= retained && doc.source_lines < MAX_SOURCE_LINES) {
std::size_t end = input.find('\n', offset);
if (end == std::string::npos || end > retained) end = retained;
std::size_t length = end - offset;
if (length > MAX_SOURCE_LINE_BYTES) {
length = MAX_SOURCE_LINE_BYTES;
while (length > 0 && offset + length < input.size() &&
(static_cast<uint8_t>(input[offset + length]) & 0xc0) == 0x80) --length;
doc.truncated = true;
}
std::string line = input.substr(offset, length);
if (!line.empty() && line.back() == '\r') line.pop_back();
const bool first = doc.source_lines == 0;
++doc.source_lines;
offset = end == retained ? retained + 1 : end + 1;
if (first && line.rfind("#!c=", 0) == 0) {
const std::string number = line.substr(4);
if (!number.empty() && std::all_of(number.begin(), number.end(),
[](unsigned char c) { return std::isdigit(c) != 0; })) {
char* parse_end = nullptr;
const unsigned long long parsed = std::strtoull(number.c_str(), &parse_end, 10);
if (parse_end && *parse_end == '\0') {
doc.cache_seconds = static_cast<uint32_t>(
std::min<unsigned long long>(parsed, MAX_CACHE_SECONDS));
} else doc.malformed = true;
} else doc.malformed = true;
continue;
}
if (line.rfind("#!bg=", 0) == 0) {
uint32_t value = 0;
if (color(line.substr(5), value)) { doc.has_background = true; doc.background = value; }
else doc.malformed = true;
continue;
}
if (line.rfind("#!fg=", 0) == 0) {
uint32_t value = 0;
if (color(line.substr(5), value)) { doc.has_foreground = true; doc.foreground = value; }
else doc.malformed = true;
continue;
}
if (line == "`=") { literal = !literal; continue; }
if (line.empty()) {
if (doc.blocks.size() >= MAX_BLOCKS) { doc.truncated = true; break; }
Block blank;
blank.depth = section_depth;
Run run;
run.text = " ";
blank.runs.push_back(std::move(run));
doc.blocks.push_back(std::move(blank));
continue;
}
while (!line.empty() && line[0] == '<') {
section_depth = 0;
line.erase(0, 1);
}
if (line.empty()) continue;
if (doc.blocks.size() >= MAX_BLOCKS) { doc.truncated = true; break; }
Block block;
block.depth = section_depth;
if (literal) {
if (line == "\\`=") line = "`=";
Run run;
run.text = line;
block.runs.push_back(std::move(run));
} else if (line[0] == '#') {
continue;
} else if (line[0] == '>') {
block.type = BlockType::HEADING;
std::size_t depth = 0;
while (depth < line.size() && line[depth] == '>') ++depth;
block.depth = static_cast<uint8_t>(std::min<std::size_t>(depth, 255));
section_depth = block.depth;
parse_inline(doc, block, line.substr(depth), style);
} else if (line[0] == '-') {
block.type = BlockType::DIVIDER;
} else if (line.rfind("`t", 0) == 0 || line.rfind("`{", 0) == 0 || line.find("`<") != std::string::npos) {
block.type = BlockType::UNSUPPORTED;
Run run;
run.text = "[Unsupported Micron content]";
block.runs.push_back(std::move(run));
doc.unsupported = true;
} else {
parse_inline(doc, block, line, style);
}
if (total_runs + block.runs.size() > MAX_TOTAL_RUNS) {
block.runs.resize(MAX_TOTAL_RUNS - total_runs);
doc.truncated = true;
}
total_runs += block.runs.size();
doc.blocks.push_back(std::move(block));
if (total_runs >= MAX_TOTAL_RUNS) break;
}
if (offset <= retained) doc.truncated = true;
if (literal) doc.malformed = true;
return doc;
}
} // namespace UI::LXMF::NomadNet
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace UI::LXMF::NomadNet {
enum class BlockType { TEXT, HEADING, DIVIDER, UNSUPPORTED };
enum class Alignment { LEFT, CENTER, RIGHT };
struct Run {
std::string text;
bool bold = false;
bool italic = false;
bool underline = false;
bool has_foreground = false;
uint32_t foreground = 0;
bool has_background = false;
uint32_t background = 0;
int link_index = -1;
};
struct Block {
BlockType type = BlockType::TEXT;
uint8_t depth = 0;
Alignment alignment = Alignment::LEFT;
std::vector<Run> runs;
};
struct Link {
std::string label;
std::string target;
std::string fields;
};
struct Document {
std::vector<Block> blocks;
std::vector<Link> links;
uint32_t cache_seconds = 0;
bool has_background = false;
uint32_t background = 0;
bool has_foreground = false;
uint32_t foreground = 0;
bool truncated = false;
bool malformed = false;
bool unsupported = false;
std::size_t source_bytes = 0;
std::size_t source_lines = 0;
};
class DocumentParser {
public:
static constexpr std::size_t MAX_DOCUMENT_BYTES = 64 * 1024;
static constexpr std::size_t MAX_SOURCE_LINES = 4096;
static constexpr std::size_t MAX_BLOCKS = 1024;
static constexpr std::size_t MAX_RUNS_PER_LINE = 128;
static constexpr std::size_t MAX_TOTAL_RUNS = 1024;
static constexpr std::size_t MAX_SOURCE_LINE_BYTES = 4096;
static constexpr std::size_t MAX_LINKS = 128;
static constexpr uint32_t MAX_CACHE_SECONDS = 7 * 24 * 60 * 60;
Document parse(const std::string& source) const;
Document parse(const char* source, std::size_t size) const;
};
} // namespace UI::LXMF::NomadNet
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <array>
#include <cstddef>
#include <string>
namespace UI::LXMF::NomadNet {
class PageHistory {
public:
static constexpr std::size_t MAX_DEPTH = 16;
const std::string& current() const { return _current; }
std::size_t depth() const { return _depth; }
void open(const std::string& address, bool add_history = true) {
if (address == _current) return;
if (add_history && !_current.empty()) {
if (_depth == MAX_DEPTH) {
for (std::size_t i = 1; i < MAX_DEPTH; ++i) _entries[i - 1] = std::move(_entries[i]);
--_depth;
}
_entries[_depth++] = _current;
}
_current = address;
}
void reload() {}
bool back() {
if (_depth == 0) return false;
_current = std::move(_entries[--_depth]);
return true;
}
void clear() {
_current.clear();
_depth = 0;
}
private:
std::array<std::string, MAX_DEPTH> _entries{};
std::string _current;
std::size_t _depth = 0;
};
} // namespace UI::LXMF::NomadNet
+137
View File
@@ -0,0 +1,137 @@
#pragma once
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <utility>
#include <vector>
#include "NomadNetMemory.h"
namespace UI::LXMF::NomadNet {
// Single-slot callback-to-main-loop handoff. Reticulum callbacks only publish
// bytes and status here; the main loop is the sole consumer and LVGL owner.
class AsyncMailbox {
public:
static constexpr std::size_t MAX_BYTES = 64 * 1024;
static constexpr std::size_t MAX_WIRE_BYTES = MAX_BYTES + 64;
enum class Kind { NONE, LINK_ESTABLISHED, LINK_CLOSED, RESPONSE, FAILED, OVERSIZED };
struct Event {
Kind kind = Kind::NONE;
ExternalVector<uint8_t> data;
std::size_t transfer_size = 0;
};
void begin(const std::vector<uint8_t>& link_token) {
Guard guard(_lock);
if (_link_token == link_token &&
(_event.kind == Kind::LINK_ESTABLISHED || _event.kind == Kind::LINK_CLOSED)) return;
_link_token = link_token;
_request_token.clear();
_event = Event{};
}
void expect_request(const std::vector<uint8_t>& request_token) {
Guard guard(_lock);
if (_request_token == request_token &&
(_event.kind == Kind::RESPONSE || _event.kind == Kind::FAILED ||
_event.kind == Kind::OVERSIZED)) return;
_request_token = request_token;
_event = Event{};
}
bool publish_link(const std::vector<uint8_t>& token, bool established) {
Guard guard(_lock);
if (token.empty()) return false;
if (_link_token.empty()) _link_token = token;
if (token != _link_token) return false;
// A remote may close immediately after delivering a response. Preserve
// terminal request events until the main loop consumes them instead of
// replacing the page/error with a generic LINK_CLOSED event.
if (!established && (_event.kind == Kind::RESPONSE ||
_event.kind == Kind::FAILED ||
_event.kind == Kind::OVERSIZED)) return false;
_event.kind = established ? Kind::LINK_ESTABLISHED : Kind::LINK_CLOSED;
_event.data.clear();
_event.transfer_size = 0;
return true;
}
bool publish_response(const std::vector<uint8_t>& token, const uint8_t* data,
std::size_t size, std::size_t transfer_size) {
Guard guard(_lock);
if (token.empty()) return false;
if (_request_token.empty()) _request_token = token;
if (token != _request_token) return false;
if (transfer_size > MAX_WIRE_BYTES || size > MAX_WIRE_BYTES || (!data && size != 0)) {
set_oversized(transfer_size);
return true;
}
_event.kind = Kind::RESPONSE;
_event.transfer_size = transfer_size;
_event.data.clear();
if (size != 0) _event.data.assign(data, data + size);
return true;
}
bool publish_failed(const std::vector<uint8_t>& token) {
Guard guard(_lock);
if (token.empty()) return false;
if (_request_token.empty()) _request_token = token;
if (token != _request_token) return false;
if (_event.kind == Kind::OVERSIZED || _event.kind == Kind::RESPONSE) return false;
_event.kind = Kind::FAILED;
_event.data.clear();
_event.transfer_size = 0;
return true;
}
bool publish_progress(const std::vector<uint8_t>& token, std::size_t transfer_size) {
Guard guard(_lock);
if (token.empty()) return false;
if (_request_token.empty()) _request_token = token;
if (token != _request_token) return false;
if (transfer_size <= MAX_WIRE_BYTES) return true;
set_oversized(transfer_size);
return true;
}
bool take(Event& event) {
Guard guard(_lock);
if (_event.kind == Kind::NONE) return false;
event = std::move(_event);
_event = Event{};
return true;
}
void clear() {
Guard guard(_lock);
_link_token.clear();
_request_token.clear();
_event = Event{};
}
private:
class Guard {
public:
explicit Guard(std::atomic_flag& lock) : _lock(lock) {
while (_lock.test_and_set(std::memory_order_acquire)) {}
}
~Guard() { _lock.clear(std::memory_order_release); }
private:
std::atomic_flag& _lock;
};
void set_oversized(std::size_t transfer_size) {
_event.kind = Kind::OVERSIZED;
_event.data.clear();
_event.transfer_size = transfer_size;
}
std::atomic_flag _lock = ATOMIC_FLAG_INIT;
std::vector<uint8_t> _link_token;
std::vector<uint8_t> _request_token;
Event _event;
};
} // namespace UI::LXMF::NomadNet
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <cstddef>
#include <new>
#include <vector>
#ifdef ARDUINO
#include <esp32-hal-psram.h>
#include <cstdlib>
#endif
namespace UI::LXMF::NomadNet {
// Large remote-page buffers must not compete with the ESP32-S3's constrained
// internal DRAM. The T-Deck build requires PSRAM; host tests use normal heap.
template <typename T>
class ExternalAllocator {
public:
using value_type = T;
ExternalAllocator() noexcept = default;
template <typename U>
ExternalAllocator(const ExternalAllocator<U>&) noexcept {}
template <typename U>
struct rebind { using other = ExternalAllocator<U>; };
T* allocate(std::size_t count) {
if (count == 0) return nullptr;
if (count > static_cast<std::size_t>(-1) / sizeof(T)) throw std::bad_alloc();
#ifdef ARDUINO
void* allocation = ps_malloc(count * sizeof(T));
#else
void* allocation = ::operator new(count * sizeof(T));
#endif
if (!allocation) throw std::bad_alloc();
return static_cast<T*>(allocation);
}
void deallocate(T* allocation, std::size_t) noexcept {
#ifdef ARDUINO
free(allocation);
#else
::operator delete(allocation);
#endif
}
template <typename U>
bool operator==(const ExternalAllocator<U>&) const noexcept { return true; }
template <typename U>
bool operator!=(const ExternalAllocator<U>&) const noexcept { return false; }
};
template <typename T>
using ExternalVector = std::vector<T, ExternalAllocator<T>>;
} // namespace UI::LXMF::NomadNet
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
#include "NomadNetMemory.h"
namespace UI::LXMF::NomadNet {
inline std::vector<uint8_t> no_form_request_data() {
// Link::request splices already encoded msgpack data into its envelope.
// NomadNet's no-form value is protocol-level nil, exactly one byte 0xc0.
return {0xc0};
}
class ResponseBuffer {
public:
static constexpr std::size_t MAX_BYTES = 64 * 1024;
bool assign(const uint8_t* data, std::size_t size) {
clear();
if ((!data && size != 0) || size > MAX_BYTES) return false;
if (size != 0) _bytes.assign(data, data + size);
return true;
}
const ExternalVector<uint8_t>& bytes() const { return _bytes; }
std::size_t size() const { return _bytes.size(); }
bool truncated() const { return false; }
void clear() { _bytes.clear(); }
private:
ExternalVector<uint8_t> _bytes;
};
inline bool normalize_response(const uint8_t* data, std::size_t size, ResponseBuffer& output) {
output.clear();
if (!data || size == 0) return false;
std::size_t offset = 0;
uint64_t payload = 0;
if (data[0] == 0xc4) {
if (size < 2) return false;
offset = 2;
payload = data[1];
} else if (data[0] == 0xc5) {
if (size < 3) return false;
offset = 3;
payload = (static_cast<uint64_t>(data[1]) << 8) | data[2];
} else if (data[0] == 0xc6) {
if (size < 5) return false;
offset = 5;
payload = (static_cast<uint64_t>(data[1]) << 24) |
(static_cast<uint64_t>(data[2]) << 16) |
(static_cast<uint64_t>(data[3]) << 8) | data[4];
} else {
// Pinned microReticulum 1bbd422 exposes packet responses as decoded
// raw bytes, while Resource responses retain the encoded MessagePack
// value. Valid UTF-8 Micron cannot begin with the bin markers above,
// so the representations are unambiguous here.
return output.assign(data, size);
}
if (payload > ResponseBuffer::MAX_BYTES) return false;
if (payload != static_cast<uint64_t>(size - offset)) return false;
return output.assign(data + offset, static_cast<std::size_t>(payload));
}
} // namespace UI::LXMF::NomadNet
+140
View File
@@ -0,0 +1,140 @@
#include "NomadNetScreen.h"
#ifdef ARDUINO
#include "Theme.h"
#include "../LVGL/LVGLInit.h"
#include "../TextAreaHelper.h"
#include <algorithm>
namespace UI::LXMF {
NomadNetScreen::NomadNetScreen() {
_screen=lv_obj_create(lv_scr_act()); lv_obj_set_size(_screen,320,240);
lv_obj_set_style_bg_color(_screen,Theme::surface(),0); lv_obj_set_style_border_width(_screen,0,0); lv_obj_set_style_pad_all(_screen,0,0);
lv_obj_t* header=lv_obj_create(_screen); lv_obj_set_size(header,LV_PCT(100),34); lv_obj_align(header,LV_ALIGN_TOP_MID,0,0);
lv_obj_set_style_bg_color(header,Theme::surfaceHeader(),0); lv_obj_set_style_border_width(header,0,0); lv_obj_set_style_pad_all(header,2,0);
_back_button=lv_btn_create(header); lv_obj_set_size(_back_button,38,28); lv_obj_align(_back_button,LV_ALIGN_LEFT_MID,0,0);
lv_obj_t* bl=lv_label_create(_back_button);lv_label_set_text(bl,LV_SYMBOL_LEFT);lv_obj_center(bl);
_home_button=lv_btn_create(header);lv_obj_set_size(_home_button,38,28);lv_obj_align(_home_button,LV_ALIGN_LEFT_MID,42,0);
lv_obj_t* hl=lv_label_create(_home_button);lv_label_set_text(hl,LV_SYMBOL_HOME);lv_obj_center(hl);
lv_obj_t* title=lv_label_create(header);lv_label_set_text(title,"NomadNet");lv_obj_center(title);
_reload_button=lv_btn_create(header);lv_obj_set_size(_reload_button,42,28);lv_obj_align(_reload_button,LV_ALIGN_RIGHT_MID,0,0);
lv_obj_t* rl=lv_label_create(_reload_button);lv_label_set_text(rl,LV_SYMBOL_REFRESH);lv_obj_center(rl);
lv_obj_t* address_row=lv_obj_create(_screen);lv_obj_set_size(address_row,LV_PCT(100),38);lv_obj_align(address_row,LV_ALIGN_TOP_MID,0,34);
lv_obj_set_style_bg_color(address_row,Theme::surface(),0);lv_obj_set_style_border_width(address_row,0,0);lv_obj_set_style_pad_all(address_row,3,0);
_address=lv_textarea_create(address_row);lv_obj_set_size(_address,270,30);lv_obj_align(_address,LV_ALIGN_LEFT_MID,0,0);
lv_textarea_set_one_line(_address,true);lv_textarea_set_max_length(_address,511);lv_textarea_set_placeholder_text(_address,"32-hex-destination:/page/path");
lv_obj_set_style_text_font(_address,&lv_font_montserrat_12,0);TextAreaHelper::enable_paste(_address);
_go_button=lv_btn_create(address_row);lv_obj_set_size(_go_button,42,30);lv_obj_align(_go_button,LV_ALIGN_RIGHT_MID,0,0);
lv_obj_t* gl=lv_label_create(_go_button);lv_label_set_text(gl,"Go");lv_obj_center(gl);
_status=lv_label_create(_screen);lv_obj_set_size(_status,312,18);lv_obj_align(_status,LV_ALIGN_TOP_LEFT,4,72);
lv_obj_set_style_text_font(_status,&lv_font_montserrat_12,0);lv_obj_set_style_text_color(_status,Theme::textTertiary(),0);
_content=lv_obj_create(_screen);lv_obj_set_size(_content,320,150);lv_obj_align(_content,LV_ALIGN_BOTTOM_MID,0,0);
lv_obj_set_style_bg_color(_content,Theme::surface(),0);lv_obj_set_style_border_width(_content,0,0);lv_obj_set_style_pad_all(_content,6,0);
lv_obj_set_flex_flow(_content,LV_FLEX_FLOW_COLUMN);lv_obj_set_flex_align(_content,LV_FLEX_ALIGN_START,LV_FLEX_ALIGN_START,LV_FLEX_ALIGN_START);
lv_obj_set_scroll_dir(_content,LV_DIR_VER);lv_obj_set_scrollbar_mode(_content,LV_SCROLLBAR_MODE_AUTO);
for(auto* o:{_back_button,_home_button,_reload_button,_go_button})lv_obj_add_event_cb(o,clicked,LV_EVENT_CLICKED,this);
lv_obj_add_event_cb(_address,clicked,LV_EVENT_READY,this);
set_status("Enter a NomadNet address");hide();
}
NomadNetScreen::~NomadNetScreen(){if(_screen)lv_obj_del(_screen);}
void NomadNetScreen::set_address(const std::string& value){lv_textarea_set_text(_address,value.c_str());}
std::string NomadNetScreen::address()const{return lv_textarea_get_text(_address);}
void NomadNetScreen::set_status(const char* value){lv_label_set_text(_status,value?value:"");}
void NomadNetScreen::set_page(const NomadNet::Document& document) {
auto* group = LVGL::LVGLInit::get_default_group();
if (group) for (auto* object : _focusables) lv_group_remove_obj(object);
lv_obj_clean(_content);
_focusables.clear();
_link_targets.clear();
std::size_t objects = 0;
std::size_t spans = 0;
bool render_truncated = false;
lv_obj_set_style_bg_color(_content, document.has_background
? lv_color_hex(document.background) : Theme::surface(), 0);
for (const auto& block : document.blocks) {
if (objects >= MAX_UI_OBJECTS || spans >= MAX_UI_SPANS) {
render_truncated = true;
break;
}
if (block.type == NomadNet::BlockType::DIVIDER) {
lv_obj_t* line = lv_obj_create(_content);
lv_obj_set_size(line, 304, 1);
lv_obj_set_style_bg_color(line, Theme::border(), 0);
lv_obj_set_style_border_width(line, 0, 0);
++objects;
continue;
}
// Spans preserve mixed inline styling and wrap the complete source line
// together instead of introducing breaks at every style transition.
lv_obj_t* text = lv_spangroup_create(_content);
const lv_coord_t indent = block.type == NomadNet::BlockType::HEADING
? 0 : static_cast<lv_coord_t>(std::min<unsigned>(block.depth, 8) * 4);
lv_obj_set_width(text, 304 - indent);
lv_obj_set_style_translate_x(text, indent, 0);
lv_spangroup_set_mode(text, LV_SPAN_MODE_BREAK);
lv_spangroup_set_overflow(text, LV_SPAN_OVERFLOW_CLIP);
lv_spangroup_set_lines(text, -1);
lv_spangroup_set_align(text, block.alignment == NomadNet::Alignment::CENTER
? LV_TEXT_ALIGN_CENTER : block.alignment == NomadNet::Alignment::RIGHT
? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT);
++objects;
for (const auto& run : block.runs) {
if (spans >= MAX_UI_SPANS) { render_truncated = true; break; }
lv_span_t* span = lv_spangroup_new_span(text);
lv_span_set_text(span, run.text.c_str());
const bool is_link = run.link_index >= 0 &&
static_cast<std::size_t>(run.link_index) < document.links.size();
lv_style_set_text_color(&span->style, is_link ? Theme::primaryLight() :
run.has_foreground ? lv_color_hex(run.foreground) :
document.has_foreground ? lv_color_hex(document.foreground) : Theme::textPrimary());
lv_style_set_text_font(&span->style,
(run.bold || block.type == NomadNet::BlockType::HEADING)
? &lv_font_montserrat_16 : &lv_font_montserrat_12);
lv_style_set_text_line_space(&span->style, 3);
if (run.underline || is_link)
lv_style_set_text_decor(&span->style, LV_TEXT_DECOR_UNDERLINE);
if (run.italic) lv_style_set_text_letter_space(&span->style, 1);
if (run.has_background) lv_style_set_bg_color(&span->style, lv_color_hex(run.background));
++spans;
}
lv_spangroup_refr_mode(text);
// A visual span cannot itself participate in the encoder focus group.
// Add one bounded, focusable activation control for every rendered link.
for (const auto& run : block.runs) {
if (run.link_index < 0 || static_cast<std::size_t>(run.link_index) >= document.links.size()) continue;
if (objects + 2 > MAX_UI_OBJECTS) { render_truncated = true; break; }
lv_obj_t* button = lv_btn_create(_content);
lv_obj_set_size(button, 304, 28);
lv_obj_set_style_pad_all(button, 4, 0);
lv_obj_t* label = lv_label_create(button);
const std::string caption = "Open: " + run.text;
lv_label_set_text(label, caption.c_str());
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
lv_obj_set_width(label, 294);
lv_obj_center(label);
_link_targets.push_back(document.links[run.link_index].target);
lv_obj_set_user_data(button, reinterpret_cast<void*>(_link_targets.size()));
lv_obj_add_event_cb(button, clicked, LV_EVENT_CLICKED, this);
_focusables.push_back(button);
if (_visible && group) lv_group_add_obj(group, button);
objects += 2;
}
}
if (document.truncated || render_truncated) {
lv_obj_t* notice = lv_label_create(_content);
lv_label_set_text(notice, "[Page truncated to device safety limits]");
lv_obj_set_style_text_color(notice, Theme::warning(), 0);
} else if (document.unsupported) {
lv_obj_t* notice = lv_label_create(_content);
lv_label_set_text(notice, "Forms, partials, tables and downloads are unsupported in this MVP.");
lv_obj_set_style_text_color(notice, Theme::warning(), 0);
}
}
void NomadNetScreen::show(){_visible=true;lv_obj_clear_flag(_screen,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(_screen);auto*g=LVGL::LVGLInit::get_default_group();if(g){for(auto*o:{_back_button,_home_button,_reload_button,_address,_go_button})lv_group_add_obj(g,o);for(auto*o:_focusables)lv_group_add_obj(g,o);lv_group_focus_obj(_address);}}
void NomadNetScreen::hide(){_visible=false;auto*g=LVGL::LVGLInit::get_default_group();if(g){for(auto*o:{_back_button,_home_button,_reload_button,_address,_go_button})lv_group_remove_obj(o);for(auto*o:_focusables)lv_group_remove_obj(o);}lv_obj_add_flag(_screen,LV_OBJ_FLAG_HIDDEN);}
void NomadNetScreen::clicked(lv_event_t*e){auto*s=static_cast<NomadNetScreen*>(lv_event_get_user_data(e));auto*t=lv_event_get_target(e);if(t==s->_back_button&&s->_back)s->_back();else if(t==s->_home_button&&s->_home)s->_home();else if(t==s->_reload_button&&s->_reload)s->_reload();else if((t==s->_go_button||t==s->_address)&&s->_open)s->_open(s->address());else{std::size_t n=reinterpret_cast<std::size_t>(lv_obj_get_user_data(t));if(n>0&&n<=s->_link_targets.size()&&s->_link)s->_link(s->_link_targets[n-1]);}}
}
#endif
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#ifdef ARDUINO
#include <functional>
#include <string>
#include <vector>
#include <lvgl.h>
#include "NomadNetDocument.h"
namespace UI::LXMF {
class NomadNetScreen {
public:
using Callback = std::function<void()>;
using OpenCallback = std::function<void(const std::string&)>;
using LinkCallback = std::function<void(const std::string&)>;
NomadNetScreen(); ~NomadNetScreen();
void set_back_callback(Callback cb) { _back = std::move(cb); }
void set_home_callback(Callback cb) { _home = std::move(cb); }
void set_reload_callback(Callback cb) { _reload = std::move(cb); }
void set_open_callback(OpenCallback cb) { _open = std::move(cb); }
void set_link_callback(LinkCallback cb) { _link = std::move(cb); }
void set_address(const std::string& address);
std::string address() const;
void set_status(const char* status);
void set_page(const NomadNet::Document& document);
void show(); void hide();
private:
static constexpr std::size_t MAX_UI_OBJECTS = 96;
static constexpr std::size_t MAX_UI_SPANS = 256;
lv_obj_t* _screen=nullptr; lv_obj_t* _back_button=nullptr; lv_obj_t* _home_button=nullptr;
lv_obj_t* _reload_button=nullptr; lv_obj_t* _address=nullptr; lv_obj_t* _go_button=nullptr;
lv_obj_t* _status=nullptr; lv_obj_t* _content=nullptr;
std::vector<lv_obj_t*> _focusables;
std::vector<std::string> _link_targets;
bool _visible = false;
Callback _back,_home,_reload; OpenCallback _open; LinkCallback _link;
static void clicked(lv_event_t* event);
};
}
#endif
+50
View File
@@ -0,0 +1,50 @@
#include "NomadNetUrl.h"
#include <algorithm>
#include <cctype>
namespace UI::LXMF::NomadNet {
bool Url::parse(const std::string& input, Url& result, std::string& error,
const std::string& current_destination) {
error.clear();
if (input.empty() || input.size() > 512) {
error = "Address is empty or too long";
return false;
}
for (unsigned char c : input) {
if (c < 0x20 || c == 0x7f) {
error = "Address contains control characters";
return false;
}
}
const auto colon = input.find(':');
if (colon != std::string::npos && input.find(':', colon + 1) != std::string::npos) {
error = "Address has too many separators";
return false;
}
std::string destination = colon == std::string::npos ? input : input.substr(0, colon);
std::string path = colon == std::string::npos ? DEFAULT_PATH : input.substr(colon + 1);
if (destination.empty()) destination = current_destination;
if (destination.size() != 32 ||
!std::all_of(destination.begin(), destination.end(), [](unsigned char c) { return std::isxdigit(c); })) {
error = "Destination must be exactly 32 hexadecimal characters";
return false;
}
std::transform(destination.begin(), destination.end(), destination.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (path.empty()) path = DEFAULT_PATH;
if (path.front() != '/') {
error = "Path must begin with /";
return false;
}
if (path.rfind("/file/", 0) == 0) {
error = "Downloads are not supported in this MVP";
return false;
}
result.destination_hex = std::move(destination);
result.path = std::move(path);
return true;
}
} // namespace UI::LXMF::NomadNet
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <string>
namespace UI::LXMF::NomadNet {
struct Url {
static constexpr const char* DEFAULT_PATH = "/page/index.mu";
std::string destination_hex;
std::string path;
std::string str() const { return destination_hex + ":" + path; }
static bool parse(const std::string& input, Url& result, std::string& error,
const std::string& current_destination = {});
};
} // namespace UI::LXMF::NomadNet
+441 -168
View File
@@ -60,10 +60,16 @@ void show_storage_error(const char* message) {
lv_obj_add_event_cb(storage_error_dialog, close_storage_error, LV_EVENT_VALUE_CHANGED, nullptr);
}
std::vector<uint8_t> token(const RNS::Bytes& bytes) {
if (!bytes || bytes.size() == 0) return {};
return std::vector<uint8_t>(bytes.data(), bytes.data() + bytes.size());
}
} // namespace
// Static singleton for Link callbacks
// Static singletons for Link callbacks
UIManager* UIManager::s_call_instance = nullptr;
UIManager* UIManager::s_nomad_instance = nullptr;
// LXST announce handler — tracks peers that support voice calls
class LXSTAnnounceHandler : public AnnounceHandler {
@@ -90,7 +96,9 @@ UIManager::UIManager(Reticulum& reticulum, ::LXMF::LXMRouter& router, ::LXMF::Me
// a Type::NONE state, then assign a real Destination later. (The fork
// had a default ctor that pyxis was implicitly relying on.)
_lxst_destination(RNS::Type::NONE),
_current_screen(SCREEN_CONVERSATION_LIST),
_home_screen(nullptr),
_network_screen(nullptr),
_nomadnet_screen(nullptr),
_conversation_list_screen(nullptr),
_chat_screen(nullptr),
_compose_screen(nullptr),
@@ -131,7 +139,14 @@ UIManager::~UIManager() {
// This pointer owns the long-lived incoming-destination callback, not only
// the current call. Clear it only when the manager itself is destroyed.
if (s_call_instance == this) s_call_instance = nullptr;
if (s_nomad_instance == this) s_nomad_instance = nullptr;
_nomad_mailbox.clear();
nomad_release_request();
if (_nomad_link && _nomad_link.status() != RNS::Type::Link::CLOSED) _nomad_link.teardown();
if (_home_screen) delete _home_screen;
if (_network_screen) delete _network_screen;
if (_nomadnet_screen) delete _nomadnet_screen;
if (_conversation_list_screen) delete _conversation_list_screen;
if (_chat_screen) delete _chat_screen;
if (_compose_screen) delete _compose_screen;
@@ -153,6 +168,9 @@ bool UIManager::init() {
INFO("Initializing UIManager");
// Create all screens
_home_screen = new HomeScreen();
_network_screen = new NetworkScreen();
_nomadnet_screen = new NomadNetScreen();
_conversation_list_screen = new ConversationListScreen();
_chat_screen = new ChatScreen();
_compose_screen = new ComposeScreen();
@@ -164,6 +182,22 @@ bool UIManager::init() {
_propagation_nodes_screen = new PropagationNodesScreen();
_call_screen = new CallScreen();
_home_screen->set_messages_callback([this]() { show_conversation_list(); });
_home_screen->set_nomadnet_callback([this]() { show_nomadnet(); });
_home_screen->set_network_callback([this]() { show_network(); });
_home_screen->set_settings_callback([this]() { show_settings(); });
_network_screen->set_back_callback([this]() { back(); });
_network_screen->set_home_callback([this]() { home(); });
_network_screen->set_announces_callback([this]() { show_announces(); });
_network_screen->set_status_callback([this]() { show_status(); });
_network_screen->set_radio_activity_callback([this]() { show_radio_activity(); });
_network_screen->set_propagation_nodes_callback([this]() { show_propagation_nodes(); });
_nomadnet_screen->set_back_callback([this]() { back(); });
_nomadnet_screen->set_home_callback([this]() { home(); });
_nomadnet_screen->set_reload_callback([this]() { nomad_reload(); });
_nomadnet_screen->set_open_callback([this](const std::string& address) { nomad_open(address); });
_nomadnet_screen->set_link_callback([this](const std::string& target) { nomad_open(target); });
// Set up callbacks for conversation list screen
_conversation_list_screen->set_conversation_selected_callback(
[this](const Bytes& peer_hash) { on_conversation_selected(peer_hash); }
@@ -176,14 +210,7 @@ bool UIManager::init() {
_conversation_list_screen->set_sync_callback(
[this]() { on_propagation_sync(); }
);
_conversation_list_screen->set_settings_callback(
[this]() { show_settings(); }
);
_conversation_list_screen->set_announces_callback(
[this]() { show_announces(); }
);
_conversation_list_screen->set_home_callback([this]() { home(); });
// Set up callbacks for chat screen
_chat_screen->set_back_callback(
@@ -258,9 +285,6 @@ bool UIManager::init() {
[this]() { on_back_from_settings(); }
);
_settings_screen->set_propagation_nodes_callback(
[this]() { show_propagation_nodes(); }
);
// Set up callbacks for propagation nodes screen
_propagation_nodes_screen->set_back_callback(
@@ -317,10 +341,6 @@ bool UIManager::init() {
_settings_screen->set_identity_hash(_router.identity().hash());
_settings_screen->set_lxmf_address(_router.delivery_destination().hash());
// Set up callback for status button in conversation list
_conversation_list_screen->set_status_callback(
[this]() { show_status(); }
);
// Set identity hash and LXMF address on status screen
_status_screen->set_identity_hash(_router.identity().hash());
@@ -342,9 +362,9 @@ bool UIManager::init() {
}
);
// Load conversations and show conversation list
// Load conversations; outer launcher is the application root.
_conversation_list_screen->load_conversations(_store);
show_conversation_list();
show_home();
// Create LXST IN destination for incoming voice calls
_lxst_destination = Destination(_router.identity(), Type::Destination::IN,
@@ -352,6 +372,7 @@ bool UIManager::init() {
_lxst_destination.set_proof_strategy(Type::Destination::PROVE_NONE);
_lxst_destination.set_link_established_callback(on_lxst_link_established);
s_call_instance = this;
s_nomad_instance = this;
// Register LXST announce handler
s_lxst_announce_handler = std::make_shared<LXSTAnnounceHandler>();
@@ -377,23 +398,24 @@ void UIManager::update() {
// (the newest were rendered synchronously on open). Done here, on the main
// loop, so each small batch only briefly holds the LVGL lock instead of the
// whole page blocking it past LVGLLock's 5s timeout.
if (_current_screen == SCREEN_CHAT && _chat_screen) {
if (_navigation.current() == Route::CHAT && _chat_screen) {
_chat_screen->tick_background_fill();
}
// Gather + render the announce list off the LVGL lock on the main loop (its
// refresh() just arms a flag). Iterating the path table on the LVGL task raced
// the main-loop path-table writes and blocked past the lock timeout once the
// table was non-empty — this serializes the gather with the writes instead.
if (_current_screen == SCREEN_ANNOUNCES && _announce_list_screen) {
if (_navigation.current() == Route::ANNOUNCES && _announce_list_screen) {
_announce_list_screen->tick();
}
if (_current_screen == SCREEN_SETTINGS && _settings_screen) {
if (_navigation.current() == Route::SETTINGS && _settings_screen) {
_settings_screen->tick(); // keep the live clock / GPS / system readouts ticking
}
const uint32_t now = millis();
nomad_update();
// Snapshot acquisition is deliberately before LVGL_LOCK and is coalesced
// to the screen's render cadence. The provider never touches radio or SPI.
if (_current_screen == SCREEN_RADIO_ACTIVITY &&
if (_navigation.current() == Route::RADIO_ACTIVITY &&
_radio_activity_screen && _radio_activity_snapshot_provider &&
_radio_activity_screen->render_due(now)) {
const RadioActivity::Snapshot snapshot = _radio_activity_snapshot_provider();
@@ -448,7 +470,7 @@ void UIManager::update() {
_conversation_list_screen->update_status();
}
// Update status screen if visible
if (_current_screen == SCREEN_STATUS && _status_screen) {
if (_navigation.current() == Route::STATUS && _status_screen) {
_status_screen->refresh();
}
}
@@ -461,7 +483,7 @@ void UIManager::update() {
// the SPI flush isn't saturated by sustained inbound traffic.
static constexpr uint32_t COALESCE_MS = 750;
if (_pending_conversation_refresh
&& _current_screen == SCREEN_CONVERSATION_LIST
&& _navigation.current() == Route::MESSAGES
&& (now - _last_conversation_refresh_ms) >= COALESCE_MS) {
_pending_conversation_refresh = false;
_last_conversation_refresh_ms = now;
@@ -471,80 +493,141 @@ void UIManager::update() {
}
}
void UIManager::show_conversation_list() {
LVGL_LOCK();
INFO("Showing conversation list");
_conversation_list_screen->refresh();
_pending_conversation_refresh = false;
_last_conversation_refresh_ms = millis();
_conversation_list_screen->show();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
void UIManager::hide_all_screens() {
if (_home_screen) _home_screen->hide();
if (_network_screen) _network_screen->hide();
if (_nomadnet_screen) _nomadnet_screen->hide();
if (_conversation_list_screen) _conversation_list_screen->hide();
if (_chat_screen) _chat_screen->hide();
if (_compose_screen) _compose_screen->hide();
if (_announce_list_screen) _announce_list_screen->hide();
if (_status_screen) _status_screen->hide();
if (_radio_activity_screen) _radio_activity_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
if (_qr_screen) _qr_screen->hide();
if (_settings_screen) _settings_screen->hide();
if (_propagation_nodes_screen) _propagation_nodes_screen->hide();
if (_call_screen) _call_screen->hide();
}
_current_screen = SCREEN_CONVERSATION_LIST;
void UIManager::show_home() {
home();
}
void UIManager::show_network() {
navigate(Route::NETWORK);
}
void UIManager::show_nomadnet() {
navigate(Route::NOMADNET);
}
void UIManager::navigate(Route route) {
if (_navigation.current() == Route::NOMADNET && route != Route::NOMADNET)
nomad_stop_transport();
LVGL_LOCK();
_navigation.navigate(route);
render_route(route);
}
void UIManager::replace_route(Route route) {
LVGL_LOCK();
_navigation.replace(route);
render_route(route);
}
void UIManager::back() {
if (_navigation.current() == Route::NOMADNET && _nomad_history.back()) {
nomad_open(_nomad_history.current(), false);
return;
}
if (_navigation.current() == Route::NOMADNET) nomad_stop_transport();
LVGL_LOCK();
if (!_navigation.back()) return;
render_route(_navigation.current());
}
void UIManager::home() {
if (_navigation.current() == Route::NOMADNET) nomad_stop_transport();
LVGL_LOCK();
_navigation.home();
render_route(Route::HOME);
}
void UIManager::render_route(Route route) {
// This is the only place that changes screen visibility. Every hide()
// removes its focus objects before the selected screen adds its own.
hide_all_screens();
switch (route) {
case Route::HOME:
_home_screen->show();
break;
case Route::MESSAGES:
_conversation_list_screen->refresh();
_pending_conversation_refresh = false;
_last_conversation_refresh_ms = millis();
_conversation_list_screen->show();
break;
case Route::CHAT:
_chat_screen->load_conversation(_current_peer_hash, _store);
_chat_screen->show();
break;
case Route::COMPOSE:
_compose_screen->clear();
_compose_screen->show();
break;
case Route::NETWORK:
_network_screen->show();
break;
case Route::ANNOUNCES:
_announce_list_screen->refresh();
_announce_list_screen->show();
break;
case Route::STATUS:
_status_screen->refresh();
_status_screen->show();
break;
case Route::RADIO_ACTIVITY:
_radio_activity_screen->show();
break;
case Route::QR:
_qr_screen->show();
break;
case Route::PROPAGATION_NODES:
_propagation_nodes_screen->show();
break;
case Route::NOMADNET:
_nomadnet_screen->show();
break;
case Route::SETTINGS:
_settings_screen->refresh();
_settings_screen->show();
break;
case Route::CALL: if (_call_screen) _call_screen->show(); break;
}
}
void UIManager::show_conversation_list() {
INFO("Showing conversation list");
navigate(Route::MESSAGES);
}
void UIManager::show_chat(const Bytes& peer_hash) {
LVGL_LOCK();
std::string hash_hex = peer_hash.toHex().substr(0, 8);
std::string msg = "Showing chat with peer " + hash_hex + "...";
INFO(msg.c_str());
_current_peer_hash = peer_hash;
_chat_screen->load_conversation(peer_hash, _store);
_chat_screen->show();
_conversation_list_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
if (_radio_activity_screen) _radio_activity_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
if (_call_screen) _call_screen->hide();
_current_screen = SCREEN_CHAT;
navigate(Route::CHAT);
}
void UIManager::show_compose() {
LVGL_LOCK();
INFO("Showing compose screen");
_compose_screen->clear();
_compose_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
if (_radio_activity_screen) _radio_activity_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
_current_screen = SCREEN_COMPOSE;
navigate(Route::COMPOSE);
}
void UIManager::show_announces() {
LVGL_LOCK();
INFO("Showing announces screen");
_announce_list_screen->refresh();
_announce_list_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_status_screen->hide();
if (_radio_activity_screen) _radio_activity_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
_current_screen = SCREEN_ANNOUNCES;
navigate(Route::ANNOUNCES);
}
void UIManager::show_status() {
@@ -595,33 +678,12 @@ void UIManager::show_status() {
_status_screen->set_propagation_node(display);
}
_status_screen->refresh();
_status_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
if (_radio_activity_screen) _radio_activity_screen->hide();
_current_screen = SCREEN_STATUS;
navigate(Route::STATUS);
}
void UIManager::show_radio_activity() {
LVGL_LOCK();
INFO("Showing Radio Activity screen");
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
_settings_screen->hide();
_propagation_nodes_screen->hide();
if (_call_screen) _call_screen->hide();
_radio_activity_screen->show();
_current_screen = SCREEN_RADIO_ACTIVITY;
navigate(Route::RADIO_ACTIVITY);
}
void UIManager::set_radio_activity_source(
@@ -640,20 +702,8 @@ void UIManager::on_new_message() {
}
void UIManager::show_settings() {
LVGL_LOCK();
INFO("Showing settings screen");
_settings_screen->refresh();
_settings_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
if (_radio_activity_screen) _radio_activity_screen->hide();
_propagation_nodes_screen->hide();
_current_screen = SCREEN_SETTINGS;
navigate(Route::SETTINGS);
}
void UIManager::show_propagation_nodes() {
@@ -683,16 +733,7 @@ void UIManager::show_propagation_nodes() {
_propagation_nodes_screen->load_nodes(*_propagation_manager, selected_hash, auto_select);
}
_propagation_nodes_screen->show();
_conversation_list_screen->hide();
_chat_screen->hide();
_compose_screen->hide();
_announce_list_screen->hide();
_status_screen->hide();
if (_radio_activity_screen) _radio_activity_screen->hide();
_settings_screen->hide();
_current_screen = SCREEN_PROPAGATION_NODES;
navigate(Route::PROPAGATION_NODES);
}
void UIManager::set_propagation_node_manager(::LXMF::PropagationNodeManager* manager) {
@@ -719,7 +760,7 @@ void UIManager::set_gps(TinyGPSPlus* gps) {
}
void UIManager::on_back_to_conversation_list() {
show_conversation_list();
back();
}
bool UIManager::on_send_message_from_chat(const String& content) {
@@ -745,13 +786,15 @@ void UIManager::on_call_from_chat() {
bool UIManager::on_send_message_from_compose(const Bytes& dest_hash, const String& message) {
if (!send_message(dest_hash, message)) return false;
// Switch to chat screen only after the message is durably accepted.
show_chat(dest_hash);
// Replace Compose with Chat so Back returns to Messages instead of
// reopening a cleared compose form.
_current_peer_hash = dest_hash;
replace_route(Route::CHAT);
return true;
}
void UIManager::on_cancel_compose() {
show_conversation_list();
back();
}
void UIManager::on_announce_selected(const Bytes& dest_hash) {
@@ -764,38 +807,31 @@ void UIManager::on_announce_selected(const Bytes& dest_hash) {
}
void UIManager::on_back_from_announces() {
show_conversation_list();
back();
}
void UIManager::on_back_from_status() {
show_conversation_list();
back();
}
void UIManager::on_back_from_radio_activity() {
show_status();
back();
}
void UIManager::on_share_from_status() {
LVGL_LOCK();
_status_screen->hide();
if (_radio_activity_screen) _radio_activity_screen->hide();
_qr_screen->show();
_current_screen = SCREEN_QR;
navigate(Route::QR);
}
void UIManager::on_back_from_qr() {
LVGL_LOCK();
_qr_screen->hide();
_status_screen->show();
_current_screen = SCREEN_STATUS;
back();
}
void UIManager::on_back_from_settings() {
show_conversation_list();
back();
}
void UIManager::on_back_from_propagation_nodes() {
show_settings();
back();
}
void UIManager::on_propagation_node_selected(const Bytes& node_hash) {
@@ -930,7 +966,7 @@ bool UIManager::send_message(const Bytes& dest_hash, const String& content) {
return false;
}
if (_current_screen == SCREEN_CHAT && _current_peer_hash == dest_hash) {
if (_navigation.current() == Route::CHAT && _current_peer_hash == dest_hash) {
_chat_screen->add_message(message, true);
}
@@ -973,7 +1009,7 @@ void UIManager::on_message_received(::LXMF::LXMessage& message) {
LVGL_LOCK();
// Update UI if we're viewing this conversation
bool viewing_this_chat = (_current_screen == SCREEN_CHAT && _current_peer_hash == message.source_hash());
bool viewing_this_chat = (_navigation.current() == Route::CHAT && _current_peer_hash == message.source_hash());
if (viewing_this_chat) {
_chat_screen->add_message(message, false);
}
@@ -1003,7 +1039,7 @@ void UIManager::on_message_delivered(::LXMF::LXMessage& message) {
LVGL_LOCK();
// Update UI if we're viewing this conversation
if (_current_screen == SCREEN_CHAT && _current_peer_hash == message.destination_hash()) {
if (_navigation.current() == Route::CHAT && _current_peer_hash == message.destination_hash()) {
_chat_screen->update_message_status(message.hash(), true);
}
}
@@ -1023,42 +1059,281 @@ void UIManager::on_message_failed(::LXMF::LXMessage& message) {
LVGL_LOCK();
// Update UI if we're viewing this conversation
if (_current_screen == SCREEN_CHAT && _current_peer_hash == message.destination_hash()) {
if (_navigation.current() == Route::CHAT && _current_peer_hash == message.destination_hash()) {
_chat_screen->update_message_status(message.hash(), false);
}
}
void UIManager::refresh_current_screen() {
LVGL_LOCK();
switch (_current_screen) {
case SCREEN_CONVERSATION_LIST:
switch (_navigation.current()) {
case Route::MESSAGES:
_conversation_list_screen->refresh();
break;
case SCREEN_CHAT:
case Route::CHAT:
_chat_screen->refresh();
break;
case SCREEN_COMPOSE:
case Route::COMPOSE:
// No refresh needed
break;
case SCREEN_ANNOUNCES:
case Route::ANNOUNCES:
_announce_list_screen->refresh();
break;
case SCREEN_STATUS:
case Route::STATUS:
_status_screen->refresh();
break;
case SCREEN_SETTINGS:
case Route::SETTINGS:
_settings_screen->refresh();
break;
case SCREEN_PROPAGATION_NODES:
case Route::PROPAGATION_NODES:
_propagation_nodes_screen->refresh();
break;
case SCREEN_CALL:
case Route::CALL:
break;
case SCREEN_QR:
case Route::QR:
break;
}
}
// ── NomadNet browser transport ──
void UIManager::nomad_release_request() {
if (!_nomad_request) return;
RequestReceipt receipt = _nomad_request;
_nomad_request = RequestReceipt(Type::NONE);
// Pinned 1bbd422 does not erase successful packet receipts or pending
// receipts on Link close. request_timed_out() is the only public erasure
// path; mailbox tokens are cleared by callers before this compatibility
// cleanup so its failed callback cannot replace the terminal UI event.
if (receipt.get_status() != Type::RequestReceipt::FAILED) {
receipt.request_timed_out(PacketReceipt(Type::NONE));
}
}
void UIManager::nomad_stop_transport() {
_nomad_state = NomadState::IDLE;
_nomad_deadline_ms = 0;
_nomad_mailbox.clear();
nomad_release_request();
if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown();
_nomad_link = Link(Type::NONE);
}
void UIManager::nomad_open(const std::string& address, bool add_history) {
NomadNet::Url parsed;
std::string error;
if (!NomadNet::Url::parse(address, parsed, error, _nomad_url.destination_hex)) {
LVGL_LOCK();
_nomadnet_screen->set_status(error.c_str());
return;
}
_nomad_mailbox.clear();
nomad_release_request();
if (_nomad_link && _nomad_link.status() != Type::Link::CLOSED) _nomad_link.teardown();
_nomad_link = Link(Type::NONE);
_nomad_request = RequestReceipt(Type::NONE);
_nomad_response.clear();
_nomad_url = parsed;
_nomad_history.open(parsed.str(), add_history);
_nomad_destination_hash = Bytes();
_nomad_destination_hash.assignHex(parsed.destination_hex.c_str());
{
LVGL_LOCK();
_nomadnet_screen->set_address(parsed.str());
_nomadnet_screen->set_status("Discovering path...");
}
if (Transport::has_path(_nomad_destination_hash)) nomad_start_link();
else {
Transport::request_path(_nomad_destination_hash);
_nomad_state = NomadState::PATH;
_nomad_deadline_ms = millis() + 10000;
}
}
void UIManager::nomad_reload() {
if (_nomad_history.current().empty()) {
LVGL_LOCK();
_nomadnet_screen->set_status("Enter a NomadNet address");
return;
}
nomad_open(_nomad_history.current(), false);
}
void UIManager::nomad_start_link() {
Identity identity = Identity::recall(_nomad_destination_hash);
if (!identity) {
_nomad_state = NomadState::IDLE;
LVGL_LOCK();
_nomadnet_screen->set_status("Node identity is not known");
return;
}
Destination destination(identity, Type::Destination::OUT,
Type::Destination::SINGLE, "nomadnetwork", "node");
if (destination.hash() != _nomad_destination_hash) {
_nomad_state = NomadState::IDLE;
LVGL_LOCK();
_nomadnet_screen->set_status("Identity does not match node address");
return;
}
_nomad_link = Link(destination, on_nomad_link_established, on_nomad_link_closed);
_nomad_mailbox.begin(token(_nomad_link.link_id()));
_nomad_state = NomadState::LINK;
_nomad_deadline_ms = millis() + 30000;
LVGL_LOCK();
_nomadnet_screen->set_status("Establishing encrypted link...");
}
void UIManager::nomad_send_request() {
if (!_nomad_link || _nomad_link.status() != Type::Link::ACTIVE) return;
const auto nil = NomadNet::no_form_request_data();
_nomad_request = _nomad_link.request(
Bytes(reinterpret_cast<const uint8_t*>(_nomad_url.path.data()), _nomad_url.path.size()),
Bytes(nil.data(), nil.size()), on_nomad_response, on_nomad_failed,
on_nomad_progress, 30.0);
if (!_nomad_request) {
_nomad_state = NomadState::IDLE;
_nomad_mailbox.clear();
if (_nomad_link) _nomad_link.teardown();
_nomad_link = Link(Type::NONE);
_nomad_request = RequestReceipt(Type::NONE);
LVGL_LOCK();
_nomadnet_screen->set_status("Request could not be sent");
return;
}
_nomad_mailbox.expect_request(token(_nomad_request.request_id()));
_nomad_state = NomadState::REQUEST;
_nomad_deadline_ms = millis() + 30000;
LVGL_LOCK();
_nomadnet_screen->set_status("Requesting page...");
}
void UIManager::nomad_update() {
const uint32_t now = millis();
if (_nomad_state == NomadState::PATH && Transport::has_path(_nomad_destination_hash)) {
nomad_start_link();
} else if (_nomad_state != NomadState::IDLE && _nomad_deadline_ms != 0 &&
static_cast<int32_t>(now - _nomad_deadline_ms) >= 0) {
_nomad_state = NomadState::IDLE;
_nomad_mailbox.clear();
nomad_release_request();
if (_nomad_link) _nomad_link.teardown();
_nomad_link = Link(Type::NONE);
_nomad_request = RequestReceipt(Type::NONE);
LVGL_LOCK();
_nomadnet_screen->set_status("NomadNet operation timed out");
return;
}
NomadNet::AsyncMailbox::Event event;
if (!_nomad_mailbox.take(event)) return;
switch (event.kind) {
case NomadNet::AsyncMailbox::Kind::LINK_ESTABLISHED:
if (_nomad_state == NomadState::LINK) {
_nomad_link.identify(_router.identity());
nomad_send_request();
}
break;
case NomadNet::AsyncMailbox::Kind::LINK_CLOSED:
_nomad_state = NomadState::IDLE;
_nomad_mailbox.clear();
nomad_release_request();
_nomad_link = Link(Type::NONE);
_nomad_request = RequestReceipt(Type::NONE);
{ LVGL_LOCK(); _nomadnet_screen->set_status("NomadNet link closed"); }
break;
case NomadNet::AsyncMailbox::Kind::FAILED:
_nomad_state = NomadState::IDLE;
_nomad_mailbox.clear();
nomad_release_request();
if (_nomad_link) _nomad_link.teardown();
_nomad_link = Link(Type::NONE);
_nomad_request = RequestReceipt(Type::NONE);
{ LVGL_LOCK(); _nomadnet_screen->set_status("Page request failed"); }
break;
case NomadNet::AsyncMailbox::Kind::OVERSIZED:
_nomad_state = NomadState::IDLE;
_nomad_response.clear();
_nomad_mailbox.clear();
nomad_release_request();
if (_nomad_link) _nomad_link.teardown();
_nomad_link = Link(Type::NONE);
_nomad_request = RequestReceipt(Type::NONE);
{ LVGL_LOCK(); _nomadnet_screen->set_status("Page exceeds 64 KiB limit"); }
break;
case NomadNet::AsyncMailbox::Kind::RESPONSE: {
_nomad_state = NomadState::IDLE;
// The callback payload has been moved into this local event. Each
// browser request uses a fresh Link, so release session state now;
// this also covers malformed responses. Clear callback tokens
// first so teardown cannot replace the terminal page status.
_nomad_mailbox.clear();
nomad_release_request();
if (_nomad_link) _nomad_link.teardown();
_nomad_link = Link(Type::NONE);
_nomad_request = RequestReceipt(Type::NONE);
if (!NomadNet::normalize_response(event.data.data(), event.data.size(), _nomad_response)) {
LVGL_LOCK();
_nomadnet_screen->set_status("Malformed NomadNet response");
break;
}
const auto& bytes = _nomad_response.bytes();
const NomadNet::Document document = _nomad_parser.parse(
reinterpret_cast<const char*>(bytes.data()), bytes.size());
LVGL_LOCK();
if (document.malformed && document.blocks.empty())
_nomadnet_screen->set_status("Page is not valid UTF-8/Micron");
else {
_nomadnet_screen->set_page(document);
_nomadnet_screen->set_status(document.truncated ? "Page loaded (truncated)" : "Page loaded");
}
break;
}
case NomadNet::AsyncMailbox::Kind::NONE:
break;
}
}
void UIManager::on_nomad_link_established(Link& link) {
if (s_nomad_instance) s_nomad_instance->_nomad_mailbox.publish_link(token(link.link_id()), true);
}
void UIManager::on_nomad_link_closed(Link& link) {
if (s_nomad_instance) s_nomad_instance->_nomad_mailbox.publish_link(token(link.link_id()), false);
}
void UIManager::on_nomad_response(const RequestReceipt& receipt) {
if (!s_nomad_instance) return;
const std::size_t transfer = receipt.response_transfer_size();
if (transfer > NomadNet::AsyncMailbox::MAX_WIRE_BYTES) {
s_nomad_instance->_nomad_mailbox.publish_progress(token(receipt.request_id()), transfer);
return;
}
const Bytes response = receipt.get_response();
s_nomad_instance->_nomad_mailbox.publish_response(
token(receipt.request_id()), response ? response.data() : nullptr,
response ? response.size() : 0, transfer);
}
void UIManager::on_nomad_failed(const RequestReceipt& receipt) {
if (s_nomad_instance) s_nomad_instance->_nomad_mailbox.publish_failed(token(receipt.request_id()));
}
void UIManager::on_nomad_progress(const RequestReceipt& receipt) {
if (!s_nomad_instance) return;
// DEPENDENCY HARDENING GAP: pinned microReticulum accepts/accumulates a
// Resource before this application progress callback. We reject as soon as
// response_transfer_size() is exposed, but true pre-allocation admission
// control remains dependency-level; physical large-Resource validation is pending.
const std::size_t transfer = receipt.response_transfer_size();
s_nomad_instance->_nomad_mailbox.publish_progress(token(receipt.request_id()), transfer);
if (transfer > NomadNet::AsyncMailbox::MAX_WIRE_BYTES) {
// Marking the receipt failed makes the pinned Resource progress path
// cancel on its next callback and erases it from the Link pending set.
const_cast<RequestReceipt&>(receipt).request_timed_out(PacketReceipt(Type::NONE));
}
}
// ── LXST Voice Call Implementation ──
// NVS breadcrumb for crash debugging (survives reboot, unlike USB CDC output)
@@ -1130,9 +1405,7 @@ void UIManager::call_initiate(const Bytes& peer_hash) {
_call_screen->set_peer(peer_dest.hash());
_call_screen->set_state(CallScreen::CallState::CONNECTING);
_call_screen->set_muted(false);
_call_screen->show();
_chat_screen->hide();
_current_screen = SCREEN_CALL;
navigate(Route::CALL);
lxst_breadcrumb(5, ESP.getFreeHeap());
@@ -1283,8 +1556,10 @@ void UIManager::call_hangup() {
_call_screen->set_state(CallScreen::CallState::ENDED);
}
// Brief delay then return to conversation list
show_conversation_list();
// Restore the screen that owned the call rather than pushing CALL behind a
// new Messages route. If teardown happened before the call UI appeared,
// leave the current route untouched.
if (_navigation.current() == Route::CALL) back();
}
void UIManager::call_set_mute(bool muted) {
@@ -1868,8 +2143,7 @@ void UIManager::call_ended() {
_call_screen->set_state(CallScreen::CallState::ENDED);
// Return to conversation list after brief display
show_conversation_list();
if (_navigation.current() == Route::CALL) back();
}
void UIManager::pump_call_tx() {
@@ -1982,12 +2256,11 @@ void UIManager::call_update() {
}
// Show incoming call UI (deferred from link callback to LVGL-safe context)
if (_call_state == CallState::INCOMING_RINGING && _current_screen != SCREEN_CALL) {
if (_call_state == CallState::INCOMING_RINGING && _navigation.current() != Route::CALL) {
_call_screen->set_peer(_call_peer_hash);
_call_screen->set_state(CallScreen::CallState::INCOMING_RINGING);
_call_screen->set_muted(false);
_call_screen->show();
_current_screen = SCREEN_CALL;
navigate(Route::CALL);
// Play notification tone
if (_settings_screen) {
+50 -15
View File
@@ -9,6 +9,15 @@
#include <lvgl.h>
#include <atomic>
#include <functional>
#include "HomeScreen.h"
#include "NetworkScreen.h"
#include "NomadNetScreen.h"
#include "NavigationStack.h"
#include "NomadNetUrl.h"
#include "NomadNetDocument.h"
#include "NomadNetProtocol.h"
#include "NomadNetHistory.h"
#include "NomadNetMailbox.h"
#include "ConversationListScreen.h"
#include "ChatScreen.h"
#include "ComposeScreen.h"
@@ -96,6 +105,13 @@ public:
void stop_loopback();
bool is_loopback() const { return _call_loopback; }
void show_home();
void show_network();
void show_nomadnet();
void navigate(Route route);
void back();
void home();
/**
* Show conversation list screen
*/
@@ -126,7 +142,7 @@ public:
void show_radio_activity();
/** True only while sampling/rendering the dedicated activity screen. */
bool radio_activity_visible() const { return _current_screen == SCREEN_RADIO_ACTIVITY; }
bool radio_activity_visible() const { return _navigation.current() == Route::RADIO_ACTIVITY; }
/** Install a copied snapshot provider and immutable active RF settings. */
void set_radio_activity_source(
@@ -294,25 +310,12 @@ public:
#endif
private:
enum Screen {
SCREEN_CONVERSATION_LIST,
SCREEN_CHAT,
SCREEN_COMPOSE,
SCREEN_ANNOUNCES,
SCREEN_STATUS,
SCREEN_RADIO_ACTIVITY,
SCREEN_QR,
SCREEN_SETTINGS,
SCREEN_PROPAGATION_NODES,
SCREEN_CALL
};
RNS::Reticulum& _reticulum;
::LXMF::LXMRouter& _router;
::LXMF::MessageStore& _store;
RNS::Destination _lxst_destination;
Screen _current_screen;
NavigationStack _navigation;
RNS::Bytes _current_peer_hash;
// Conversation-list refresh debouncing. on_message_received() used
@@ -325,6 +328,9 @@ private:
volatile bool _pending_conversation_refresh;
uint32_t _last_conversation_refresh_ms;
HomeScreen* _home_screen;
NetworkScreen* _network_screen;
NomadNetScreen* _nomadnet_screen;
ConversationListScreen* _conversation_list_screen;
ChatScreen* _chat_screen;
ComposeScreen* _compose_screen;
@@ -343,6 +349,35 @@ private:
bool _initialized;
NomadNet::Url _nomad_url;
NomadNet::DocumentParser _nomad_parser;
NomadNet::ResponseBuffer _nomad_response;
NomadNet::PageHistory _nomad_history;
NomadNet::AsyncMailbox _nomad_mailbox;
RNS::Bytes _nomad_destination_hash;
RNS::Link _nomad_link{RNS::Type::NONE};
RNS::RequestReceipt _nomad_request{RNS::Type::NONE};
enum class NomadState { IDLE, PATH, LINK, REQUEST };
NomadState _nomad_state = NomadState::IDLE;
uint32_t _nomad_deadline_ms = 0;
static UIManager* s_nomad_instance;
void render_route(Route route);
void replace_route(Route route);
void hide_all_screens();
void nomad_open(const std::string& address, bool add_history = true);
void nomad_reload();
void nomad_update();
void nomad_start_link();
void nomad_send_request();
void nomad_release_request();
void nomad_stop_transport();
static void on_nomad_link_established(RNS::Link& link);
static void on_nomad_link_closed(RNS::Link& link);
static void on_nomad_response(const RNS::RequestReceipt& receipt);
static void on_nomad_failed(const RNS::RequestReceipt& receipt);
static void on_nomad_progress(const RNS::RequestReceipt& receipt);
// Screen navigation handlers
void on_conversation_selected(const RNS::Bytes& peer_hash);
void on_new_message();