feat(reticulum): complete messaging, calls, and P4 support

Add official Sideband/LXST messaging and telephony flows, realtime resource ownership, call and ping UI, durable unread state, and bounded Nomad/Micron browsing.

Integrate ESP32-P4/C6 gateway transport, TinyUSB CDC, ES8311 audio, shared Codec2 call media, Reticulum defaults, compatibility settings, and conformance coverage.
This commit is contained in:
liu weikai
2026-07-14 22:36:07 +08:00
parent 427f610a5c
commit f7d6599310
122 changed files with 10954 additions and 1046 deletions
@@ -13,6 +13,10 @@
#include "ui/localization.h"
#include "ui/menu/dashboard/dashboard_state.h"
#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12
#define lv_font_montserrat_12 lv_font_montserrat_14
#endif
namespace ui::menu::dashboard
{
namespace
@@ -13,6 +13,10 @@
#include "ui/presentation_sources/runtime_gps_status_source.h"
#include "ui_presentation/gps/gps_status_model.h"
#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12
#define lv_font_montserrat_12 lv_font_montserrat_14
#endif
namespace ui::menu::dashboard
{
namespace
@@ -10,6 +10,10 @@
#include "ui/presentation_sources/runtime_mesh_status_source.h"
#include "ui_presentation/mesh/mesh_status_model.h"
#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12
#define lv_font_montserrat_12 lv_font_montserrat_14
#endif
namespace ui::menu::dashboard
{
namespace
@@ -10,6 +10,10 @@
#include "ui/localization.h"
#include "ui/menu/dashboard/dashboard_state.h"
#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12
#define lv_font_montserrat_12 lv_font_montserrat_14
#endif
namespace ui::menu::dashboard
{
namespace
@@ -7,6 +7,10 @@
#include "ui/assets/fonts/font_utils.h"
#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12
#define lv_font_montserrat_12 lv_font_montserrat_14
#endif
namespace ui::menu::dashboard
{
namespace
@@ -475,6 +475,7 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot(
}
}
row.outgoing = message.status != ::chat::MessageStatus::Incoming;
row.source_unverified = !row.outgoing && message.source_unverified;
row.sender_node_id = row.outgoing ? 0 : message.from;
copyString(row.text, message.text);
copyTimeLabel(row.time_label, message.timestamp);
@@ -1219,7 +1219,8 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row
lv_obj_t* bubble = chat::ui::layout::create_bubble(item.container);
item.bubble = bubble;
chat::ui::conversation::styles::apply_bubble(bubble, is_self);
chat::ui::conversation::styles::apply_bubble(
bubble, is_self, row.source_unverified);
chat::ui::layout::set_bubble_max_width(bubble, max_bubble_w);
char time_buf[24];
@@ -21,6 +21,7 @@ static lv_style_t s_row;
static lv_style_t s_bubble_base;
static lv_style_t s_bubble_self;
static lv_style_t s_bubble_other;
static lv_style_t s_bubble_unverified;
static lv_style_t s_bubble_text;
static lv_style_t s_bubble_time;
static lv_style_t s_bubble_status;
@@ -124,6 +125,10 @@ void init_once()
lv_style_set_bg_color(&s_bubble_other, kBubbleOther);
lv_style_set_border_color(&s_bubble_other, lv_color_hex(0xE2C487));
lv_style_init(&s_bubble_unverified);
lv_style_set_bg_color(&s_bubble_unverified, lv_color_hex(0xF4E1DE));
lv_style_set_border_color(&s_bubble_unverified, lv_color_hex(0xC47D70));
lv_style_init(&s_bubble_text);
lv_style_set_text_color(&s_bubble_text, kTextColor);
lv_style_set_text_align(&s_bubble_text, LV_TEXT_ALIGN_LEFT);
@@ -177,11 +182,14 @@ void apply_message_row(lv_obj_t* row)
lv_obj_add_style(row, &s_row, 0);
}
void apply_bubble(lv_obj_t* bubble, bool is_self)
void apply_bubble(lv_obj_t* bubble, bool is_self, bool source_unverified)
{
init_once();
lv_obj_add_style(bubble, &s_bubble_base, LV_PART_MAIN);
lv_obj_add_style(bubble, is_self ? &s_bubble_self : &s_bubble_other, LV_PART_MAIN);
lv_style_t* message_style =
is_self ? &s_bubble_self
: (source_unverified ? &s_bubble_unverified : &s_bubble_other);
lv_obj_add_style(bubble, message_style, LV_PART_MAIN);
}
void apply_bubble_text(lv_obj_t* label)
@@ -23,6 +23,7 @@
#include "ui/screens/chat/chat_team_workflow.h"
#include "ui/ui_common.h"
#include "ui/widgets/ime/ime_widget.h"
#include "ui/widgets/reticulum_ping_overlay.h"
#include "ui_chat_runtime/chat_delivery_action_port_adapter.h"
#include "ui_lvgl_ux_packs/common/key_verification_modal_renderer.h"
#include "ui_lvgl_ux_packs/common/team_position_picker_renderer.h"
@@ -1199,11 +1200,17 @@ void UiController::handlePingDestination(const chat::ConversationId& conv)
return;
}
const std::string display_name = base_conversation_name(conv);
::ui::widgets::reticulum_ping::show_loading(
conv.reticulum_identity.destination_hash,
display_name.c_str());
const chat::MeshActionResult result =
service_.pingReticulumDestination(conv.reticulum_identity);
::ui::feedback::show_notice(result.ok ? "Ping sent"
: reticulum_ping_failure_message(result),
result.ok ? 1400 : 2200);
if (!result.ok)
{
::ui::widgets::reticulum_ping::show_send_failure(
reticulum_ping_failure_message(result));
}
}
void UiController::handleDeleteConversation(const chat::ConversationId& conv)
@@ -303,8 +303,16 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row
lv_label_set_text(item.text_label, display_text.c_str());
lv_obj_set_style_text_color(item.text_label, ::ui::theme::text(), 0);
lv_obj_set_style_bg_color(item.text_label,
is_self ? ::ui::theme::surface_alt() : ::ui::theme::surface(),
is_self ? ::ui::theme::surface_alt()
: (row.source_unverified
? lv_color_hex(0xF4E1DE)
: ::ui::theme::surface()),
0);
if (!is_self && row.source_unverified)
{
lv_obj_set_style_border_width(item.text_label, 1, 0);
lv_obj_set_style_border_color(item.text_label, lv_color_hex(0xC47D70), 0);
}
lv_obj_set_style_bg_opa(item.text_label, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(item.text_label, 6, 0);
lv_obj_set_style_radius(item.text_label, 6, 0);
@@ -44,6 +44,7 @@
#include "ui/ui_common.h"
#include "ui/widgets/busy_overlay.h"
#include "ui/widgets/ime/ime_widget.h"
#include "ui/widgets/reticulum_ping_overlay.h"
#include "ui/widgets/top_bar.h"
#include <cctype>
@@ -1844,21 +1845,21 @@ static void on_lxmf_address_apply(const char* text, void* /*user_data*/)
char generated_nickname[13] = {};
make_lxmf_short_name(identity, node_id, short_name, sizeof(short_name));
make_lxmf_contact_nickname(identity, generated_nickname, sizeof(generated_nickname));
std::snprintf(nickname, sizeof(nickname), "%s", generated_nickname);
lv_strlcpy(nickname, generated_nickname, sizeof(nickname));
const char* address_display_name =
has_full_address && address_record.display_name[0] != '\0'
? address_record.display_name
: nullptr;
if (address_display_name && std::strlen(address_display_name) <= 12U)
{
std::snprintf(nickname, sizeof(nickname), "%s", address_display_name);
lv_strlcpy(nickname, address_display_name, sizeof(nickname));
}
else if (existing_node)
{
const std::string existing_name = node_display_name_for_contacts(*existing_node);
if (!existing_name.empty() && existing_name.size() <= 12U)
{
std::snprintf(nickname, sizeof(nickname), "%s", existing_name.c_str());
lv_strlcpy(nickname, existing_name.c_str(), sizeof(nickname));
}
}
@@ -3971,11 +3972,16 @@ static void start_reticulum_ping_for_selected_node()
return;
}
::ui::widgets::reticulum_ping::show_loading(
node->reticulum_identity.destination_hash,
node->display_name.empty() ? node->long_name : node->display_name.c_str());
const chat::MeshActionResult result =
g_contacts_state.chat_service->pingReticulumDestination(node->reticulum_identity);
::ui::feedback::show_notice(result.ok ? "Ping sent"
: reticulum_ping_failure_message(result),
result.ok ? 1400 : 2200);
if (!result.ok)
{
::ui::widgets::reticulum_ping::show_send_failure(
reticulum_ping_failure_message(result));
}
contacts_focus_to_list();
}
@@ -48,6 +48,23 @@ bool is_hex_text(const char* text, std::size_t len)
return true;
}
uint8_t hex_nibble(char ch)
{
if (ch >= '0' && ch <= '9')
{
return static_cast<uint8_t>(ch - '0');
}
if (ch >= 'A' && ch <= 'F')
{
return static_cast<uint8_t>(ch - 'A' + 10);
}
if (ch >= 'a' && ch <= 'f')
{
return static_cast<uint8_t>(ch - 'a' + 10);
}
return 0xFF;
}
std::size_t utf8_step_len(unsigned char lead)
{
if ((lead & 0x80U) == 0)
@@ -347,6 +364,88 @@ void slug_from_text(const char* text, char* out, std::size_t out_len)
out[out_pos] = '\0';
}
bool parse_color_text(const char* text, std::size_t len, std::uint32_t* out)
{
if (!text || !out)
{
return false;
}
if (len == 3 && (text[0] == 'g' || text[0] == 'G') &&
std::isdigit(static_cast<unsigned char>(text[1])) &&
std::isdigit(static_cast<unsigned char>(text[2])))
{
const std::uint32_t value =
static_cast<std::uint32_t>((text[1] - '0') * 10 + (text[2] - '0'));
const std::uint32_t byte = value > 99U ? 255U : (value * 255U) / 99U;
*out = (byte << 16U) | (byte << 8U) | byte;
return true;
}
if (len == 3 && is_hex_text(text, len))
{
const std::uint32_t r = static_cast<std::uint32_t>(hex_nibble(text[0]) * 17U);
const std::uint32_t g = static_cast<std::uint32_t>(hex_nibble(text[1]) * 17U);
const std::uint32_t b = static_cast<std::uint32_t>(hex_nibble(text[2]) * 17U);
*out = (r << 16U) | (g << 8U) | b;
return true;
}
if (len == 6 && is_hex_text(text, len))
{
std::uint32_t value = 0;
for (std::size_t i = 0; i < 6; ++i)
{
value = (value << 4U) | hex_nibble(text[i]);
}
*out = value;
return true;
}
return false;
}
ColorToken parse_color_token(const char* line, std::size_t len, std::size_t index)
{
ColorToken token{};
if (!line || index >= len)
{
return token;
}
std::uint32_t rgb = 0;
if (index + 8U <= len && (line[index + 1U] == 'T' || line[index + 1U] == 't') &&
parse_color_text(line + index + 2U, 6, &rgb))
{
token.valid = true;
token.rgb = rgb;
token.consumed = 8;
return token;
}
if (index + 9U <= len && line[index + 4U] == '`' &&
line[index + 5U] == line[index] && is_hex_text(line + index + 1U, 3) &&
is_hex_text(line + index + 6U, 3))
{
char expanded[6] = {
line[index + 6U],
line[index + 1U],
line[index + 7U],
line[index + 2U],
line[index + 8U],
line[index + 3U],
};
if (parse_color_text(expanded, sizeof(expanded), &rgb))
{
token.valid = true;
token.rgb = rgb;
token.consumed = 9;
return token;
}
}
if (index + 4U <= len && parse_color_text(line + index + 1U, 3, &rgb))
{
token.valid = true;
token.rgb = rgb;
token.consumed = 4;
}
return token;
}
TableOpenOptions parse_table_opening_tag(const char* line)
{
TableOpenOptions options{};
@@ -607,6 +706,22 @@ LinkTargetKind classify_link_target(const char* target)
return LinkTargetKind::Unknown;
}
const char* link_target_kind_label(LinkTargetKind kind)
{
switch (kind)
{
case LinkTargetKind::Page:
return "page";
case LinkTargetKind::Anchor:
return "anchor";
case LinkTargetKind::Resource:
return "resource";
case LinkTargetKind::Unknown:
default:
return "unknown";
}
}
bool extract_partial_url(const char* line, char* out, std::size_t out_len)
{
if (!out || out_len == 0)
@@ -614,10 +729,22 @@ bool extract_partial_url(const char* line, char* out, std::size_t out_len)
return false;
}
out[0] = '\0';
if (!line || line[0] != '`' || line[1] != '{')
const PartialReference partial = parse_partial_reference(line);
if (!partial.valid)
{
return false;
}
copy_range(out, out_len, partial.target, std::strlen(partial.target));
return out[0] != '\0';
}
PartialReference parse_partial_reference(const char* line)
{
PartialReference partial{};
if (!line || line[0] != '`' || line[1] != '{')
{
return partial;
}
const char* start = line + 2;
const char* end = start;
@@ -633,8 +760,36 @@ bool extract_partial_url(const char* line, char* out, std::size_t out_len)
{
++start;
}
copy_range(out, out_len, start, static_cast<std::size_t>(end - start));
return out[0] != '\0';
copy_range(partial.target,
sizeof(partial.target),
start,
static_cast<std::size_t>(end - start));
if (partial.target[0] == '\0')
{
return partial;
}
partial.valid = true;
partial.target_kind = classify_link_target(partial.target);
if (*end == '`')
{
const char* hint = end + 1;
while (ascii_space(*hint))
{
++hint;
}
while (std::isdigit(static_cast<unsigned char>(*hint)))
{
partial.has_display_hint = true;
const unsigned value =
static_cast<unsigned>(partial.display_hint) * 10U +
static_cast<unsigned>(*hint - '0');
partial.display_hint =
static_cast<std::uint16_t>(value > 999U ? 999U : value);
++hint;
}
}
return partial;
}
} // namespace ui::screens::network::micron
File diff suppressed because it is too large Load Diff
@@ -1310,6 +1310,10 @@ static void reset_mesh_settings()
app_ctx.getConfig().reticulumConfig().reticulum_wifi_auto_connect;
g_settings.rt_anonymous_peer =
app_ctx.getConfig().reticulumConfig().reticulum_anonymous_peer;
g_settings.rt_call_wire = static_cast<int>(
app_ctx.getConfig().reticulumConfig().reticulum_call_wire_profile);
g_settings.rt_location_requests =
app_ctx.getConfig().reticulumConfig().reticulum_allow_location_requests;
g_settings.mt_mqtt_enabled = app_ctx.getConfig().meshtastic_mqtt_enabled;
g_settings.mt_mqtt_uplink = app_ctx.getConfig().meshtastic_mqtt_uplink_enabled;
g_settings.mt_mqtt_downlink = app_ctx.getConfig().meshtastic_mqtt_downlink_enabled;
@@ -1614,6 +1618,10 @@ static void settings_load()
g_settings.rt_wifi_gateway_enabled = reticulum_cfg.reticulum_wifi_gateway_enabled;
g_settings.rt_wifi_auto_connect = reticulum_cfg.reticulum_wifi_auto_connect;
g_settings.rt_anonymous_peer = reticulum_cfg.reticulum_anonymous_peer;
g_settings.rt_call_wire =
static_cast<int>(reticulum_cfg.reticulum_call_wire_profile);
g_settings.rt_location_requests =
reticulum_cfg.reticulum_allow_location_requests;
copy_bounded(g_settings.rt_wifi_gateway_host,
sizeof(g_settings.rt_wifi_gateway_host),
reticulum_cfg.reticulum_wifi_gateway_host);
@@ -3241,6 +3249,20 @@ static void on_option_clicked(lv_event_t* e)
app_ctx.applyMeshConfig();
rebuild_list = true;
}
if (id == settings::ui::SettingId::RtCallWire)
{
app::IAppFacade& app_ctx = app::appFacade();
const int clamped = payload->value == static_cast<int>(
chat::ReticulumCallWireProfile::MeshChatCallAudio)
? payload->value
: static_cast<int>(
chat::ReticulumCallWireProfile::SidebandLxst);
g_settings.rt_call_wire = clamped;
app_ctx.getConfig().reticulumConfig().reticulum_call_wire_profile =
static_cast<chat::ReticulumCallWireProfile>(clamped);
app_ctx.saveConfig();
app_ctx.applyMeshConfig();
}
if (id == settings::ui::SettingId::MeshProtocol)
{
app::IAppFacade& app_ctx = app::appFacade();
@@ -4388,6 +4410,10 @@ static const settings::ui::SettingOption kReticulumBearerOptions[] = {
{"LoRa", static_cast<int>(chat::ReticulumInterfacePolicy::LoRaOnly)},
{"Wi-Fi", static_cast<int>(chat::ReticulumInterfacePolicy::WifiGatewayOnly)},
};
static const settings::ui::SettingOption kReticulumCallWireOptions[] = {
{"Sideband", static_cast<int>(chat::ReticulumCallWireProfile::SidebandLxst)},
{"MeshChat", static_cast<int>(chat::ReticulumCallWireProfile::MeshChatCallAudio)},
};
static const settings::ui::SettingOption kChatContactAlertOptions[] = {
{"OFF", kChatContactAlertsNone},
{"Contacts Only", kChatContactAlertsContacts},
@@ -4577,6 +4603,8 @@ static settings::ui::SettingItem kMeshItems[] = {
{"Gateway Port", settings::ui::SettingType::Text, nullptr, 0, nullptr, nullptr, g_settings.rt_wifi_gateway_port, sizeof(g_settings.rt_wifi_gateway_port), false, "rt_wifi_port"},
{"Auto Wi-Fi", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_wifi_auto_connect, nullptr, 0, false, "rt_wifi_auto"},
{"Anonymous Peer", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_anonymous_peer, nullptr, 0, false, "rt_anonymous_peer"},
{"Call Protocol", settings::ui::SettingType::Enum, kReticulumCallWireOptions, sizeof(kReticulumCallWireOptions) / sizeof(kReticulumCallWireOptions[0]), &g_settings.rt_call_wire, nullptr, nullptr, 0, false, "rt_call_wire"},
{"Location Requests", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_location_requests, nullptr, 0, false, "rt_location_requests"},
};
static settings::ui::SettingItem kRadioItems[] = {
@@ -5121,6 +5149,15 @@ static bool activate_item_widget(settings::ui::ItemWidget& widget)
app_ctx.applyMeshConfig();
break;
}
case settings::ui::SettingId::RtLocationRequests:
{
app::IAppFacade& app_ctx = app::appFacade();
app_ctx.getConfig().reticulumConfig().reticulum_allow_location_requests =
*item.bool_value;
app_ctx.saveConfig();
app_ctx.applyMeshConfig();
break;
}
case settings::ui::SettingId::NetOverrideDuty:
{
app::IAppFacade& app_ctx = app::appFacade();
@@ -70,6 +70,8 @@ constexpr SettingSpec kSpecs[] = {
{SettingId::RtWifiPort, "rt_wifi_port", DynamicOptionKind::None},
{SettingId::RtWifiAuto, "rt_wifi_auto", DynamicOptionKind::None},
{SettingId::RtAnonymousPeer, "rt_anonymous_peer", DynamicOptionKind::None},
{SettingId::RtCallWire, "rt_call_wire", DynamicOptionKind::None},
{SettingId::RtLocationRequests, "rt_location_requests", DynamicOptionKind::None},
{SettingId::NetUsePreset, "net_use_preset", DynamicOptionKind::None},
{SettingId::NetPreset, "net_preset", DynamicOptionKind::None},
{SettingId::NetBw, "net_bw", DynamicOptionKind::None},
@@ -274,6 +276,8 @@ bool is_reticulum_mesh(SettingId id)
case SettingId::RtWifiPort:
case SettingId::RtWifiAuto:
case SettingId::RtAnonymousPeer:
case SettingId::RtCallWire:
case SettingId::RtLocationRequests:
return true;
default:
return false;
@@ -32,6 +32,8 @@ constexpr uint32_t kWarn = 0xB94A2C;
constexpr uint32_t kOk = 0x3E7D3E;
constexpr int kEncoderKeyRotateUp = 19;
constexpr int kEncoderKeyRotateDown = 20;
constexpr uint8_t kVolumeStep = 5;
constexpr uint32_t kActivationGuardMs = 400;
constexpr lv_coord_t kFallbackScreenW = 320;
constexpr lv_coord_t kFallbackScreenH = 240;
@@ -42,6 +44,7 @@ struct OverlayState
lv_obj_t* title = nullptr;
lv_obj_t* peer = nullptr;
lv_obj_t* status = nullptr;
lv_obj_t* shortcut_hint = nullptr;
lv_obj_t* answer_btn = nullptr;
lv_obj_t* hangup_btn = nullptr;
lv_obj_t* decline_btn = nullptr;
@@ -49,6 +52,7 @@ struct OverlayState
lv_group_t* previous_group = nullptr;
AppScreen* owner_app = nullptr;
bool needs_present = false;
uint32_t activation_armed_ms = 0;
::platform::ui::reticulum_call::State last_state =
::platform::ui::reticulum_call::State::Idle;
};
@@ -244,6 +248,52 @@ bool handle_focus_key(lv_event_t* event)
return false;
}
void update_shortcut_hint()
{
if (!s_overlay.shortcut_hint)
{
return;
}
char hint[48] = {};
std::snprintf(hint,
sizeof(hint),
"+/- Volume %u%%",
static_cast<unsigned>(
::platform::ui::reticulum_call::speaker_volume()));
apply_label(s_overlay.shortcut_hint, hint, kTextDim, caption_font());
}
bool handle_volume_key(lv_event_t* event)
{
if (!event || lv_event_get_code(event) != LV_EVENT_KEY)
{
return false;
}
const uint32_t key = lv_event_get_key(event);
const bool louder = key == '+' || key == '=';
const bool quieter = key == '-' || key == '_';
if (!louder && !quieter)
{
return false;
}
const uint8_t current = ::platform::ui::reticulum_call::speaker_volume();
const int delta = louder ? static_cast<int>(kVolumeStep) : -static_cast<int>(kVolumeStep);
int next = static_cast<int>(current) + delta;
if (next < 0)
{
next = 0;
}
else if (next > 100)
{
next = 100;
}
::platform::ui::reticulum_call::set_speaker_volume(static_cast<uint8_t>(next));
update_shortcut_hint();
stop_input_event(event);
return true;
}
bool visible_action(lv_obj_t* obj, bool incoming)
{
if (!obj || !lv_obj_is_valid(obj) || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN))
@@ -311,6 +361,11 @@ bool activation_event(lv_event_t* event)
{
return false;
}
if (static_cast<int32_t>(lv_tick_get() - s_overlay.activation_armed_ms) < 0)
{
stop_input_event(event);
return false;
}
if (lv_event_get_code(event) == LV_EVENT_CLICKED)
{
return true;
@@ -325,6 +380,10 @@ bool activation_event(lv_event_t* event)
void answer_cb(lv_event_t* event)
{
if (handle_volume_key(event))
{
return;
}
if (handle_focus_key(event))
{
return;
@@ -342,6 +401,10 @@ void answer_cb(lv_event_t* event)
void hangup_cb(lv_event_t* event)
{
if (handle_volume_key(event))
{
return;
}
if (handle_focus_key(event))
{
return;
@@ -356,6 +419,10 @@ void hangup_cb(lv_event_t* event)
void decline_cb(lv_event_t* event)
{
if (handle_volume_key(event))
{
return;
}
if (handle_focus_key(event))
{
return;
@@ -378,6 +445,10 @@ void root_key_cb(lv_event_t* event)
{
return;
}
if (handle_volume_key(event))
{
return;
}
const uint32_t key = lv_event_get_key(event);
const auto snapshot = ::platform::ui::reticulum_call::snapshot();
if ((key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) &&
@@ -517,8 +588,47 @@ void ensure_overlay()
s_overlay.answer_btn = make_button(actions, "Answer", kOk, 0x2F662F, answer_cb);
s_overlay.hangup_btn = make_button(actions, "Hang up", kWarn, 0x95351F, hangup_cb);
s_overlay.decline_btn = make_button(actions, "Decline", kAmber, kAmberDark, decline_cb);
s_overlay.shortcut_hint = lv_label_create(s_overlay.panel);
lv_obj_set_width(s_overlay.shortcut_hint, LV_PCT(100));
lv_label_set_long_mode(s_overlay.shortcut_hint, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_align(s_overlay.shortcut_hint,
LV_TEXT_ALIGN_CENTER,
LV_PART_MAIN);
lv_obj_move_foreground(s_overlay.root);
s_overlay.needs_present = true;
s_overlay.activation_armed_ms = lv_tick_get() + kActivationGuardMs;
}
bool hash_has_value(const uint8_t* hash)
{
if (!hash)
{
return false;
}
for (std::size_t index = 0; index < ::platform::ui::reticulum_call::kHashSize; ++index)
{
if (hash[index] != 0U)
{
return true;
}
}
return false;
}
void format_hash_prefix(const char* label,
const uint8_t* hash,
char* out,
std::size_t out_len)
{
std::snprintf(out,
out_len,
"%s %.2X%.2X%.2X%.2X",
label,
static_cast<unsigned>(hash[0]),
static_cast<unsigned>(hash[1]),
static_cast<unsigned>(hash[2]),
static_cast<unsigned>(hash[3]));
}
void format_peer_name(const ::platform::ui::reticulum_call::Snapshot& snapshot,
@@ -534,13 +644,22 @@ void format_peer_name(const ::platform::ui::reticulum_call::Snapshot& snapshot,
std::snprintf(out, out_len, "%s", snapshot.peer_name);
return;
}
std::snprintf(out,
out_len,
"%.2X%.2X%.2X%.2X",
static_cast<unsigned>(snapshot.peer_destination_hash[0]),
static_cast<unsigned>(snapshot.peer_destination_hash[1]),
static_cast<unsigned>(snapshot.peer_destination_hash[2]),
static_cast<unsigned>(snapshot.peer_destination_hash[3]));
if (hash_has_value(snapshot.peer_destination_hash))
{
format_hash_prefix("Dest", snapshot.peer_destination_hash, out, out_len);
return;
}
if (hash_has_value(snapshot.peer_identity_hash))
{
format_hash_prefix("ID", snapshot.peer_identity_hash, out, out_len);
return;
}
if (hash_has_value(snapshot.link_id))
{
format_hash_prefix("Link", snapshot.link_id, out, out_len);
return;
}
std::snprintf(out, out_len, "%s", "Unknown caller");
}
void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot)
@@ -600,6 +719,7 @@ void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot)
std::snprintf(status, sizeof(status), "%s", "Wi-Fi call.audio");
}
apply_label(s_overlay.status, status, kTextDim, caption_font());
update_shortcut_hint();
if (incoming)
{
@@ -633,6 +753,7 @@ void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot)
void tick()
{
::platform::ui::reticulum_call::service_ui_runtime();
const auto snapshot = ::platform::ui::reticulum_call::snapshot();
if (snapshot.realtime_phase ==
::platform::ui::reticulum_call::RealtimePhase::Idle)
@@ -0,0 +1,269 @@
#include "ui/widgets/reticulum_ping_overlay.h"
#include "lvgl.h"
#include "sys/clock.h"
#include "ui/widgets/foreground_operation_overlay.h"
#include <algorithm>
#include <array>
#include <cstdio>
#include <cstring>
namespace ui::widgets::reticulum_ping
{
namespace
{
namespace foreground = ::ui::widgets::foreground_operation;
constexpr std::uint32_t kFallbackTimeoutMs = 31000;
constexpr std::uint32_t kProgressHorizonMs = 30000;
constexpr std::uint32_t kProgressUpdateMs = 500;
constexpr std::uint32_t kResultHoldMs = 3200;
constexpr int kInitialProgress = 8;
constexpr int kWaitingProgressLimit = 92;
enum class TimerPurpose : std::uint8_t
{
None = 0,
AwaitResult,
DismissResult,
};
std::array<std::uint8_t, ::chat::kReticulumPeerHashSize> s_destination_hash{};
std::array<char, 96> s_loading_detail{};
lv_timer_t* s_timer = nullptr;
std::uint32_t s_generation = 0;
std::uint32_t s_wait_started_ms = 0;
TimerPurpose s_timer_purpose = TimerPurpose::None;
int s_wait_progress = kInitialProgress;
bool s_active = false;
void stop_timer()
{
if (s_timer)
{
lv_timer_del(s_timer);
s_timer = nullptr;
}
s_timer_purpose = TimerPurpose::None;
}
void publish_result(const char* title, const char* detail, int progress_percent)
{
foreground::publish(
foreground::make_snapshot(foreground::Slot::ReticulumPing,
foreground::Policy::OverlayImmediate,
foreground::Priority::Blocking,
title,
detail,
progress_percent,
nullptr,
s_generation));
}
void publish_loading(int progress_percent, foreground::Policy policy)
{
foreground::publish(
foreground::make_snapshot(foreground::Slot::ReticulumPing,
policy,
foreground::Priority::Blocking,
"Pinging",
s_loading_detail.data(),
progress_percent,
nullptr,
s_generation));
}
int waiting_progress(std::uint32_t elapsed_ms)
{
const std::uint32_t bounded = std::min(elapsed_ms, kProgressHorizonMs);
const std::uint32_t span = static_cast<std::uint32_t>(
kWaitingProgressLimit - kInitialProgress);
return kInitialProgress + static_cast<int>((span * bounded) / kProgressHorizonMs);
}
void timer_cb(lv_timer_t* timer)
{
if (s_timer_purpose == TimerPurpose::AwaitResult)
{
if (!s_active)
{
s_timer = nullptr;
s_timer_purpose = TimerPurpose::None;
lv_timer_del(timer);
return;
}
const std::uint32_t elapsed_ms = sys::millis_now() - s_wait_started_ms;
if (elapsed_ms >= kFallbackTimeoutMs)
{
s_timer = nullptr;
s_timer_purpose = TimerPurpose::None;
lv_timer_del(timer);
publish_result("No response", "Timeout after 30 s", 100);
s_timer_purpose = TimerPurpose::DismissResult;
s_timer = lv_timer_create(timer_cb, kResultHoldMs, nullptr);
if (s_timer)
{
lv_timer_set_repeat_count(s_timer, 1);
}
return;
}
const int progress = waiting_progress(elapsed_ms);
if (progress > s_wait_progress)
{
s_wait_progress = progress;
publish_loading(s_wait_progress, foreground::Policy::Overlay);
}
return;
}
const TimerPurpose purpose = s_timer_purpose;
s_timer = nullptr;
s_timer_purpose = TimerPurpose::None;
if (purpose == TimerPurpose::DismissResult)
{
foreground::clear(foreground::Slot::ReticulumPing, s_generation);
s_destination_hash.fill(0);
s_loading_detail.fill(0);
s_active = false;
}
}
void schedule_timer(TimerPurpose purpose, std::uint32_t delay_ms)
{
stop_timer();
s_timer_purpose = purpose;
s_timer = lv_timer_create(timer_cb, delay_ms, nullptr);
if (s_timer)
{
lv_timer_set_repeat_count(s_timer, 1);
}
}
void schedule_result_dismiss()
{
schedule_timer(TimerPurpose::DismissResult, kResultHoldMs);
}
void schedule_wait_progress()
{
stop_timer();
s_timer_purpose = TimerPurpose::AwaitResult;
s_timer = lv_timer_create(timer_cb, kProgressUpdateMs, nullptr);
if (s_timer)
{
lv_timer_set_repeat_count(s_timer, -1);
}
}
std::uint32_t next_generation()
{
++s_generation;
if (s_generation == 0)
{
++s_generation;
}
return s_generation;
}
} // namespace
void show_loading(
const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize],
const char* display_name)
{
if (!destination_hash)
{
return;
}
stop_timer();
next_generation();
std::memcpy(s_destination_hash.data(),
destination_hash,
s_destination_hash.size());
s_active = true;
const char* detail = display_name && display_name[0] != '\0'
? display_name
: "Waiting for response";
std::snprintf(s_loading_detail.data(), s_loading_detail.size(), "%s", detail);
s_wait_started_ms = sys::millis_now();
s_wait_progress = kInitialProgress;
publish_loading(s_wait_progress, foreground::Policy::OverlayImmediate);
schedule_wait_progress();
}
void show_send_failure(const char* detail)
{
if (!s_active)
{
return;
}
publish_result("Ping failed",
detail && detail[0] != '\0' ? detail : "Unable to send",
100);
schedule_result_dismiss();
}
void show_delivered(
const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize],
std::uint32_t latency_ms,
std::uint8_t hops)
{
if (!active_for(destination_hash))
{
return;
}
char detail[64] = {};
std::snprintf(detail,
sizeof(detail),
"Latency: %lu ms | Hops: %u",
static_cast<unsigned long>(latency_ms),
static_cast<unsigned>(hops));
publish_result("Reachable", detail, 100);
schedule_result_dismiss();
}
void show_timeout(
const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize],
std::uint32_t elapsed_ms)
{
if (!active_for(destination_hash))
{
return;
}
char detail[48] = {};
std::snprintf(detail,
sizeof(detail),
"Timeout after %lu s",
static_cast<unsigned long>((elapsed_ms + 999U) / 1000U));
publish_result("No response", detail, 100);
schedule_result_dismiss();
}
void dismiss()
{
stop_timer();
foreground::clear(foreground::Slot::ReticulumPing, s_generation);
s_destination_hash.fill(0);
s_loading_detail.fill(0);
s_active = false;
}
bool active_for(
const std::uint8_t destination_hash[::chat::kReticulumPeerHashSize])
{
return s_active && destination_hash &&
std::memcmp(s_destination_hash.data(),
destination_hash,
s_destination_hash.size()) == 0;
}
} // namespace ui::widgets::reticulum_ping
@@ -5,6 +5,10 @@
#include <cstdint>
#include <cstdio>
#if !defined(LV_FONT_MONTSERRAT_10) || !LV_FONT_MONTSERRAT_10
#define lv_font_montserrat_10 lv_font_montserrat_14
#endif
namespace ui::widgets::route_elevation_profile
{
namespace
@@ -6,6 +6,13 @@
#include <cstdint>
#include <cstdio>
#if !defined(LV_FONT_MONTSERRAT_10) || !LV_FONT_MONTSERRAT_10
#define lv_font_montserrat_10 lv_font_montserrat_14
#endif
#if !defined(LV_FONT_MONTSERRAT_12) || !LV_FONT_MONTSERRAT_12
#define lv_font_montserrat_12 lv_font_montserrat_14
#endif
namespace ui::widgets::route_image_strip
{
namespace