From d0e1ce273cbfc5f8922377c547f434bb592ec46a Mon Sep 17 00:00:00 2001 From: "torlando-agent[bot]" <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:20:45 +0000 Subject: [PATCH] feat(ui): add app launcher and NomadNet MVP --- lib/lv_conf.h | 2 +- .../UI/LXMF/ConversationListScreen.cpp | 71 +- lib/tdeck_ui/UI/LXMF/ConversationListScreen.h | 27 +- lib/tdeck_ui/UI/LXMF/HomeScreen.cpp | 62 ++ lib/tdeck_ui/UI/LXMF/HomeScreen.h | 25 + lib/tdeck_ui/UI/LXMF/NavigationStack.h | 63 ++ lib/tdeck_ui/UI/LXMF/NetworkScreen.cpp | 38 ++ lib/tdeck_ui/UI/LXMF/NetworkScreen.h | 23 + lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp | 279 ++++++++ lib/tdeck_ui/UI/LXMF/NomadNetDocument.h | 68 ++ lib/tdeck_ui/UI/LXMF/NomadNetHistory.h | 47 ++ lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h | 137 ++++ lib/tdeck_ui/UI/LXMF/NomadNetMemory.h | 56 ++ lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h | 68 ++ lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp | 140 ++++ lib/tdeck_ui/UI/LXMF/NomadNetScreen.h | 39 ++ lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp | 50 ++ lib/tdeck_ui/UI/LXMF/NomadNetUrl.h | 18 + lib/tdeck_ui/UI/LXMF/UIManager.cpp | 609 +++++++++++++----- lib/tdeck_ui/UI/LXMF/UIManager.h | 65 +- src/main.cpp | 4 +- .../native/fixtures/columba_releases_live.mu | 378 +++++++++++ tests/native/fixtures/nomadnet_readme.mu | 122 ++++ tests/native/test_app_launcher_nomadnet.cpp | 241 +++++++ tests/native/test_app_launcher_nomadnet.py | 77 +++ tests/native/test_radio_activity.py | 2 +- 26 files changed, 2464 insertions(+), 247 deletions(-) create mode 100644 lib/tdeck_ui/UI/LXMF/HomeScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/HomeScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/NavigationStack.h create mode 100644 lib/tdeck_ui/UI/LXMF/NetworkScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/NetworkScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetDocument.h create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetHistory.h create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetMemory.h create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetScreen.h create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp create mode 100644 lib/tdeck_ui/UI/LXMF/NomadNetUrl.h create mode 100644 tests/native/fixtures/columba_releases_live.mu create mode 100644 tests/native/fixtures/nomadnet_readme.mu create mode 100644 tests/native/test_app_launcher_nomadnet.cpp create mode 100644 tests/native/test_app_launcher_nomadnet.py diff --git a/lib/lv_conf.h b/lib/lv_conf.h index 0b7aee62..3b771a4a 100644 --- a/lib/lv_conf.h +++ b/lib/lv_conf.h @@ -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 diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp index 6fb7adf3..594f7006 100644 --- a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.cpp @@ -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; } diff --git a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h index d9b1b431..dcf573b9 100644 --- a/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h +++ b/lib/tdeck_ui/UI/LXMF/ConversationListScreen.h @@ -71,9 +71,7 @@ public: using ConversationSelectedCallback = std::function; using ComposeCallback = std::function; using SyncCallback = std::function; - using SettingsCallback = std::function; - using AnnouncesCallback = std::function; - using StatusCallback = std::function; + using HomeCallback = std::function; /** * 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); diff --git a/lib/tdeck_ui/UI/LXMF/HomeScreen.cpp b/lib/tdeck_ui/UI/LXMF/HomeScreen.cpp new file mode 100644 index 00000000..fb03192a --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/HomeScreen.cpp @@ -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(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 diff --git a/lib/tdeck_ui/UI/LXMF/HomeScreen.h b/lib/tdeck_ui/UI/LXMF/HomeScreen.h new file mode 100644 index 00000000..3224fec1 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/HomeScreen.h @@ -0,0 +1,25 @@ +#pragma once +#ifdef ARDUINO +#include +#include + +namespace UI::LXMF { +class HomeScreen { +public: + using Callback = std::function; + 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 diff --git a/lib/tdeck_ui/UI/LXMF/NavigationStack.h b/lib/tdeck_ui/UI/LXMF/NavigationStack.h new file mode 100644 index 00000000..f83378d3 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NavigationStack.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +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 _stack{}; + std::size_t _depth = 0; +}; + +} // namespace UI::LXMF diff --git a/lib/tdeck_ui/UI/LXMF/NetworkScreen.cpp b/lib/tdeck_ui/UI/LXMF/NetworkScreen.cpp new file mode 100644 index 00000000..c555cd3f --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NetworkScreen.cpp @@ -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(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 diff --git a/lib/tdeck_ui/UI/LXMF/NetworkScreen.h b/lib/tdeck_ui/UI/LXMF/NetworkScreen.h new file mode 100644 index 00000000..91762830 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NetworkScreen.h @@ -0,0 +1,23 @@ +#pragma once +#ifdef ARDUINO +#include +#include +namespace UI::LXMF { +class NetworkScreen { +public: + using Callback = std::function; + 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 diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp new file mode 100644 index 00000000..cde66d48 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.cpp @@ -0,0 +1,279 @@ +#include "NomadNetDocument.h" + +#include +#include +#include + +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(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(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(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(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(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(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( + std::min(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(std::min(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 diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h new file mode 100644 index 00000000..5080bc59 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetDocument.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include + +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 runs; +}; + +struct Link { + std::string label; + std::string target; + std::string fields; +}; + +struct Document { + std::vector blocks; + std::vector 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 diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h b/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h new file mode 100644 index 00000000..9f27bd3f --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetHistory.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +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 _entries{}; + std::string _current; + std::size_t _depth = 0; +}; + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h b/lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h new file mode 100644 index 00000000..56cdd93d --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetMailbox.h @@ -0,0 +1,137 @@ +#pragma once + +#include +#include +#include +#include +#include +#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 data; + std::size_t transfer_size = 0; + }; + + void begin(const std::vector& 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& 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& 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& 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& 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& 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 _link_token; + std::vector _request_token; + Event _event; +}; + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetMemory.h b/lib/tdeck_ui/UI/LXMF/NomadNetMemory.h new file mode 100644 index 00000000..57d43323 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetMemory.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +#ifdef ARDUINO +#include +#include +#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 +class ExternalAllocator { +public: + using value_type = T; + + ExternalAllocator() noexcept = default; + template + ExternalAllocator(const ExternalAllocator&) noexcept {} + template + struct rebind { using other = ExternalAllocator; }; + + T* allocate(std::size_t count) { + if (count == 0) return nullptr; + if (count > static_cast(-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(allocation); + } + + void deallocate(T* allocation, std::size_t) noexcept { +#ifdef ARDUINO + free(allocation); +#else + ::operator delete(allocation); +#endif + } + + template + bool operator==(const ExternalAllocator&) const noexcept { return true; } + template + bool operator!=(const ExternalAllocator&) const noexcept { return false; } +}; + +template +using ExternalVector = std::vector>; + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h b/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h new file mode 100644 index 00000000..2e9381e9 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetProtocol.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include "NomadNetMemory.h" + +namespace UI::LXMF::NomadNet { + +inline std::vector 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& bytes() const { return _bytes; } + std::size_t size() const { return _bytes.size(); } + bool truncated() const { return false; } + void clear() { _bytes.clear(); } + +private: + ExternalVector _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(data[1]) << 8) | data[2]; + } else if (data[0] == 0xc6) { + if (size < 5) return false; + offset = 5; + payload = (static_cast(data[1]) << 24) | + (static_cast(data[2]) << 16) | + (static_cast(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(size - offset)) return false; + return output.assign(data + offset, static_cast(payload)); +} + +} // namespace UI::LXMF::NomadNet diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp new file mode 100644 index 00000000..4e55ff09 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.cpp @@ -0,0 +1,140 @@ +#include "NomadNetScreen.h" +#ifdef ARDUINO +#include "Theme.h" +#include "../LVGL/LVGLInit.h" +#include "../TextAreaHelper.h" +#include + +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(std::min(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(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(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(_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(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(lv_obj_get_user_data(t));if(n>0&&n<=s->_link_targets.size()&&s->_link)s->_link(s->_link_targets[n-1]);}} +} +#endif diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h new file mode 100644 index 00000000..b4c2ee3c --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetScreen.h @@ -0,0 +1,39 @@ +#pragma once +#ifdef ARDUINO +#include +#include +#include +#include +#include "NomadNetDocument.h" + +namespace UI::LXMF { +class NomadNetScreen { +public: + using Callback = std::function; + using OpenCallback = std::function; + using LinkCallback = std::function; + 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 _focusables; + std::vector _link_targets; + bool _visible = false; + Callback _back,_home,_reload; OpenCallback _open; LinkCallback _link; + static void clicked(lv_event_t* event); +}; +} +#endif diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp b/lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp new file mode 100644 index 00000000..7a2b80a0 --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetUrl.cpp @@ -0,0 +1,50 @@ +#include "NomadNetUrl.h" + +#include +#include + +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(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 diff --git a/lib/tdeck_ui/UI/LXMF/NomadNetUrl.h b/lib/tdeck_ui/UI/LXMF/NomadNetUrl.h new file mode 100644 index 00000000..ecb4bd3a --- /dev/null +++ b/lib/tdeck_ui/UI/LXMF/NomadNetUrl.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +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 diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.cpp b/lib/tdeck_ui/UI/LXMF/UIManager.cpp index 1a506a11..c24f127d 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.cpp +++ b/lib/tdeck_ui/UI/LXMF/UIManager.cpp @@ -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 token(const RNS::Bytes& bytes) { + if (!bytes || bytes.size() == 0) return {}; + return std::vector(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(); @@ -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(_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(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(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(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) { diff --git a/lib/tdeck_ui/UI/LXMF/UIManager.h b/lib/tdeck_ui/UI/LXMF/UIManager.h index 787e9766..dccc1fda 100644 --- a/lib/tdeck_ui/UI/LXMF/UIManager.h +++ b/lib/tdeck_ui/UI/LXMF/UIManager.h @@ -9,6 +9,15 @@ #include #include #include +#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(); diff --git a/src/main.cpp b/src/main.cpp index dbc3e16c..97a0f5db 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2411,7 +2411,9 @@ static void handle_test_hook_command(const String& line) { // additional state (peer hash / identity / active call) and // are not exposed here. if (!ui_manager) { Serial.println("T:ERR no ui_manager"); return; } - if (args == "conversation_list" || args == "home") { + if (args == "home") { + ui_manager->show_home(); + } else if (args == "conversation_list") { ui_manager->show_conversation_list(); } else if (args == "compose") { ui_manager->show_compose(); diff --git a/tests/native/fixtures/columba_releases_live.mu b/tests/native/fixtures/columba_releases_live.mu new file mode 100644 index 00000000..3a22e3f2 --- /dev/null +++ b/tests/native/fixtures/columba_releases_live.mu @@ -0,0 +1,378 @@ +#!c=0 +>Columba Downloads + +`Bb6d*----- `b +`Bb6d ----- ---*-- `b +`Bb6d ----- --- --- `b +`Bb6d -*--- --- *`b +`Bb6d * `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d / `b +`Bb6d * `b + +`!Columba`! + +A native android messaging application for the Reticulum Network Stack. Features a BLE interface + +- + +>>Latest Stable Release + +`!v2.0.9`! +`F888Released: 2026-07-20`f + +`F0af`[Download (27.1 MB)`:/page/download.mu`file=columba-2.0.9-EXPERIMENTAL-reticulum-kt-arm64-v8a-no-sentry.apk]`f `F888(19 views)`f + +`F666SHA256: d80e60b4954eadab9f79698bb8db53fe14e64ad94d390d5c98c678cfda8f2042`f + +`!Release Notes:`! + +## Columba 2.0.9 + +### Installation +Pick a **backend**, then download the APK matching your device's CPU architecture. + +- **Python backend, RECOMMENDED** — ships Mark Qvist's official python reference implementations of Reticulum and LXMF via Chaquopy. +- **Kotlin backend** — ships Torlando's EXPERIMENTAL, AI-GENERATED native reticulum-kt / lxmf-kt / lxst-kt stack. + +The python version is highly recommended. The kotlin version is a work in progress, and is not yet verified to be completely safe. +The kotlin version may yield better battery life depending on your device, and comes in a smaller apk due to not requiring a python runtime. + +#### Python backend (`official-rns-py`) +| APK | Architecture | Devices | +|-----|-------------|---------| +| [`columba-2.0.9-official-rns-py-armeabi-v7a.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-official-rns-py-armeabi-v7a.apk) | armeabi-v7a | Older 32-bit ARM phones & tablets | +| [`columba-2.0.9-official-rns-py-arm64-v8a.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-official-rns-py-arm64-v8a.apk) | arm64-v8a | Most modern Android phones & tablets | +| [`columba-2.0.9-official-rns-py-x86_64.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-official-rns-py-x86_64.apk) | x86_64 | Chromebooks, emulators, some tablets | +| [`columba-2.0.9-official-rns-py-universal.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-official-rns-py-universal.apk) | All | Universal fallback (larger download) | + +#### Kotlin backend (`EXPERIMENTAL-reticulum-kt`) +| APK | Architecture | Devices | +|-----|-------------|---------| +| [`columba-2.0.9-EXPERIMENTAL-reticulum-kt-armeabi-v7a.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-EXPERIMENTAL-reticulum-kt-armeabi-v7a.apk) | armeabi-v7a | Older 32-bit ARM phones & tablets | +| [`columba-2.0.9-EXPERIMENTAL-reticulum-kt-arm64-v8a.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-EXPERIMENTAL-reticulum-kt-arm64-v8a.apk) | arm64-v8a | Most modern Android phones & tablets | +| [`columba-2.0.9-EXPERIMENTAL-reticulum-kt-x86_64.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-EXPERIMENTAL-reticulum-kt-x86_64.apk) | x86_64 | Chromebooks, emulators, some tablets | +| [`columba-2.0.9-EXPERIMENTAL-reticulum-kt-universal.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-EXPERIMENTAL-reticulum-kt-universal.apk) | All | Universal fallback (larger download) | + +**Telemetry variants:** +Each APK above also has a `-no-sentry` variant without crash reporting for maximum privacy — append `-no-sentry` to any download link above (e.g. the most common builds: [Python arm64-v8a no-sentry](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-official-rns-py-arm64-v8a-no-sentry.apk) · [Kotlin arm64-v8a no-sentry](https://github.com/torlando-tech/columba/releases/download/v2.0.9/columba-2.0.9-EXPERIMENTAL-reticulum-kt-arm64-v8a-no-sentry.apk)). Sentry collects stack traces when Columba encounters errors +and provides helpful information to the developer to improve Columba, but is hosted at sentry.io, and is thus not the maximum privacy option. + +> **Not sure which to pick?** Most Android phones use **arm64-v8a**. If unsure, use the **universal** APK. + +### Verification +See [SECURITY.md](https://github.com/torlando-tech/columba/blob/main/SECURITY.md) for verification instructions. + +**Signing Certificate Fingerprints:** +``` +SHA-256: 02:2B:12:20:48:63:A3:1F:BF:07:5B:C9:F9:34:1E:33:52:78:80:2E:80:C9:27:A4:75:46:E4:7E:2F:4A:0C:5F +SHA-1: 0A:6B:AE:58:4E:D7:B5:D0:35:8B:3C:7B:65:11:D6:3A:81:21:0D:CE +``` + +**SHA256 Checksums:** +``` +d80e60b4954eadab9f79698bb8db53fe14e64ad94d390d5c98c678cfda8f2042 columba-2.0.9-EXPERIMENTAL-reticulum-kt-arm64-v8a-no-sentry.apk +10dff623760088c00fea95bbdcd32aa92d96cb6f2e6d08e9e148d51db717c859 columba-2.0.9-EXPERIMENTAL-reticulum-kt-arm64-v8a.apk +b8d5b94c5e5a4f535bffad9c3d92ade83caf9de7392543781c1f2a8de2ccd1d5 columba-2.0.9-EXPERIMENTAL-reticulum-kt-armeabi-v7a-no-sentry.apk +cba52a852412592e4fa69fd7942f1def6b4377332addc46d3a8158f522b7b8d4 columba-2.0.9-EXPERIMENTAL-reticulum-kt-armeabi-v7a.apk +77c5df7c2521436ac3dddda43341d8ab537da9a9edd495174ce976e8d3df8e07 columba-2.0.9-EXPERIMENTAL-reticulum-kt-universal-no-sentry.apk +bafb1cd3c9693c94c3fb672a7536e126457450b0ab9225d853d2cbcf9591709e columba-2.0.9-EXPERIMENTAL-reticulum-kt-universal.apk +f2231ababe6fe2a6e59da846546c858804165465f52a572f00551d7e562a3ccf columba-2.0.9-EXPERIMENTAL-reticulum-kt-x86_64-no-sentry.apk +5a39564a4cc592d60961ae0fb7329f2754e426b91c3e858ee5439f0e87a059e5 columba-2.0.9-EXPERIMENTAL-reticulum-kt-x86_64.apk +4db07f32d3f4f042b0ff0510c39350e032615fab6c8cd7114cb1ebedb6f5e143 columba-2.0.9-official-rns-py-arm64-v8a-no-sentry.apk +c29392e074d0aa9e96c902c24a3e525d83abff04e1ec21cbb8055fa7aa7bad4a columba-2.0.9-official-rns-py-arm64-v8a.apk +1f2cfede410e82873351f4f66046b3a34e2a26a7ad9cb26ad74b5c0f43fa7331 columba-2.0.9-official-rns-py-armeabi-v7a-no-sentry.apk +9d1c73153f5d80655eb99f418889337942536d3e76914c0f204b8494835f482d columba-2.0.9-official-rns-py-armeabi-v7a.apk +a58ef772dc1fcc0a40a78b30515640c30b229f89c845170ee30589dea8b8b876 columba-2.0.9-official-rns-py-universal-no-sentry.apk +e4ec9a9342d4fb6f730db06c81b99229c63bd6f4d9da93ccca73e3419a564546 columba-2.0.9-official-rns-py-universal.apk +c037a4b312483f0f4deedee4481c8ffa38aae00a0c50bfe992b8b05ddf46d093 columba-2.0.9-official-rns-py-x86_64-no-sentry.apk +922bb1d06057ba51fc0af665422ad5302f42297d682d62aece30f5308b8e24de columba-2.0.9-official-rns-py-x86_64.apk + +``` + + + +## What's Changed +* deps: bump Chaquopy 16.1.0 → 17.0.0 for Gradle 9 compat by @torlando-tech in https://github.com/torlando-tech/columba/pull/643 +* feat(map): declutter overlapping contact markers by @MatthieuTexier in https://github.com/torlando-tech/columba/pull/572 +* feat: add background location permission flow in Location Sharing settings by @MatthieuTexier in https://github.com/torlando-tech/columba/pull/600 +* feat: add 'Locate on Map' to Chats, Messaging, and Contacts screens by @MatthieuTexier in https://github.com/torlando-tech/columba/pull/597 +* fix: prevent binder buffer exhaustion causing crash loop by @torlando-tech in https://github.com/torlando-tech/columba/pull/652 +* feat: add offline mode banner (#623) by @torlando-tech in https://github.com/torlando-tech/columba/pull/626 +* fix: show notifications when app backgrounded on active conversation by @torlando-tech in https://github.com/torlando-tech/columba/pull/649 +* fix: sort messages by local receive time to prevent clock-drift reordering by @torlando-tech in https://github.com/torlando-tech/columba/pull/645 +* ci: split module tests into separate job to prevent shard 0 timeouts by @torlando-tech in https://github.com/torlando-tech/columba/pull/655 +* Prevent duplicate message notifications on service restart by @torlando-tech in https://github.com/torlando-tech/columba/pull/650 +* feat: add block & blackhole peer protection by @torlando-tech in https://github.com/torlando-tech/columba/pull/601 +* feat: clean up stale announces, add PHONE NodeType and cross-link buttons by @torlando-tech in https://github.com/torlando-tech/columba/pull/581 +* feat: add WiFi hotspot sharing for APK distribution without existing network by @torlando-tech in https://github.com/torlando-tech/columba/pull/632 +* ci: trigger CI on release/* branches by @torlando-tech in https://github.com/torlando-tech/columba/pull/660 +* fix: catch SecurityException when location permission revoked while backgrounded by @torlando-tech in https://github.com/torlando-tech/columba/pull/662 +* feat: multiple firmware sources + custom firmware in RNode flasher (#485) by @torlando-tech in https://github.com/torlando-tech/columba/pull/582 +* feat: RNode disconnect notification by @torlando-tech in https://github.com/torlando-tech/columba/pull/628 +* fix: path persistence + proactive path resolution by @torlando-tech in https://github.com/torlando-tech/columba/pull/665 +* chore: bump GitHub Actions dependencies to latest versions by @torlando-tech in https://github.com/torlando-tech/columba/pull/667 +* feat: show outbound interface in message details (#646) by @torlando-tech in https://github.com/torlando-tech/columba/pull/666 +* feat: NomadNet browser with Micron rendering, URL bar, and URI handler by @torlando-tech in https://github.com/torlando-tech/columba/pull/671 +* ci: Bump reactivecircus/android-emulator-runner from 2.36.0 to 2.37.0 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/682 +* fix: prevent crash on corrupted interface in edit dialog by @torlando-tech in https://github.com/torlando-tech/columba/pull/710 +* fix: replace modal restart dialog with inline banner by @torlando-tech in https://github.com/torlando-tech/columba/pull/711 +* fix: preserve settings state when combine flow re-fires (#688) by @torlando-tech in https://github.com/torlando-tech/columba/pull/712 +* feat: add IFAC network_name and passphrase support for RNode interfaces by @torlando-tech in https://github.com/torlando-tech/columba/pull/718 +* fix: prevent identity file loss and add opportunistic key backup by @MatthieuTexier in https://github.com/torlando-tech/columba/pull/717 +* test: add regression tests for identity recovery edge cases by @torlando-tech in https://github.com/torlando-tech/columba/pull/720 +* fix: prevent initial camera from overriding Locate on Map by @MatthieuTexier in https://github.com/torlando-tech/columba/pull/715 +* fix: show address for auto-discovered backbone interfaces by @torlando-tech in https://github.com/torlando-tech/columba/pull/722 +* fix: handle inline field=value variables in Micron links by @torlando-tech in https://github.com/torlando-tech/columba/pull/723 +* feat: improve NomadNet page request reliability and status reporting by @torlando-tech in https://github.com/torlando-tech/columba/pull/724 +* feat: add NomadNet file download support by @torlando-tech in https://github.com/torlando-tech/columba/pull/725 +* Add micron parser audit comparing Columba against NomadNet and micron-parser-js by @torlando-tech in https://github.com/torlando-tech/columba/pull/678 +* fix: prevent stale page request from overwriting current page by @torlando-tech in https://github.com/torlando-tech/columba/pull/726 +* feat: render discovered interfaces as map pins with type filtering by @torlando-tech in https://github.com/torlando-tech/columba/pull/729 +* fix: prevent announce list scroll jumps when new announces arrive by @torlando-tech in https://github.com/torlando-tech/columba/pull/727 +* chore: upgrade to Gradle 9.4.1 and AGP 9.1.0 by @torlando-tech in https://github.com/torlando-tech/columba/pull/730 +* fix: deduplicate path requests with hasPath guard by @torlando-tech in https://github.com/torlando-tech/columba/pull/735 +* ci: bump gradle/actions from 5 to 6 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/731 +* deps(deps): Bump the compose group with 2 updates by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/684 +* deps(deps): Bump cameraX from 1.5.2 to 1.5.3 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/685 +* ci: bump codecov/codecov-action from 5 to 6 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/732 +* fix: scope MapViewModel announce query to location senders by @torlando-tech in https://github.com/torlando-tech/columba/pull/737 +* Interface discovery: search + filter + IFAC handling by @torlando-tech in https://github.com/torlando-tech/columba/pull/772 +* Fix/sentry gradle hang by @torlando-tech in https://github.com/torlando-tech/columba/pull/776 +* Complete native Reticulum migration and remove Python runtime by @torlando-tech in https://github.com/torlando-tech/columba/pull/762 +* ci: bump softprops/action-gh-release from 2 to 3 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/768 +* ci: bump actions/github-script from 8 to 9 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/769 +* deps(deps): bump com.squareup.leakcanary:leakcanary-android from 2.13 to 2.14 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/756 +* Default transport node to disabled for new users by @torlando-tech in https://github.com/torlando-tech/columba/pull/777 +* Keep foreground notification in sync after :reticulum process restart by @torlando-tech in https://github.com/torlando-tech/columba/pull/775 +* deps(deps): bump org.robolectric:robolectric from 4.16 to 4.16.1 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/755 +* Rename com.lxmf.messenger -> network.columba.app by @torlando-tech in https://github.com/torlando-tech/columba/pull/779 +* Remove AIDL surface from ReticulumService by @torlando-tech in https://github.com/torlando-tech/columba/pull/780 +* Remove Python-era dead code from ReticulumService by @torlando-tech in https://github.com/torlando-tech/columba/pull/781 +* Collapse Room migrations — ColumbaDatabase v1 + InterfaceDatabase v1 by @torlando-tech in https://github.com/torlando-tech/columba/pull/782 +* Make builds reproducible by @torlando-tech in https://github.com/torlando-tech/columba/pull/783 +* Consume Reticulum/LXMF/LXST stack from JitPack (drop submodules) by @torlando-tech in https://github.com/torlando-tech/columba/pull/786 +* Wire delivery + transport identities through memory, scrub on-disk copies by @torlando-tech in https://github.com/torlando-tech/columba/pull/785 +* Bump LXST-kt to v0.0.3 (JVM 21 target) by @torlando-tech in https://github.com/torlando-tech/columba/pull/787 +* Drop files/reticulum/ backup exclusion (take 2, with LXMF ratchets) by @torlando-tech in https://github.com/torlando-tech/columba/pull/790 +* About card: AGPLv3 label, catalog-driven protocol versions, settings-flow state preservation by @torlando-tech in https://github.com/torlando-tech/columba/pull/789 +* Eliminate plaintext identity-file window in createIdentity/importIdentity paths by @torlando-tech in https://github.com/torlando-tech/columba/pull/791 +* Bump LXMF-kt to v0.0.4 by @torlando-tech in https://github.com/torlando-tech/columba/pull/793 +* Register MIGRATION_2_3 + destinationRatchetStore in NativeReticulumProtocol by @torlando-tech in https://github.com/torlando-tech/columba/pull/794 +* Update and rename LICENSE to LICENSE.md by @serialrf433 in https://github.com/torlando-tech/columba/pull/797 +* Route to IdentityUnlockScreen when Keystore-wrapped identity can't be decrypted after restore by @torlando-tech in https://github.com/torlando-tech/columba/pull/796 +* fix: harden Room DB against process-kill corruption (COLUMBA-8C) by @torlando-tech in https://github.com/torlando-tech/columba/pull/799 +* fix: ignore CancellationException in rapid interface toggles by @torlando-tech in https://github.com/torlando-tech/columba/pull/801 +* fix: remove TestHostActivity launcher entry and LeakCanary icon by @torlando-tech in https://github.com/torlando-tech/columba/pull/743 +* fix: route all PRAGMAs through db.query() so Android accepts them by @torlando-tech in https://github.com/torlando-tech/columba/pull/803 +* fix: pass IFAC passphrase through to TCPClientInterface factory by @torlando-tech in https://github.com/torlando-tech/columba/pull/804 +* fix: render image/file/audio-only messages instead of skipping as telemetry by @torlando-tech in https://github.com/torlando-tech/columba/pull/805 +* fix: render file attachments sent in LXMF positional wire format by @torlando-tech in https://github.com/torlando-tech/columba/pull/806 +* Bump reticulum-kt to v0.0.8 by @torlando-tech in https://github.com/torlando-tech/columba/pull/808 +* Surface announce filters as sticky chip rows; rebrand Nodes → Sites by @torlando-tech in https://github.com/torlando-tech/columba/pull/807 +* fix: guard PRAGMA journal_mode and synchronous against Room transactions by @sentry[bot] in https://github.com/torlando-tech/columba/pull/809 +* Fix ANR when answering LXST call on slow transports by @torlando-tech in https://github.com/torlando-tech/columba/pull/811 +* Restore long-press → delete on interface cards by @torlando-tech in https://github.com/torlando-tech/columba/pull/812 +* Refresh interface online status when library state changes by @torlando-tech in https://github.com/torlando-tech/columba/pull/813 +* Stop interface-card subtitle from repeating the type label by @torlando-tech in https://github.com/torlando-tech/columba/pull/814 +* Bump reticulum-kt to v0.0.10 by @torlando-tech in https://github.com/torlando-tech/columba/pull/815 +* NativeInterfaceFactory owns its own coroutine scope by @torlando-tech in https://github.com/torlando-tech/columba/pull/818 +* Plumb received-side RSSI/SNR/interface/hop fields to MessageEntity by @torlando-tech in https://github.com/torlando-tech/columba/pull/819 +* Propagate LXMessage packet metadata through the receive pipeline by @torlando-tech in https://github.com/torlando-tech/columba/pull/820 +* Bump reticulum-kt to v0.0.11 (BLE per-packet RSSI) + local composite-build dev loop by @torlando-tech in https://github.com/torlando-tech/columba/pull/826 +* Remove .drop(1) from interface online observer to close fast-transition race by @torlando-tech in https://github.com/torlando-tech/columba/pull/827 +* Bump reticulum-kt to v0.0.12 (expire path on link establishment failure) by @torlando-tech in https://github.com/torlando-tech/columba/pull/828 +* Dispatch MapViewModel.loadInterfaceMarkers to IO to avoid main-thread Room read by @torlando-tech in https://github.com/torlando-tech/columba/pull/829 +* Bump LXMF-kt to v0.0.6 (send-hang fix) + dependencySubstitution for JitPack-collapsed libs by @torlando-tech in https://github.com/torlando-tech/columba/pull/830 +* Add Edit action to interface-card long-press menu by @torlando-tech in https://github.com/torlando-tech/columba/pull/831 +* Give interface cards the same semantic icon mapping as announce cards by @torlando-tech in https://github.com/torlando-tech/columba/pull/832 +* Unify IFAC network-access UI across all interface wizards by @torlando-tech in https://github.com/torlando-tech/columba/pull/833 +* Implement TCPServer branch in NativeInterfaceFactory by @torlando-tech in https://github.com/torlando-tech/columba/pull/834 +* Bump reticulum-kt to v0.0.13 (BLE handshake concurrency fix) by @torlando-tech in https://github.com/torlando-tech/columba/pull/835 +* Move map interface filters behind a layers icon by @torlando-tech in https://github.com/torlando-tech/columba/pull/838 +* Add map style picker (Auto / Light / Dark) to the layers sheet by @torlando-tech in https://github.com/torlando-tech/columba/pull/839 +* Bump LXMF-kt to v0.0.7 (processingScope silent-drop fix) by @torlando-tech in https://github.com/torlando-tech/columba/pull/840 +* Render Micron links on form lines in the NomadNet browser by @torlando-tech in https://github.com/torlando-tech/columba/pull/841 +* Auto-linkify bare http/https URLs in the NomadNet browser by @torlando-tech in https://github.com/torlando-tech/columba/pull/842 +* fix: prevent spontaneous scroll jumps when reading old messages by @torlando-tech in https://github.com/torlando-tech/columba/pull/668 +* fix: replace continuous GPS with on-demand one-shot in Group Tracker by @MatthieuTexier in https://github.com/torlando-tech/columba/pull/746 +* Move Transport Node toggle to a new Advanced settings card by @torlando-tech in https://github.com/torlando-tech/columba/pull/846 +* Bump LXMF-kt to v0.0.8 — DIRECT double-delivery + dedup keys fixes by @torlando-tech in https://github.com/torlando-tech/columba/pull/848 +* Bump reticulum-kt v0.0.14 + LXMF-kt v0.0.9 — multi-hop link race fixes + version alignment by @torlando-tech in https://github.com/torlando-tech/columba/pull/849 +* detekt: add DiscardedConcurrencyReturn custom rule by @torlando-tech in https://github.com/torlando-tech/columba/pull/858 +* feat(NomadNetBrowserScreen): replace duplicate Identify dropdown item with Close site by @torlando-tech in https://github.com/torlando-tech/columba/pull/882 +* chore: bump reticulum-kt v0.0.14 → v0.0.16, LXMF-kt v0.0.9 → v0.0.11 by @torlando-tech in https://github.com/torlando-tech/columba/pull/870 +* chore(build): isolate debug builds with .debug applicationId + columbatest label by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/885 +* fix(SmokeTest): assert context.packageName == BuildConfig.APPLICATION_ID by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/895 +* fix(OnboardingPagerScreen): refresh permission cards on resume by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/890 +* feat(InterfaceConfig): per-interface Wi-Fi/cellular network restriction by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/896 +* fix(viewmodel): deflake DebugViewModelEventDrivenTest + ApkSharingViewModelTest by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/891 +* feat(MicronPageContent): allow text selection on rendered NomadNet pages by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/884 +* Update ci.yml - update JDK to 25 and setup-java to `@5` by @serialrf433 in https://github.com/torlando-tech/columba/pull/778 +* ci(release): include reticulum-kt / LXMF-kt / LXST-kt changelogs in release notes by @torlando-tech in https://github.com/torlando-tech/columba/pull/908 +* test(debug): TestReceiver/TestController for phone harness Stage 1 by @torlando-tech in https://github.com/torlando-tech/columba/pull/909 +* deps(reticulum-kt, LXMF-kt): bump v0.0.16 → v0.0.17, v0.0.11 → v0.0.12 by @torlando-tech in https://github.com/torlando-tech/columba/pull/910 +* Fix shutdown race that could close DB while Room writes are mid-transaction (Sentry COLUMBA-8R) by @torlando-tech in https://github.com/torlando-tech/columba/pull/857 +* deps(LXMF-kt): bump v0.0.12 → v0.0.13 (Sideband stamp-drop fix) by @torlando-tech in https://github.com/torlando-tech/columba/pull/914 +* deps(reticulum-kt): bump v0.0.17 → v0.0.20 by @torlando-tech in https://github.com/torlando-tech/columba/pull/915 +* fix(nomadnet): submit field defaults and pad form for IME (#917) by @torlando-tech in https://github.com/torlando-tech/columba/pull/918 +* feat(rns): dual-backend architecture (kotlin + python flavors) by @torlando-tech in https://github.com/torlando-tech/columba/pull/932 +* feat: LXST call privacy gates + telemetry responder (dual-build) by @torlando-tech in https://github.com/torlando-tech/columba/pull/933 +* feat: punch-list items 3-7 + 10 + identity flow + RNode (dual-build) by @torlando-tech in https://github.com/torlando-tech/columba/pull/934 +* fix(python-backend): map shows no discovered-interface markers by @torlando-tech in https://github.com/torlando-tech/columba/pull/935 +* chore(license): AGPLv3 -> MPL 2.0 for Reticulum compatibility by @torlando-tech in https://github.com/torlando-tech/columba/pull/936 +* Dual-backend release: Python-first APKs, co-installable Kotlin flavor, R8 bridge gate by @torlando-tech in https://github.com/torlando-tech/columba/pull/938 +* feat(rns,ui): user-toggleable Share Instance hosting (Python backend) by @torlando-tech in https://github.com/torlando-tech/columba/pull/939 +* fix(release): keep minified pythonBackend Python startup working (duplicate org.json) by @torlando-tech in https://github.com/torlando-tech/columba/pull/940 +* feat(ble): surface live BLE peer connections to the Network Status card by @torlando-tech in https://github.com/torlando-tech/columba/pull/941 +* feat(settings): add BLE Connections button to the Network card by @torlando-tech in https://github.com/torlando-tech/columba/pull/942 +* ci(release): bump release build timeout 20m → 40m by @torlando-tech in https://github.com/torlando-tech/columba/pull/943 +* fix(rns): TCP/interface type shows as obfuscated "wy2" in release builds by @torlando-tech in https://github.com/torlando-tech/columba/pull/946 +* feat(telemetry): consent-based crash reporting + build-time Sentry removal by @torlando-tech in https://github.com/torlando-tech/columba/pull/947 +* ci(release): link APKs directly from the release body tables by @torlando-tech in https://github.com/torlando-tech/columba/pull/948 +* fix(rns): inbound LXST voice R8 regression + enforce @ReflectivelyKept keep contract by @torlando-tech in https://github.com/torlando-tech/columba/pull/949 +* fix(ci): raise Gradle heap to 6GB to stop release APK packaging OOM by @torlando-tech in https://github.com/torlando-tech/columba/pull/950 +* fix(rns-host): 1-arg readParcelable in snapshot reader (API 33 NoSuchMethodError on minSdk 24) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/953 +* chore(lint): enable Android Lint (NewApi, no baseline) + fix 6 NewApi violations by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/954 +* feat(reactions): migrate reaction wire format to canonical LXMF FIELD_REACTION (0x40) by @torlando-tech in https://github.com/torlando-tech/columba/pull/956 +* fix(settings): don't crash on RnsException(BackendNotReady) during availability polling by @torlando-tech in https://github.com/torlando-tech/columba/pull/960 +* ci: Bump actions/setup-python from 5 to 6 by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/959 +* fix(reactions): reject blank/absent emoji on the legacy 0x10 parse path by @torlando-tech in https://github.com/torlando-tech/columba/pull/957 +* fix(rns-ipc): guard network-status observer registration against dead backend by @torlando-tech in https://github.com/torlando-tech/columba/pull/962 +* fix(rns-ipc): guard all observer registrations against dead backend (COLUMBA-B0) by @torlando-tech in https://github.com/torlando-tech/columba/pull/963 +* fix(proguard): keep LXST NativeCodec2/NativeOpus for JNI RegisterNatives (COLUMBA-B1/B2) by @torlando-tech in https://github.com/torlando-tech/columba/pull/964 +* chore(deps): bump LXST-kt to v0.0.4 (JNI keep for NativeCodec2/NativeOpus) by @torlando-tech in https://github.com/torlando-tech/columba/pull/966 +* fix(rns-ipc): transfer LXMF attachments out-of-band to fix TransactionTooLargeException by @torlando-tech in https://github.com/torlando-tech/columba/pull/967 +* fix(performance): batch interface_first_seen inserts in MapViewModel by @sentry[bot] in https://github.com/torlando-tech/columba/pull/951 +* fix(build): enable java.time desugaring for minSdk 24 by @sentry[bot] in https://github.com/torlando-tech/columba/pull/944 +* Match Sideband's LXMF telemetry-request command: epoch-seconds timebase + collector flag (#927) by @torlando-tech in https://github.com/torlando-tech/columba/pull/968 +* fix(settings): handle ActivityNotFoundException when opening URLs by @sentry[bot] in https://github.com/torlando-tech/columba/pull/958 +* fix(ui): treat NomadNet page addresses in messages as in-app links by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/971 +* fix(ui): make announce-stream filter chips exclusive (closes #862) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/975 +* feat(ui): collapsible announce-stream filter chips + WindowSizeClass foundation (closes #922) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/976 +* fix(announce,ui): make announce-card interface icon resilient to classifier-cache drift by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/978 +* fix(rns-ipc): inbound attachments out-of-band + stop observer-detach cascade (TransactionTooLargeException) by @torlando-tech in https://github.com/torlando-tech/columba/pull/979 +* feat(share-instance): surface host row + Hub icon, fix Change-pending hint by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/974 +* fix(share-instance): persist hosting toggle + close 37428 listener so hosting survives :reticulum restart by @torlando-tech in https://github.com/torlando-tech/columba/pull/980 +* build: bump ktlint to 1.5.0 to relax discouraged-comment-location (#923) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/984 +* Fix: pan short NomadNet pages from anywhere in the viewport (#681) by @torlando-tech in https://github.com/torlando-tech/columba/pull/982 +* fix(location): restore precise location for sharing — drop merged FINE maxSdkVersion cap (#855) by @torlando-tech in https://github.com/torlando-tech/columba/pull/986 +* feat(messaging): add "Select text" action to copy message substrings (#920) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/985 +* feat(nomadnet): remember selected page rendering mode across sessions by @torlando-tech in https://github.com/torlando-tech/columba/pull/983 +* fix(location): gate precise-permission prompt on location sharing enabled (#991) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/992 +* fix(announce): route test announce through proper LXMF self-announce (#988) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/989 +* deps(deps): Bump the kotlin group across 1 directory with 6 updates by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/913 +* fix(identity): reject non-64-byte keys before RNS reconstruction (COLUMBA-B8) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/993 +* chore(deps): bump reticulum-kt v0.0.20 → v0.0.21 (COLUMBA-B7) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/994 +* fix(nomadnet): handle link field/query params on deep-link & URL-bar page loads by @torlando-tech in https://github.com/torlando-tech/columba/pull/998 +* fix(qr): don't crash when CameraX init fails on virtual cameras (COLUMBA-BA) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/1000 +* feat(messaging): send on Enter, Shift+Enter for newline by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/999 +* deps(deps): bump the compose group with 2 updates by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/995 +* feat(InterfaceManagementScreen): network-restriction chip on interface cards by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/906 + +## New Contributors +* @torlando-agent[bot] made their first contribution in https://github.com/torlando-tech/columba/pull/885 + +**Full Changelog**: https://github.com/torlando-tech/columba/compare/v0.9.18...v2.0.9 + +- + +>>Latest Pre-release + +`*v2.0.9-beta`* +`F888Released: 2026-07-15`f + +`Ff80`[Download Beta (27.1 MB)`:/page/download.mu`file=columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-arm64-v8a-no-sentry.apk]`f `F888(2 views)`f + +`!Release Notes:`! + +## Columba 2.0.9-beta + +### Installation +Pick a **backend**, then download the APK matching your device's CPU architecture. + +- **Python backend, RECOMMENDED** — ships Mark Qvist's official python reference implementations of Reticulum and LXMF via Chaquopy. +- **Kotlin backend** — ships Torlando's EXPERIMENTAL, AI-GENERATED native reticulum-kt / lxmf-kt / lxst-kt stack. + +The python version is highly recommended. The kotlin version is a work in progress, and is not yet verified to be completely safe. +The kotlin version may yield better battery life depending on your device, and comes in a smaller apk due to not requiring a python runtime. + +#### Python backend (`official-rns-py`) +| APK | Architecture | Devices | +|-----|-------------|---------| +| [`columba-2.0.9-beta-official-rns-py-armeabi-v7a.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-official-rns-py-armeabi-v7a.apk) | armeabi-v7a | Older 32-bit ARM phones & tablets | +| [`columba-2.0.9-beta-official-rns-py-arm64-v8a.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-official-rns-py-arm64-v8a.apk) | arm64-v8a | Most modern Android phones & tablets | +| [`columba-2.0.9-beta-official-rns-py-x86_64.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-official-rns-py-x86_64.apk) | x86_64 | Chromebooks, emulators, some tablets | +| [`columba-2.0.9-beta-official-rns-py-universal.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-official-rns-py-universal.apk) | All | Universal fallback (larger download) | + +#### Kotlin backend (`EXPERIMENTAL-reticulum-kt`) +| APK | Architecture | Devices | +|-----|-------------|---------| +| [`columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-armeabi-v7a.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-armeabi-v7a.apk) | armeabi-v7a | Older 32-bit ARM phones & tablets | +| [`columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-arm64-v8a.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-arm64-v8a.apk) | arm64-v8a | Most modern Android phones & tablets | +| [`columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-x86_64.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-x86_64.apk) | x86_64 | Chromebooks, emulators, some tablets | +| [`columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-universal.apk`](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-universal.apk) | All | Universal fallback (larger download) | + +**Telemetry variants:** +Each APK above also has a `-no-sentry` variant without crash reporting for maximum privacy — append `-no-sentry` to any download link above (e.g. the most common builds: [Python arm64-v8a no-sentry](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-official-rns-py-arm64-v8a-no-sentry.apk) · [Kotlin arm64-v8a no-sentry](https://github.com/torlando-tech/columba/releases/download/v2.0.9-beta/columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-arm64-v8a-no-sentry.apk)). Sentry collects stack traces when Columba encounters errors +and provides helpful information to the developer to improve Columba, but is hosted at sentry.io, and is thus not the maximum privacy option. + +> **Not sure which to pick?** Most Android phones use **arm64-v8a**. If unsure, use the **universal** APK. + +### Verification +See [SECURITY.md](https://github.com/torlando-tech/columba/blob/main/SECURITY.md) for verification instructions. + +**Signing Certificate Fingerprints:** +``` +SHA-256: 02:2B:12:20:48:63:A3:1F:BF:07:5B:C9:F9:34:1E:33:52:78:80:2E:80:C9:27:A4:75:46:E4:7E:2F:4A:0C:5F +SHA-1: 0A:6B:AE:58:4E:D7:B5:D0:35:8B:3C:7B:65:11:D6:3A:81:21:0D:CE +``` + +**SHA256 Checksums:** +``` +1ce3310b1bd860d2c99d21e94654be99da2c56d06227be3a80acf42764067cfa columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-arm64-v8a-no-sentry.apk +54c07d095504887ce1820cdc139080a71aac021c0319ccedf9b2a425243a68b1 columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-arm64-v8a.apk +b68c54c8c955e4b259c683293ecaaf8e895927f396ff662d7cbaa9739628df60 columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-armeabi-v7a-no-sentry.apk +b2f0dd58d4972f11009aa371e7e3e33b9116c4757f5014f63844c0c31e675a1d columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-armeabi-v7a.apk +61ad470036ac73fba64f508e3bf56e864f6a20cb94e31bdfe224c0c2695187c4 columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-universal-no-sentry.apk +e72041565fbba312b87a8cc77c0073def6d84a0d0492a2442b414f2c44f8da74 columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-universal.apk +60f5d2e13bf1a531678ab5c26e5c8e1c2a3f8e94cdafda0d6a91c4284ae08535 columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-x86_64-no-sentry.apk +30d7eb35ccf239c7c76b00c29407bf552c553e79951b5ebe007518d2d69230ac columba-2.0.9-beta-EXPERIMENTAL-reticulum-kt-x86_64.apk +0d263e1b28c54dfd57ca107fd293d4d660b047c567ee9bf067aab55eb00b47eb columba-2.0.9-beta-official-rns-py-arm64-v8a-no-sentry.apk +beb2bb78e18424097aa5efa8f72971abd183622528b1db6249987eb6d4f22e10 columba-2.0.9-beta-official-rns-py-arm64-v8a.apk +9e95d43065a2a7159d5181a8e0a69a3c303d7d35395b82932a46dccbb93c991c columba-2.0.9-beta-official-rns-py-armeabi-v7a-no-sentry.apk +85572f1108332c5691f64331741b8b19bc94c3d366b181aabf23c51c68a6a8c4 columba-2.0.9-beta-official-rns-py-armeabi-v7a.apk +9cf43b6e9178f049822f9b92bda5530714a793252ab856bd26a2d68e400f795b columba-2.0.9-beta-official-rns-py-universal-no-sentry.apk +dace04b6ab19a016f2cf9a88cf45fdba3b8153bf233ebfcc62f8a3dc8b53b259 columba-2.0.9-beta-official-rns-py-universal.apk +cba20c6d247ed25851f4017d5fe66d008dc8dc21f55ec59247511884765e9f7b columba-2.0.9-beta-official-rns-py-x86_64-no-sentry.apk +a2f61cb699424978abbe0d33d615f61e143b7943903855e94d109e4e0f7e14e1 columba-2.0.9-beta-official-rns-py-x86_64.apk + +``` + + + +## What's Changed +* fix(qr): don't crash when CameraX init fails on virtual cameras (COLUMBA-BA) by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/1000 +* feat(messaging): send on Enter, Shift+Enter for newline by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/999 +* deps(deps): bump the compose group with 2 updates by @dependabot[bot] in https://github.com/torlando-tech/columba/pull/995 +* feat(InterfaceManagementScreen): network-restriction chip on interface cards by @torlando-agent[bot] in https://github.com/torlando-tech/columba/pull/906 + + +**Full Changelog**: https://github.com/torlando-tech/columba/compare/v2.0.8-beta...v2.0.9-beta + +- + +`F0af`[View All Releases`:/page/releases.mu]`f + +- + +`F666Last synced: 2026-08-04`f diff --git a/tests/native/fixtures/nomadnet_readme.mu b/tests/native/fixtures/nomadnet_readme.mu new file mode 100644 index 00000000..332b95a2 --- /dev/null +++ b/tests/native/fixtures/nomadnet_readme.mu @@ -0,0 +1,122 @@ +>> Nomad Network - Communicate Freely + +Off-grid, resilient mesh communication with strong encryption, forward secrecy and extreme privacy. + +Nomad Network allows you to build private and resilient communications platforms that are in complete control and ownership of the people that use them. No signups, no agreements, no handover of any data, no permissions and gatekeepers. + +Nomad Network is build on `_`!`[LXMF`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=lxmf]`!`_ and `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_, which together provides the cryptographic mesh functionality and peer-to-peer message routing that Nomad Network relies on. This foundation also makes it possible to use the program over a very wide variety of communication mediums, from packet radio to fiber optics. + +Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. Since Nomad Network uses Reticulum, it is efficient enough to run even over `*extremely`* low-bandwidth medium, and has been succesfully used over 300bps radio links. + +If you'd rather want to use an LXMF client with a graphical user interface, you may want to take a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_, which is available for Linux, Android, Windows and macOS. + +>> Notable Features + + • Encrypted messaging over packet-radio, LoRa, WiFi or anything else `_`!`[Reticulum`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=reticulum]`!`_ supports. + • Zero-configuration, minimal-infrastructure mesh communication + • Distributed and encrypted message store holds messages for offline users + • Connectable nodes that can host pages and files + • Node-side generated pages with PHP, Python, bash or others + • Built-in text-based browser for interacting with contents on nodes + • Built-in RRC client for live, many-to-many chat + • An easy to use and bandwidth efficient markup language for writing pages + • Page caching in browser + +>> How do I get started? + +The easiest way to install Nomad Network is via `B333pip`b: + +`B333 +`= +# Install Nomad Network and dependencies +pip install nomadnet + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +`= +`b + +If you are using an operating system that blocks normal user package installation via `B333pip`b, you can return `B333pip`b to normal behaviour by editing the `B333~/.config/pip/pip.conf`b file, and adding the following directive in the `B333[global]`b section: + +`B333 +`= +[global] +break-system-packages = true +`= +`b + +Alternatively, you can use the `B333pipx`b tool to install Nomad Network in an isolated environment: + +`B333 +`= +# Install Nomad Network +pipx install nomadnet + +# Optionally install Reticulum utilities +pipx install rns + +# Optionally install standalone LXMF utilities +pipx install lxmf + +# Run the client +nomadnet + +# Or alternatively run as a daemon, with no user interface +nomadnet --daemon + +# List options +nomadnet --help +`= +`b + +`!Please Note`!: If this is the very first time you use pip to install a program on your system, you might need to reboot your system for the program to become available. If you get a "command not found" error or similar when running the program, reboot your system and try again. + +The first time the program is running, you will be presented with the `!Guide section`!, which contains all the information you need to start using Nomad Network. + +To use Nomad Network on packet radio or LoRa, you will need to configure your Reticulum installation to use any relevant packet radio TNCs or LoRa devices on your system. See the `_`!`[Reticulum documentation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/index.md]`!`_ for info. + +If you want to try Nomad Network without building your own physical network, you can connect to the `_`!`[distributed RNS backbone`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=connect-to-the-distributed-backbone]`!`_ over the Internet, where there is already quite a bit of Nomad Network and LXMF activity. If you connect to the testnet, you can leave nomadnet running for a while and wait for it to receive announces from other nodes on the network that host pages or services, or you can try connecting directly to some nodes listed here: + + • `!`_`[9ce92808be498e9e05590ff27cbfdfe4]`_`! The rns.recipes forum + • `!`_`[a4a5e861626ce97c9aa544d9ecdf6d22]`_`! rmap.world + +To browse pages on a node that is not currently known, open the URL dialog in the `!Network`! section of the program by pressing `!Ctrl+U`!, paste or enter the address and select `!Go`! or press enter. Nomadnet will attempt to discover and connect to the requested node. + +>>> Install on Android + +You can install Nomad Network on Android using Termux, but there's a few more commands involved than the above one-liner. The process is documented in the `_`!`[Android Installation`a8d24177d946de4f1f0a0fe1af9a1338:/page/blob.mu`g=reticulum|r=reticulum|ref=HEAD|path=docs/markdown/gettingstartedfast.md|anchor=reticulum-on-android]`!`_ section of the Reticulum Manual. Once the Reticulum has been installed according to the linked documentation, Nomad Network can be installed as usual with pip. + +For a native Android application with a graphical user interface, have a look at `_`!`[Sideband`a8d24177d946de4f1f0a0fe1af9a1338:/page/repo.mu`g=reticulum|r=sideband]`!`_. + +>> Help & Discussion + +For help requests, discussion, sharing ideas or anything else related to Nomad Network, please have a look at the `_`!`[rns.recipes forum`9ce92808be498e9e05590ff27cbfdfe4]`!`_. + +>> Support Nomad Network + +For this to be possible, I need your help. Please support the continued development of open, free and private communications systems by donating via one of the following channels: + +• `!Monero`! + 84FpY1QbxHcgdseePYNmhTHcrgMX4nFfBYtz2GKYToqHVVhJp8Eaw1Z1EedRnKD19b3B8NiLCGVxzKV17UMmmeEsCrPyA5w + +• `!Bitcoin`! + bc1pgqgu8h8xvj4jtafslq396v7ju7hkgymyrzyqft4llfslz5vp99psqfk3a6 + +• `!Ethereum`! + 0x91C421DdfB8a30a49A71d63447ddb54cEBe3465E + +• `!Liberapay`! + `[https://liberapay.com/Reticulum/] + +• `!Ko-Fi`! + `[https://ko-fi.com/markqvist] + +>> Caveat Emptor + +Nomad Network is experimental software, and should be considered as such. While it has been built with cryptography best-practices very foremost in mind, it `!has not`! been externally security audited, and there could very well be privacy-breaking bugs. Use at your own risk and responsibility. diff --git a/tests/native/test_app_launcher_nomadnet.cpp b/tests/native/test_app_launcher_nomadnet.cpp new file mode 100644 index 00000000..3b9d41ea --- /dev/null +++ b/tests/native/test_app_launcher_nomadnet.cpp @@ -0,0 +1,241 @@ +#include +#include +#include +#include +#include +#include + +#include "NavigationStack.h" +#include "NomadNetDocument.h" +#include "NomadNetHistory.h" +#include "NomadNetMailbox.h" +#include "NomadNetProtocol.h" +#include "NomadNetUrl.h" + +using UI::LXMF::NavigationStack; +using UI::LXMF::Route; +using UI::LXMF::NomadNet::Alignment; +using UI::LXMF::NomadNet::BlockType; +using UI::LXMF::NomadNet::DocumentParser; +using UI::LXMF::NomadNet::PageHistory; +using UI::LXMF::NomadNet::AsyncMailbox; +using UI::LXMF::NomadNet::ResponseBuffer; +using UI::LXMF::NomadNet::Url; + +int main(int argc, char** argv) { + int passed = 0; + int failed = 0; + auto check = [&](const char* name, bool condition) { + if (condition) ++passed; + else { ++failed; std::cerr << "FAIL: " << name << "\n"; } + }; + + NavigationStack nav; + check("launcher is navigation root", nav.current() == Route::HOME && nav.depth() == 0); + nav.navigate(Route::MESSAGES); + nav.navigate(Route::CHAT); + check("routes form a hierarchy", nav.current() == Route::CHAT && nav.depth() == 2); + check("back restores parent route exactly once", nav.back() && nav.current() == Route::MESSAGES && nav.depth() == 1); + nav.home(); + check("home clears hierarchy", nav.current() == Route::HOME && nav.depth() == 0); + nav.navigate(Route::MESSAGES); + nav.navigate(Route::COMPOSE); + nav.replace(Route::CHAT); + check("compose send replaces route", nav.current() == Route::CHAT && nav.back() && nav.current() == Route::MESSAGES); + nav.navigate(Route::CHAT); + nav.navigate(Route::CALL); + check("call exit restores owner", nav.back() && nav.current() == Route::CHAT); + nav.home(); + for (std::size_t i = 0; i < NavigationStack::MAX_DEPTH + 5; ++i) + nav.navigate((i & 1) ? Route::STATUS : Route::NETWORK); + check("navigation stack is bounded", nav.depth() == NavigationStack::MAX_DEPTH); + for (std::size_t i = 0; i < NavigationStack::MAX_DEPTH; ++i) nav.back(); + check("bounded stack preserves root", nav.current() == Route::HOME && !nav.back()); + + PageHistory history; + history.open("a"); + history.open("b"); + history.reload(); + check("reload does not add browser history", history.current() == "b" && history.depth() == 1); + check("browser back restores prior page", history.back() && history.current() == "a" && history.depth() == 0); + for (std::size_t i = 0; i < PageHistory::MAX_DEPTH + 4; ++i) history.open(std::to_string(i)); + check("browser history is bounded", history.depth() == PageHistory::MAX_DEPTH); + + AsyncMailbox mailbox; + const std::vector old_link{1}, new_link{2}; + mailbox.begin(new_link); + check("stale link callback is rejected", !mailbox.publish_link(old_link, true)); + check("active link callback is accepted", mailbox.publish_link(new_link, true)); + AsyncMailbox::Event event; + check("link event crosses mailbox", mailbox.take(event) && event.kind == AsyncMailbox::Kind::LINK_ESTABLISHED); + AsyncMailbox early_link; + check("link callback may arrive before token arming", early_link.publish_link(new_link, true)); + early_link.begin(new_link); + check("matching early link callback is retained", early_link.take(event) && event.kind == AsyncMailbox::Kind::LINK_ESTABLISHED); + const std::vector old_request{3}, new_request{4}; + AsyncMailbox early_request; + early_request.begin(new_link); + const uint8_t early[] = {'o', 'k'}; + check("response may arrive before receipt token arming", + early_request.publish_response(new_request, early, sizeof(early), sizeof(early))); + early_request.expect_request(new_request); + check("matching early response is retained", + early_request.take(event) && event.kind == AsyncMailbox::Kind::RESPONSE); + mailbox.expect_request(new_request); + const uint8_t tiny[] = {0xc4, 1, 'x'}; + check("stale response callback is rejected", !mailbox.publish_response(old_request, tiny, sizeof(tiny), sizeof(tiny))); + check("active response callback is accepted", mailbox.publish_response(new_request, tiny, sizeof(tiny), sizeof(tiny))); + check("terminal response is not overwritten by a racing link-close callback", + !mailbox.publish_link(new_link, false)); + check("response event crosses mailbox", mailbox.take(event) && event.kind == AsyncMailbox::Kind::RESPONSE && event.data.size() == sizeof(tiny)); + mailbox.expect_request(new_request); + check("oversized transfer is rejected before payload retention", + mailbox.publish_progress(new_request, AsyncMailbox::MAX_WIRE_BYTES + 1) && mailbox.take(event) && + event.kind == AsyncMailbox::Kind::OVERSIZED && event.data.empty()); + + Url url; + std::string error; + check("32 hex destination with page path parses", + Url::parse("0123456789abcdef0123456789ABCDEF:/page/index.mu", url, error)); + check("url normalizes destination hex and preserves path", + url.destination_hex == "0123456789abcdef0123456789abcdef" && url.path == "/page/index.mu"); + check("bare destination gets default page", + Url::parse("0123456789abcdef0123456789abcdef", url, error) && url.path == "/page/index.mu"); + check("relative same-node path parses with context", + Url::parse(":/page/about.mu", url, error, "fedcba9876543210fedcba9876543210") && + url.destination_hex == "fedcba9876543210fedcba9876543210"); + check("wrong destination length rejected", !Url::parse("abcd:/page/index.mu", url, error)); + check("nonhex destination rejected", !Url::parse("zz23456789abcdef0123456789abcdef:/page/index.mu", url, error)); + check("control characters rejected", !Url::parse("0123456789abcdef0123456789abcdef:/page/a\nb", url, error)); + check("downloads explicitly unsupported", !Url::parse("0123456789abcdef0123456789abcdef:/file/x", url, error)); + + DocumentParser parser; + auto doc = parser.parse( + "#!c=60\n#!bg=123\n#!fg=abcdef\n>> Heading\n" + "ordinary `!bold`! `*italic`* `_under`_ `Ff00red`f `B0f0back`b `ccentre\n" + "`[Next`0123456789abcdef0123456789abcdef:/page/next.mu]\n-\n" + "`=\n`!literal\n\\`=\n`=\n`tabc\n"); + check("cache metadata parsed from first line", doc.cache_seconds == 60); + check("page colors parsed", doc.has_background && doc.background == 0x112233 && + doc.has_foreground && doc.foreground == 0xabcdef); + check("heading depth parsed", doc.blocks.size() >= 5 && + doc.blocks[0].type == BlockType::HEADING && doc.blocks[0].depth == 2); + check("inline runs are styled", doc.blocks[1].runs.size() >= 6 && + doc.blocks[1].runs[1].bold && doc.blocks[1].runs[3].italic); + bool saw_background = false; + for (const auto& run : doc.blocks[1].runs) saw_background = saw_background || run.has_background; + check("inline background style parsed", saw_background); + check("alignment modifier represented", doc.blocks[1].alignment == Alignment::CENTER); + check("links are model elements", doc.links.size() == 1 && + doc.links[0].target.find("/page/next.mu") != std::string::npos); + auto link_fields = parser.parse("`[Search`:/page/search.mu`q=pyxis]\n"); + check("link target excludes request fields", link_fields.links.size() == 1 && + link_fields.links[0].target == ":/page/search.mu" && link_fields.links[0].fields == "q=pyxis"); + check("divider parsed", doc.blocks[3].type == BlockType::DIVIDER); + check("literal mode suppresses formatting", doc.blocks[4].runs.size() == 1 && + doc.blocks[4].runs[0].text == "`!literal"); + bool saw_unsupported = false; + for (const auto& block : doc.blocks) saw_unsupported = saw_unsupported || block.type == BlockType::UNSUPPORTED; + check("unsupported structured content has fallback", doc.unsupported && saw_unsupported); + + auto later_cache = parser.parse("text\n#!c=99999999999999999999\nmore"); + check("cache metadata is first-line-only", later_cache.cache_seconds == 0); + auto clamped_cache = parser.parse("#!c=99999999999999999999\ntext"); + check("cache seconds clamps without overflow", clamped_cache.cache_seconds == DocumentParser::MAX_CACHE_SECONDS); + auto high_bit_digit = parser.parse(std::string("#!c=1") + char(0xff) + "\ntext"); + check("cache digit validation is unsigned-char safe", high_bit_digit.cache_seconds == 0 && high_bit_digit.malformed); + + std::string invalid_utf8("ok\xF0\x28\x8C\x28", 6); + auto utf8_doc = parser.parse(invalid_utf8); + check("invalid UTF-8 is rejected before rendering", utf8_doc.malformed && utf8_doc.blocks.empty()); + + std::string huge(DocumentParser::MAX_DOCUMENT_BYTES, 'x'); + huge += "\n#!c=99\n"; + auto huge_doc = parser.parse(huge); + check("document bytes capped and truncation propagated", huge_doc.truncated && + huge_doc.source_bytes == DocumentParser::MAX_DOCUMENT_BYTES); + check("parser never processes metadata beyond retained source", huge_doc.cache_seconds == 0); + std::string long_line(DocumentParser::MAX_SOURCE_LINE_BYTES + 20, 'q'); + auto line_doc = parser.parse(long_line); + check("source line capped", line_doc.truncated && !line_doc.blocks.empty() && + line_doc.blocks[0].runs[0].text.size() == DocumentParser::MAX_SOURCE_LINE_BYTES); + auto utf8_boundary = parser.parse(std::string(DocumentParser::MAX_SOURCE_LINE_BYTES - 1, 'x') + "\xe2\x82\xac"); + check("line truncation preserves UTF-8 boundaries", utf8_boundary.truncated && !utf8_boundary.malformed && + utf8_boundary.blocks[0].runs[0].text.size() == + DocumentParser::MAX_SOURCE_LINE_BYTES - 1); + auto blank_lines = parser.parse("one\n\nthree\n"); + check("blank lines preserve vertical structure", blank_lines.blocks.size() == 4 && + blank_lines.blocks[1].runs[0].text == " "); + std::string many_lines; + for (std::size_t i = 0; i < DocumentParser::MAX_SOURCE_LINES + 50; ++i) many_lines += "x\n"; + auto lines_doc = parser.parse(many_lines); + check("source line count capped", lines_doc.truncated && + lines_doc.source_lines <= DocumentParser::MAX_SOURCE_LINES); + std::string many_runs; + for (std::size_t i = 0; i < DocumentParser::MAX_RUNS_PER_LINE + 20; ++i) many_runs += "x`!"; + auto runs_doc = parser.parse(many_runs); + check("runs per line capped", runs_doc.truncated && + runs_doc.blocks[0].runs.size() == DocumentParser::MAX_RUNS_PER_LINE); + std::string global_runs; + for (std::size_t i = 0; i < DocumentParser::MAX_TOTAL_RUNS; ++i) global_runs += "a`!b`!\n"; + auto global_runs_doc = parser.parse(global_runs); + std::size_t retained_runs = 0; + for (const auto& block : global_runs_doc.blocks) retained_runs += block.runs.size(); + check("runs are globally bounded", global_runs_doc.truncated && retained_runs <= DocumentParser::MAX_TOTAL_RUNS); + std::string many_links; + for (std::size_t i = 0; i < DocumentParser::MAX_LINKS + 10; ++i) many_links += "`[x`:/page/x]"; + auto links_doc = parser.parse(many_links); + check("links are globally bounded", links_doc.truncated && links_doc.links.size() == DocumentParser::MAX_LINKS); + + auto malformed = parser.parse("#!c=not-a-number\n#!bg=xyz\n`F1broken\n[label`target\n`=\ntext"); + check("malformed micron remains bounded", malformed.blocks.size() <= DocumentParser::MAX_BLOCKS); + check("invalid metadata ignored", malformed.cache_seconds == 0 && !malformed.has_background); + check("unterminated literal is reported", malformed.malformed); + + if (argc > 1) { + std::ifstream input(argv[1], std::ios::binary); + std::string fixture((std::istreambuf_iterator(input)), std::istreambuf_iterator()); + auto real = parser.parse(fixture); + check("authoritative Aleph fixture is readable", input.good() || input.eof()); + check("authoritative fixture yields headings", !real.blocks.empty() && real.blocks[0].type == BlockType::HEADING); + check("authoritative fixture yields links", !real.links.empty()); + check("authoritative fixture remains within bounds", real.blocks.size() <= DocumentParser::MAX_BLOCKS && + real.links.size() <= DocumentParser::MAX_LINKS); + } else { + check("authoritative Aleph fixture is readable", false); + check("authoritative fixture yields headings", false); + check("authoritative fixture yields links", false); + check("authoritative fixture remains within bounds", false); + } + + const auto nil = UI::LXMF::NomadNet::no_form_request_data(); + check("no-form request is exact msgpack nil", nil.size() == 1 && nil[0] == 0xc0); + ResponseBuffer response; + const uint8_t bin8[] = {0xc4, 0x03, 'm', 'u', '!'}; + check("msgpack bin8 response normalizes", UI::LXMF::NomadNet::normalize_response(bin8, sizeof(bin8), response) && + response.size() == 3 && response.bytes()[0] == 'm'); + const uint8_t bin16[] = {0xc5, 0x00, 0x03, 'm', 'u', '!'}; + check("msgpack bin16 response normalizes", UI::LXMF::NomadNet::normalize_response(bin16, sizeof(bin16), response)); + std::vector exact(5 + ResponseBuffer::MAX_BYTES, 'x'); + exact[0] = 0xc6; exact[1] = 0; exact[2] = 1; exact[3] = 0; exact[4] = 0; + check("exact 64KiB bin32 is accepted", UI::LXMF::NomadNet::normalize_response(exact.data(), exact.size(), response) && + response.size() == ResponseBuffer::MAX_BYTES && !response.truncated()); + const uint8_t raw[] = {'r', 'a', 'w'}; + check("raw packet response normalizes", UI::LXMF::NomadNet::normalize_response(raw, sizeof(raw), response) && + response.size() == sizeof(raw) && response.bytes()[0] == 'r'); + const uint8_t trailing[] = {0xc4, 0x01, 'x', 'y'}; + check("trailing bytes are rejected", !UI::LXMF::NomadNet::normalize_response(trailing, sizeof(trailing), response)); + const uint8_t malformed8[] = {0xc4}; + check("malformed bin8 is rejected", !UI::LXMF::NomadNet::normalize_response(malformed8, sizeof(malformed8), response)); + const uint8_t malformed16[] = {0xc5, 0x00}; + check("malformed bin16 is rejected", !UI::LXMF::NomadNet::normalize_response(malformed16, sizeof(malformed16), response)); + const uint8_t malformed32[] = {0xc6, 0, 1, 0}; + check("malformed bin32 is rejected", !UI::LXMF::NomadNet::normalize_response(malformed32, sizeof(malformed32), response)); + const uint8_t oversize_advertised[] = {0xc6, 0, 1, 0, 1}; + check("oversize advertised payload is rejected before retention", + !UI::LXMF::NomadNet::normalize_response(oversize_advertised, sizeof(oversize_advertised), response) && response.size() == 0); + check("null and empty responses are rejected", !UI::LXMF::NomadNet::normalize_response(nullptr, 0, response)); + + std::cout << passed << " passed, " << failed << " failed\n"; + return failed == 0 ? 0 : 1; +} diff --git a/tests/native/test_app_launcher_nomadnet.py b/tests/native/test_app_launcher_nomadnet.py new file mode 100644 index 00000000..89afa87c --- /dev/null +++ b/tests/native/test_app_launcher_nomadnet.py @@ -0,0 +1,77 @@ +import shutil +import subprocess +import hashlib +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +SOURCE = HERE / "test_app_launcher_nomadnet.cpp" +INCLUDE = ROOT / "lib" / "tdeck_ui" / "UI" / "LXMF" +FIXTURES = { + HERE / "fixtures" / "nomadnet_readme.mu": + "e47de4e19e7216e48a8d441abbca1650b77ab6a2b47bedbd2144301f44172728", + HERE / "fixtures" / "columba_releases_live.mu": + "1ea4eea2b06d6988c53c19df51d38f78abd27125b4613f7a3fa5715f7461b10a", +} + + +def _cxx(): + for name in ("clang++", "g++"): + if shutil.which(name): + return name + pytest.skip("no C++ compiler found") + + +def test_app_launcher_nomadnet_native(tmp_path): + # Exact upstream NomadNet 1.2.8 README.mu plus Micron generated by + # the live Columba Releases service; hashes prevent fixture drift. The + # Columba fixture is LF-normalized from raw artifact SHA-256: + # 6b93c458d31e3b74c33a0cd0333582a69a3ffd0b0033c2a228b0a85da779e37f. + for fixture, expected_hash in FIXTURES.items(): + assert hashlib.sha256(fixture.read_bytes()).hexdigest() == expected_hash + binary = tmp_path / "test_app_launcher_nomadnet" + command = [ + _cxx(), "-std=c++17", "-Wall", "-Wextra", "-Werror", + f"-I{INCLUDE}", str(SOURCE), + str(INCLUDE / "NomadNetDocument.cpp"), + str(INCLUDE / "NomadNetUrl.cpp"), + "-o", str(binary), + ] + compiled = subprocess.run(command, capture_output=True, text=True) + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + for fixture in FIXTURES: + result = subprocess.run([str(binary), str(fixture)], capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip().endswith("passed, 0 failed") + + +def test_ui_wiring_contract(): + manager_h = (INCLUDE / "UIManager.h").read_text() + manager_cpp = (INCLUDE / "UIManager.cpp").read_text() + launcher_cpp = (INCLUDE / "HomeScreen.cpp").read_text() + network_cpp = (INCLUDE / "NetworkScreen.cpp").read_text() + browser_cpp = (INCLUDE / "NomadNetScreen.cpp").read_text() + + for tile in ("Messages", "NomadNet", "Network", "Settings"): + assert tile in launcher_cpp + for child in ("Announces", "Status", "Radio Activity", "Propagation Nodes"): + assert child in network_cpp + assert "NavigationStack" in manager_h + assert "void UIManager::navigate(" in manager_cpp + assert "void UIManager::back()" in manager_cpp + assert "void UIManager::home()" in manager_cpp + assert "SCREEN_" not in manager_h + assert "nomadnetwork\", \"node" in manager_cpp + assert "Identity::recall" in manager_cpp + assert "Transport::request_path" in manager_cpp + assert "no_form_request_data" in manager_cpp + assert "response_transfer_size()" in manager_cpp + assert "DEPENDENCY HARDENING GAP" in manager_cpp + assert "lv_obj_set_scroll_dir" in browser_cpp + assert "lv_textarea_set_max_length" in browser_cpp + assert "set_link_callback" in manager_cpp + assert "_link_targets" in browser_cpp + assert "LV_SYMBOL_HOME" in browser_cpp + assert "lv_font_" in browser_cpp diff --git a/tests/native/test_radio_activity.py b/tests/native/test_radio_activity.py index 614992ab..de9d0765 100644 --- a/tests/native/test_radio_activity.py +++ b/tests/native/test_radio_activity.py @@ -113,7 +113,7 @@ def test_ui_contract_uses_dedicated_status_child_and_snapshot_only_rendering(): screen_cpp = SCREEN_CPP.read_text() status_cpp = STATUS_CPP.read_text() - assert "SCREEN_RADIO_ACTIVITY" in manager_h + assert "Route::RADIO_ACTIVITY" in manager_h assert "set_radio_activity_callback" in status_cpp assert "show_radio_activity" in manager_cpp assert "on_back_from_radio_activity" in manager_cpp