diff --git a/apps/esp32_lvgl/library.json b/apps/esp32_lvgl/library.json index 3185b9a5..6495f336 100644 --- a/apps/esp32_lvgl/library.json +++ b/apps/esp32_lvgl/library.json @@ -23,7 +23,6 @@ "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", - "-I../../modules/core_hostlink/include", "-I../../modules/core_team/include", "-I../../modules/ui_presentation/include", "-I../../modules/chat_presentation_adapters/include", diff --git a/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp index d83b1f4d..164719e8 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_arduino_loop_runtime.cpp @@ -8,6 +8,7 @@ #include "platform/esp/boards/board_runtime.h" #include "ui/app_runtime.h" #include "ui/loop_shell.h" +#include "ui/widgets/reticulum_call_overlay.h" #ifndef MAIN_TIMING_DEBUG #define MAIN_TIMING_DEBUG 0 @@ -75,6 +76,7 @@ void tick() { delay(ms); }; hooks.overlay_sleep_ms = 10; hooks.idle_sleep_ms = 2; + ui::widgets::reticulum_call_overlay::tick(); ui::loop_shell::tick(hooks); } diff --git a/apps/esp32_lvgl/src/esp32_lvgl_loop_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_loop_runtime.cpp index 735c6cef..b4e2cdee 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_loop_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_loop_runtime.cpp @@ -9,6 +9,7 @@ #include "freertos/task.h" #include "platform/esp/idf_common/wireless_companion/c6_companion.h" #include "ui/loop_shell.h" +#include "ui/widgets/reticulum_call_overlay.h" #endif namespace trailmate::apps::esp32_lvgl @@ -46,6 +47,7 @@ void loopTask(void* parameter) while (true) { platform::esp::idf_common::wireless_companion::c6_companion().poll(); + ui::widgets::reticulum_call_overlay::tick(); ui::loop_shell::tick(hooks); } } diff --git a/boards/t_display_p4/library.json b/boards/t_display_p4/library.json index f7c60267..1b716623 100644 --- a/boards/t_display_p4/library.json +++ b/boards/t_display_p4/library.json @@ -21,7 +21,6 @@ "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", - "-I../../modules/core_hostlink/include", "-I../../modules/core_team/include" ] } diff --git a/boards/tab5/library.json b/boards/tab5/library.json index dc438230..789302ca 100644 --- a/boards/tab5/library.json +++ b/boards/tab5/library.json @@ -21,7 +21,6 @@ "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", - "-I../../modules/core_hostlink/include", "-I../../modules/core_team/include" ] } diff --git a/boards/tdeck/library.json b/boards/tdeck/library.json index 5b40008b..c6f6735f 100644 --- a/boards/tdeck/library.json +++ b/boards/tdeck/library.json @@ -20,7 +20,6 @@ "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", - "-I../../modules/core_hostlink/include", "-I../../modules/core_team/include", "-I../../platform/esp/arduino_common/include" ] diff --git a/boards/tdeck_pro/library.json b/boards/tdeck_pro/library.json index df517ac3..a7028d69 100644 --- a/boards/tdeck_pro/library.json +++ b/boards/tdeck_pro/library.json @@ -20,7 +20,6 @@ "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", - "-I../../modules/core_hostlink/include", "-I../../modules/core_team/include", "-I../../platform/esp/arduino_common/include" ] diff --git a/boards/tlora_pager/library.json b/boards/tlora_pager/library.json index 5712e4a5..b2dd1d99 100644 --- a/boards/tlora_pager/library.json +++ b/boards/tlora_pager/library.json @@ -20,7 +20,6 @@ "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", - "-I../../modules/core_hostlink/include", "-I../../modules/core_team/include", "-I../../platform/esp/arduino_common/include" ] diff --git a/boards/twatchs3/library.json b/boards/twatchs3/library.json index e8d5136a..a590ee4d 100644 --- a/boards/twatchs3/library.json +++ b/boards/twatchs3/library.json @@ -20,7 +20,6 @@ "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", - "-I../../modules/core_hostlink/include", "-I../../modules/core_team/include", "-I../../platform/esp/arduino_common/include" ] diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index e3613c06..5462a045 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -20,19 +20,13 @@ set(TRAILMATE_ESP_IDF_PRODUCT_COMPOSITION_SOURCES "${TRAILMATE_ROOT}/modules/product_composition/src/target_build_binding.cpp" "${TRAILMATE_ROOT}/modules/product_composition/src/target_ux_binding.cpp") -set(TRAILMATE_ESP_IDF_CORE_HOSTLINK_SOURCES +set(TRAILMATE_ESP_IDF_C6_HOSTLINK_SOURCES "${TRAILMATE_ROOT}/modules/core_hostlink/src/c6_frame_codec_c.c" - "${TRAILMATE_ROOT}/modules/core_hostlink/src/c6_frame_codec.cpp" - "${TRAILMATE_ROOT}/modules/core_hostlink/src/hostlink_app_data_codec.cpp" - "${TRAILMATE_ROOT}/modules/core_hostlink/src/hostlink_codec.cpp" - "${TRAILMATE_ROOT}/modules/core_hostlink/src/hostlink_config_codec.cpp" - "${TRAILMATE_ROOT}/modules/core_hostlink/src/hostlink_event_codec.cpp" - "${TRAILMATE_ROOT}/modules/core_hostlink/src/hostlink_frame_router.cpp" - "${TRAILMATE_ROOT}/modules/core_hostlink/src/hostlink_service_codec.cpp" - "${TRAILMATE_ROOT}/modules/core_hostlink/src/hostlink_session.cpp") + "${TRAILMATE_ROOT}/modules/core_hostlink/src/c6_frame_codec.cpp") set(TRAILMATE_ESP_IDF_CORE_SYS_SOURCES "${TRAILMATE_ROOT}/modules/core_sys/src/app/app_facade_access.cpp" + "${TRAILMATE_ROOT}/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/sys/clock.cpp" "${TRAILMATE_ROOT}/modules/core_sys/src/platform/ui/timezone_profile.cpp") @@ -260,8 +254,6 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/gps/gps_page_shell.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/node_info/node_info_page_layout.cpp" - "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp" - "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_shell.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/settings/settings_page_input.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/screens/settings/settings_page_layout.cpp" @@ -317,6 +309,7 @@ set(TRAILMATE_ESP_IDF_UI_SHARED_SOURCES "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/ime/ime_widget.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/ime/pinyin_ime.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/map/map_viewport.cpp" + "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/system_notification.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/text_candidate_data.cpp" "${TRAILMATE_ROOT}/modules/ui_shared/src/ui/widgets/text_candidate_picker.cpp" @@ -377,7 +370,6 @@ set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_device_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_firmware_update_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_gps_runtime.cpp" - "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_hostlink_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_lora_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_orientation_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_pack_repository_runtime.cpp" diff --git a/builds/esp_idf/main/CMakeLists.txt b/builds/esp_idf/main/CMakeLists.txt index 57b9413c..8b479987 100644 --- a/builds/esp_idf/main/CMakeLists.txt +++ b/builds/esp_idf/main/CMakeLists.txt @@ -16,7 +16,7 @@ idf_component_register( "idf_entry.cpp" ${TRAILMATE_ESP_IDF_APP_SHELL_SOURCES} ${TRAILMATE_ESP_IDF_PRODUCT_COMPOSITION_SOURCES} - ${TRAILMATE_ESP_IDF_CORE_HOSTLINK_SOURCES} + ${TRAILMATE_ESP_IDF_C6_HOSTLINK_SOURCES} ${TRAILMATE_ESP_IDF_CORE_SYS_SOURCES} ${TRAILMATE_ESP_IDF_CORE_DEVICE_SOURCES} ${TRAILMATE_ESP_IDF_CORE_GPS_SOURCES} diff --git a/docs/RETICULUM_MODE_USER_GUIDE.md b/docs/RETICULUM_MODE_USER_GUIDE.md index 81c4b2e0..5755f80e 100644 --- a/docs/RETICULUM_MODE_USER_GUIDE.md +++ b/docs/RETICULUM_MODE_USER_GUIDE.md @@ -67,6 +67,30 @@ This is expected behavior. Seeing only a partial or slightly stale public announce view is preferable to making chat, the keypad UI, LoRa, or SD storage unreliable. +## Reticulum Calls + +On Pager-class hardware with a microphone, speaker, ES8311 audio codec, and +Wi-Fi gateway connectivity, Reticulum mode supports MeshChat-compatible +`call.audio` links. + +Calls are Wi-Fi-gateway only. Trail Mate does not place call audio on LoRa, +because the LoRa channel is too slow and too precious for realtime voice. The +Contacts action menu shows `Call` for Reticulum peers when the peer has a known +LXMF/Reticulum identity and the active adapter reports call support. + +During a call, Trail Mate enters Reticulum call realtime mode: + +- direct call audio, LinkIdentify, and hangup packets keep running through the + Reticulum Wi-Fi gateway +- Codec2 and the ES8311 audio path run at the call's realtime cadence +- MQTT, BLE runtime updates, GPS collection, HTTP/downloads, OTA, + pack/language/image fetches, public discovery, periodic announces, LoRa + polling, and non-urgent SD writes are paused or deferred + +Incoming calls wake the device into the call overlay even if the screen was +sleeping. The overlay lets you answer, decline, or hang up without entering the +normal app UI first. + ## Reticulum Identity When the active protocol is `Reticulum`, Settings > Network shows the local @@ -149,6 +173,12 @@ In Chat, press `/` or `s` to search conversation/contact names in the current Direct, Broadcast, or Team list. The Chat search is a conversation-list filter; it does not search message body history. +In Reticulum Contacts, selecting a peer opens the action menu. `Chat` opens text +chat, `Info` shows Reticulum identity/address details, and `Call` starts a +MeshChat-compatible audio call when the peer identity and Wi-Fi gateway path are +known. If the device needs a path first, it sends a Reticulum path request and +shows `Path requested`; retry after the peer's announce/path response arrives. + ## SD Card Requirement Reticulum directory data lives on the SD card. @@ -200,7 +230,8 @@ Each announce line has these fields: destination_hashidentity_hashaspectsourcefirst_seenlast_seenhopspath_responselocaldeliverypropagationdisplay_nameraw_packet_hexapp_data_hex ``` -`aspect` is usually `lxmf.delivery`, `lxmf.propagation`, or `unknown`. +`aspect` is usually `lxmf.delivery`, `lxmf.propagation`, `call.audio`, or +`unknown`. `source` is usually `runtime_rx` or `path_response`. `raw_packet_hex` stores the verified raw Reticulum announce packet so future tooling can inspect what was actually received. The runtime keeps the file bounded and updates an diff --git a/docs/wifi_access_resource_policy.md b/docs/wifi_access_resource_policy.md index 77d4c88f..bcf22cc6 100644 --- a/docs/wifi_access_resource_policy.md +++ b/docs/wifi_access_resource_policy.md @@ -55,12 +55,35 @@ Trail Mate is a low-frequency terminal, not a desktop router node. | Screen off | MQTT may connect and normally send/receive. | Gateway may connect and receive with expanded terminal budget. | Queued maintenance/background work may run, one transaction at a time. | | HTTP active | MQTT and Reticulum are reduced to minimal messaging budget. | Reduced to minimal gateway budget. | Exactly one HTTP transaction owns the HTTP resource. | | OTA active | Protocol pump budget is suspended. | Protocol pump budget is suspended. | OTA is exclusive. | +| Reticulum call active | Suspended. MQTT sockets must not connect, pump, publish, or consume RX budget. | Exclusive `call.audio` link traffic only. Discovery, public announce ingest, route/path background noise, and non-call link work are deferred. | Denied. No HTTP, OTA, catalog, pack, language, image, or route download may start. | The wake-protected phase shields the path from screen sleep to screen wake to entering the UI. It is intentionally short and prevents reconnect storms, TLS allocation, route image downloads, and catalog/update checks from causing visible UI stalls. +## Reticulum Call Realtime Mode + +Reticulum calls are a realtime resource mode, not a Contacts-page feature. When +a MeshChat-compatible `call.audio` link is dialing, ringing, active, or closing, +the device must protect the audio path first: + +- Wi-Fi access grants budget only to the Reticulum gateway client, and only for + the call link's Reticulum packets. +- LoRa polling, GPS collection, BLE runtime updates, public Reticulum discovery, + periodic announces, MQTT, HTTP, OTA, route/image downloads, language-pack + downloads, catalog refreshes, and SD-backed discovery persistence are paused, + denied, or coalesced. +- Codec2/ES8311 media owns the audio hardware for the call. Other background + hardware users must not start while the call overlay is active. +- Incoming calls may wake the screen and show the call overlay, but they must + not trigger unrelated Wi-Fi reconnect storms, SD scans, or UI-heavy refreshes. + +After hangup, the runtime may flush deferred SD/cache work and resume normal +low-frequency terminal budgets. Hangup signalling itself remains inside the +call realtime mode so the LinkClose packet can be sent before other Wi-Fi users +resume. + ## Enforcement The CI boundary job runs `scripts/check_wifi_access_policy.py`. The check fails diff --git a/modules/chat_presentation_adapters/include/chat_presentation_adapters/chat_message_mapper.h b/modules/chat_presentation_adapters/include/chat_presentation_adapters/chat_message_mapper.h index c6e67933..60727763 100644 --- a/modules/chat_presentation_adapters/include/chat_presentation_adapters/chat_message_mapper.h +++ b/modules/chat_presentation_adapters/include/chat_presentation_adapters/chat_message_mapper.h @@ -8,6 +8,7 @@ namespace chat_presentation_adapters ui::chat::MessageDeliveryState mapMessageStatus(chat::MessageStatus status); ui::chat::MessageFailureKind mapMessageFailure(chat::MessageStatus status); +ui::chat::MessageIngressTransport mapMessageIngressTransport(chat::RxOrigin origin); ui::chat::MessageRef toUiMessageRef(const chat::ChatMessage& message); } // namespace chat_presentation_adapters diff --git a/modules/chat_presentation_adapters/src/chat_message_mapper.cpp b/modules/chat_presentation_adapters/src/chat_message_mapper.cpp index c8a1efdb..e764e1b8 100644 --- a/modules/chat_presentation_adapters/src/chat_message_mapper.cpp +++ b/modules/chat_presentation_adapters/src/chat_message_mapper.cpp @@ -26,6 +26,22 @@ ui::chat::MessageFailureKind mapMessageFailure(chat::MessageStatus status) : ui::chat::MessageFailureKind::None; } +ui::chat::MessageIngressTransport mapMessageIngressTransport(chat::RxOrigin origin) +{ + switch (origin) + { + case chat::RxOrigin::Mesh: + case chat::RxOrigin::LoRa: + return ui::chat::MessageIngressTransport::LoRa; + case chat::RxOrigin::External: + case chat::RxOrigin::WiFi: + return ui::chat::MessageIngressTransport::WiFi; + case chat::RxOrigin::Unknown: + break; + } + return ui::chat::MessageIngressTransport::Unknown; +} + ui::chat::MessageRef toUiMessageRef(const chat::ChatMessage& message) { ui::chat::MessageRef out; diff --git a/modules/chat_presentation_adapters/tests/test_chat_message_mapper.cpp b/modules/chat_presentation_adapters/tests/test_chat_message_mapper.cpp index 5244a91d..a640070b 100644 --- a/modules/chat_presentation_adapters/tests/test_chat_message_mapper.cpp +++ b/modules/chat_presentation_adapters/tests/test_chat_message_mapper.cpp @@ -29,6 +29,20 @@ void messageStatusesMapToFailureKinds() ui::chat::MessageFailureKind::Unknown); } +void rxOriginsMapToIngressTransport() +{ + assert(chat_presentation_adapters::mapMessageIngressTransport(chat::RxOrigin::Unknown) == + ui::chat::MessageIngressTransport::Unknown); + assert(chat_presentation_adapters::mapMessageIngressTransport(chat::RxOrigin::Mesh) == + ui::chat::MessageIngressTransport::LoRa); + assert(chat_presentation_adapters::mapMessageIngressTransport(chat::RxOrigin::LoRa) == + ui::chat::MessageIngressTransport::LoRa); + assert(chat_presentation_adapters::mapMessageIngressTransport(chat::RxOrigin::External) == + ui::chat::MessageIngressTransport::WiFi); + assert(chat_presentation_adapters::mapMessageIngressTransport(chat::RxOrigin::WiFi) == + ui::chat::MessageIngressTransport::WiFi); +} + void incomingMessageMapsToRemoteStoredRef() { chat::ChatMessage message; @@ -82,6 +96,7 @@ int main() { messageStatusesMapToDeliveryStates(); messageStatusesMapToFailureKinds(); + rxOriginsMapToIngressTransport(); incomingMessageMapsToRemoteStoredRef(); queuedMessageMapsToLocalPendingRef(); storedLocalMessageMapsToLocalStoredRef(); diff --git a/modules/core_chat/include/chat/domain/chat_model.h b/modules/core_chat/include/chat/domain/chat_model.h index 4f992479..f9857ae9 100644 --- a/modules/core_chat/include/chat/domain/chat_model.h +++ b/modules/core_chat/include/chat/domain/chat_model.h @@ -62,6 +62,11 @@ class ChatModel */ void clearAll(); + /** + * @brief Clear one conversation and matching failed-message entries + */ + void clearConversation(const ConversationId& conv); + /** * @brief Set policy */ diff --git a/modules/core_chat/include/chat/domain/chat_types.h b/modules/core_chat/include/chat/domain/chat_types.h index e91cfe18..a0eeea72 100644 --- a/modules/core_chat/include/chat/domain/chat_types.h +++ b/modules/core_chat/include/chat/domain/chat_types.h @@ -208,8 +208,10 @@ enum class RxTimeSource : uint8_t enum class RxOrigin : uint8_t { Unknown = 0, - Mesh = 1, - External = 2 + Mesh = 1, // Legacy LoRa/radio-origin value. + External = 2, // Legacy internet/MQTT-origin value. + LoRa = 3, + WiFi = 4 }; /** @@ -356,6 +358,7 @@ struct ChatMessage int32_t geo_lat_e7; int32_t geo_lon_e7; ReticulumPeerIdentity reticulum_identity{}; + RxOrigin rx_origin; MessageStatus status; ChatMessage() : protocol(MeshProtocol::Meshtastic), @@ -365,6 +368,7 @@ struct ChatMessage has_geo(false), geo_lat_e7(0), geo_lon_e7(0), + rx_origin(RxOrigin::Unknown), status(MessageStatus::Incoming) {} }; diff --git a/modules/core_chat/include/chat/infra/mesh_adapter_router_core.h b/modules/core_chat/include/chat/infra/mesh_adapter_router_core.h index a581c221..eeac62c3 100644 --- a/modules/core_chat/include/chat/infra/mesh_adapter_router_core.h +++ b/modules/core_chat/include/chat/infra/mesh_adapter_router_core.h @@ -48,6 +48,8 @@ class MeshAdapterRouterCore : public IMeshAdapter bool hasPkiKey(NodeId dest) const override; bool triggerDiscoveryAction(MeshDiscoveryAction action) override; MeshActionResult triggerDiscoveryActionDetailed(MeshDiscoveryAction action) override; + MeshActionResult startReticulumAudioCall( + const ReticulumPeerIdentity& destination) override; void applyConfig(const MeshConfig& config) override; void setUserInfo(const char* long_name, const char* short_name) override; void setNetworkLimits(bool duty_cycle_enabled, uint8_t util_percent) override; diff --git a/modules/core_chat/include/chat/infra/reticulum/audio_call_wire.h b/modules/core_chat/include/chat/infra/reticulum/audio_call_wire.h new file mode 100644 index 00000000..193219eb --- /dev/null +++ b/modules/core_chat/include/chat/infra/reticulum/audio_call_wire.h @@ -0,0 +1,44 @@ +/** + * @file audio_call_wire.h + * @brief MeshChat-compatible Reticulum audio call protobuf subset. + */ + +#pragma once + +#include +#include + +namespace chat::reticulum::audio_call +{ + +enum class Codec2Mode : uint8_t +{ + Mode3200 = 0, + Mode2400 = 1, + Mode1600 = 2, + Mode1400 = 3, + Mode1300 = 4, + Mode1200 = 5, + Mode700C = 6, + Mode450 = 7, + Mode450Pwb = 8, +}; + +struct DecodedPayload +{ + Codec2Mode mode = Codec2Mode::Mode1200; + const uint8_t* encoded = nullptr; + std::size_t encoded_len = 0; +}; + +bool encodePayload(Codec2Mode mode, + const uint8_t* encoded, + std::size_t encoded_len, + uint8_t* out, + std::size_t* inout_len); + +bool decodePayload(const uint8_t* data, + std::size_t len, + DecodedPayload* out); + +} // namespace chat::reticulum::audio_call diff --git a/modules/core_chat/include/chat/ports/i_mesh_adapter.h b/modules/core_chat/include/chat/ports/i_mesh_adapter.h index 1b2cb702..9a6d5734 100644 --- a/modules/core_chat/include/chat/ports/i_mesh_adapter.h +++ b/modules/core_chat/include/chat/ports/i_mesh_adapter.h @@ -39,6 +39,7 @@ struct MeshCapabilities bool supports_meshcore_identity_keys = false; bool supports_meshcore_peer_secret_derivation = false; bool supports_meshcore_rich_trace_projection = false; + bool supports_reticulum_audio_call = false; }; struct ReticulumLocalIdentityInfo @@ -266,6 +267,16 @@ class IMeshAdapter : MeshActionResult::fail(MeshOperationFailure::Unsupported); } + /** + * @brief Start a MeshChat-compatible Reticulum call.audio Link call. + */ + virtual MeshActionResult startReticulumAudioCall( + const ReticulumPeerIdentity& destination) + { + (void)destination; + return MeshActionResult::fail(MeshOperationFailure::Unsupported); + } + /** * @brief Apply mesh configuration * @param config Configuration to apply diff --git a/modules/core_chat/include/chat/usecase/chat_service.h b/modules/core_chat/include/chat/usecase/chat_service.h index 7c11119a..74089153 100644 --- a/modules/core_chat/include/chat/usecase/chat_service.h +++ b/modules/core_chat/include/chat/usecase/chat_service.h @@ -81,6 +81,8 @@ class ChatService */ bool triggerDiscoveryAction(MeshDiscoveryAction action); MeshActionResult triggerDiscoveryActionDetailed(MeshDiscoveryAction action); + MeshActionResult startReticulumAudioCall( + const ReticulumPeerIdentity& destination); /** * @brief Switch to channel @@ -119,6 +121,11 @@ class ChatService */ void clearAllMessages(); + /** + * @brief Clear one conversation from model and backing store + */ + void clearConversation(const ConversationId& conv); + /** * @brief Process incoming messages (call from mesh task) */ diff --git a/modules/core_chat/include/chat/usecase/contact_service.h b/modules/core_chat/include/chat/usecase/contact_service.h index 1af7dad3..de497a44 100644 --- a/modules/core_chat/include/chat/usecase/contact_service.h +++ b/modules/core_chat/include/chat/usecase/contact_service.h @@ -72,6 +72,13 @@ class ContactService */ std::string getContactName(uint32_t node_id) const; + /** + * @brief Get display name for a Reticulum destination identity + * @param identity Reticulum peer destination identity + * @return Saved nickname, announce/long name, or empty when unknown + */ + std::string getReticulumContactName(const ReticulumPeerIdentity& identity) const; + /** * @brief Get all contacts (nodes with nicknames) */ diff --git a/modules/core_chat/src/domain/chat_model.cpp b/modules/core_chat/src/domain/chat_model.cpp index e18307ac..6906a902 100644 --- a/modules/core_chat/src/domain/chat_model.cpp +++ b/modules/core_chat/src/domain/chat_model.cpp @@ -144,6 +144,27 @@ void ChatModel::clearAll() next_sequence_ = 1; } +void ChatModel::clearConversation(const ConversationId& conv) +{ + auto it = conversations_.find(conv); + if (it == conversations_.end()) + { + return; + } + + const size_t removed_count = it->second.messages.size(); + conversations_.erase(it); + total_message_count_ -= std::min(total_message_count_, removed_count); + failed_messages_.erase( + std::remove_if(failed_messages_.begin(), + failed_messages_.end(), + [&](const ChatMessage& failed) + { + return conversationIdForMessage(failed) == conv; + }), + failed_messages_.end()); +} + std::vector ChatModel::getConversations() const { std::vector list; diff --git a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp index c1b1e39f..71b5f5a3 100644 --- a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp +++ b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp @@ -145,6 +145,29 @@ bool appendBin(const uint8_t* data, size_t len, uint8_t* out, size_t out_len, si return false; } +bool appendString(const uint8_t* data, size_t len, uint8_t* out, size_t out_len, size_t& used) +{ + if (len <= 0x1FU) + { + return appendByte(static_cast(0xA0U | (len & 0x1FU)), out, out_len, used) && + appendBytes(data, len, out, out_len, used); + } + if (len <= 0xFFU) + { + return appendByte(0xD9, out, out_len, used) && + appendByte(static_cast(len), out, out_len, used) && + appendBytes(data, len, out, out_len, used); + } + if (len <= 0xFFFFU) + { + return appendByte(0xDA, out, out_len, used) && + appendByte(static_cast((len >> 8) & 0xFFU), out, out_len, used) && + appendByte(static_cast(len & 0xFFU), out, out_len, used) && + appendBytes(data, len, out, out_len, used); + } + return false; +} + bool readByte(Cursor& cursor, uint8_t* out) { if (!out || !cursor.data || cursor.pos >= cursor.len) @@ -387,6 +410,16 @@ bool readBinary(Cursor& cursor, std::vector* out_data) } len = len8; } + else if (tag == 0xDA) + { + uint8_t hi = 0; + uint8_t lo = 0; + if (!readByte(cursor, &hi) || !readByte(cursor, &lo)) + { + return false; + } + len = static_cast((static_cast(hi) << 8) | lo); + } else { return false; @@ -561,7 +594,7 @@ bool packPeerAnnounceAppData(const char* display_name, return false; } } - else if (!appendBin(name_bytes, name_len, out_data, *inout_len, used)) + else if (!appendString(name_bytes, name_len, out_data, *inout_len, used)) { return false; } diff --git a/modules/core_chat/src/infra/mesh_adapter_router_core.cpp b/modules/core_chat/src/infra/mesh_adapter_router_core.cpp index 4b9f9e17..bef729a1 100644 --- a/modules/core_chat/src/infra/mesh_adapter_router_core.cpp +++ b/modules/core_chat/src/infra/mesh_adapter_router_core.cpp @@ -221,6 +221,17 @@ MeshActionResult MeshAdapterRouterCore::triggerDiscoveryActionDetailed(MeshDisco return backend->triggerDiscoveryActionDetailed(action); } +MeshActionResult MeshAdapterRouterCore::startReticulumAudioCall( + const ReticulumPeerIdentity& destination) +{ + IMeshAdapter* backend = activeBackend(); + if (!backend) + { + return MeshActionResult::fail(MeshOperationFailure::NotReady); + } + return backend->startReticulumAudioCall(destination); +} + void MeshAdapterRouterCore::applyConfig(const MeshConfig& config) { IMeshAdapter* backend = activeBackend(); diff --git a/modules/core_chat/src/infra/reticulum/audio_call_wire.cpp b/modules/core_chat/src/infra/reticulum/audio_call_wire.cpp new file mode 100644 index 00000000..3772b367 --- /dev/null +++ b/modules/core_chat/src/infra/reticulum/audio_call_wire.cpp @@ -0,0 +1,325 @@ +/** + * @file audio_call_wire.cpp + * @brief MeshChat-compatible Reticulum audio call protobuf subset. + */ + +#include "chat/infra/reticulum/audio_call_wire.h" + +#include + +namespace chat::reticulum::audio_call +{ +namespace +{ + +constexpr uint8_t kWireVarint = 0; +constexpr uint8_t kWireLengthDelimited = 2; +constexpr uint32_t kFieldAudioData = 1; +constexpr uint32_t kFieldCodec2Audio = 1; +constexpr uint32_t kFieldCodec2Mode = 1; +constexpr uint32_t kFieldCodec2Encoded = 2; + +std::size_t varintSize(uint64_t value) +{ + std::size_t len = 1; + while (value >= 0x80U) + { + value >>= 7U; + ++len; + } + return len; +} + +bool writeByte(uint8_t byte, uint8_t* out, std::size_t capacity, std::size_t& used) +{ + if (!out || used >= capacity) + { + return false; + } + out[used++] = byte; + return true; +} + +bool writeVarint(uint64_t value, uint8_t* out, std::size_t capacity, std::size_t& used) +{ + do + { + uint8_t byte = static_cast(value & 0x7FU); + value >>= 7U; + if (value != 0) + { + byte |= 0x80U; + } + if (!writeByte(byte, out, capacity, used)) + { + return false; + } + } while (value != 0); + return true; +} + +bool writeKey(uint32_t field, uint8_t wire_type, uint8_t* out, std::size_t capacity, std::size_t& used) +{ + return writeVarint((static_cast(field) << 3U) | wire_type, + out, + capacity, + used); +} + +bool writeBytes(const uint8_t* data, + std::size_t len, + uint8_t* out, + std::size_t capacity, + std::size_t& used) +{ + if ((!data && len != 0) || !out || used > capacity || len > (capacity - used)) + { + return false; + } + if (len != 0) + { + std::memcpy(out + used, data, len); + } + used += len; + return true; +} + +bool readVarint(const uint8_t* data, + std::size_t len, + std::size_t& offset, + uint64_t* out) +{ + if (!data || !out) + { + return false; + } + + uint64_t value = 0; + uint8_t shift = 0; + while (offset < len && shift < 64) + { + const uint8_t byte = data[offset++]; + value |= static_cast(byte & 0x7FU) << shift; + if ((byte & 0x80U) == 0) + { + *out = value; + return true; + } + shift = static_cast(shift + 7U); + } + return false; +} + +bool skipField(uint8_t wire_type, + const uint8_t* data, + std::size_t len, + std::size_t& offset) +{ + if (wire_type == kWireVarint) + { + uint64_t ignored = 0; + return readVarint(data, len, offset, &ignored); + } + if (wire_type == kWireLengthDelimited) + { + uint64_t field_len = 0; + if (!readVarint(data, len, offset, &field_len)) + { + return false; + } + if (field_len > (len - offset)) + { + return false; + } + offset += static_cast(field_len); + return true; + } + return false; +} + +bool decodeCodec2Audio(const uint8_t* data, + std::size_t len, + DecodedPayload* out) +{ + if (!data || !out) + { + return false; + } + + bool has_mode = false; + bool has_encoded = false; + std::size_t offset = 0; + while (offset < len) + { + uint64_t key = 0; + if (!readVarint(data, len, offset, &key)) + { + return false; + } + const uint32_t field = static_cast(key >> 3U); + const uint8_t wire_type = static_cast(key & 0x07U); + + if (field == kFieldCodec2Mode && wire_type == kWireVarint) + { + uint64_t mode = 0; + if (!readVarint(data, len, offset, &mode) || mode > 8U) + { + return false; + } + out->mode = static_cast(mode); + has_mode = true; + } + else if (field == kFieldCodec2Encoded && wire_type == kWireLengthDelimited) + { + uint64_t encoded_len = 0; + if (!readVarint(data, len, offset, &encoded_len) || + encoded_len > (len - offset)) + { + return false; + } + out->encoded = data + offset; + out->encoded_len = static_cast(encoded_len); + offset += static_cast(encoded_len); + has_encoded = true; + } + else if (!skipField(wire_type, data, len, offset)) + { + return false; + } + } + return has_mode && has_encoded && out->encoded != nullptr && out->encoded_len != 0; +} + +bool decodeAudioData(const uint8_t* data, + std::size_t len, + DecodedPayload* out) +{ + if (!data || !out) + { + return false; + } + + std::size_t offset = 0; + while (offset < len) + { + uint64_t key = 0; + if (!readVarint(data, len, offset, &key)) + { + return false; + } + const uint32_t field = static_cast(key >> 3U); + const uint8_t wire_type = static_cast(key & 0x07U); + if (field == kFieldCodec2Audio && wire_type == kWireLengthDelimited) + { + uint64_t message_len = 0; + if (!readVarint(data, len, offset, &message_len) || + message_len > (len - offset)) + { + return false; + } + return decodeCodec2Audio(data + offset, + static_cast(message_len), + out); + } + if (!skipField(wire_type, data, len, offset)) + { + return false; + } + } + return false; +} + +} // namespace + +bool encodePayload(Codec2Mode mode, + const uint8_t* encoded, + std::size_t encoded_len, + uint8_t* out, + std::size_t* inout_len) +{ + if ((!encoded && encoded_len != 0) || encoded_len == 0 || !out || !inout_len) + { + return false; + } + + const std::size_t capacity = *inout_len; + const std::size_t codec2_len = + varintSize((kFieldCodec2Mode << 3U) | kWireVarint) + + varintSize(static_cast(mode)) + + varintSize((kFieldCodec2Encoded << 3U) | kWireLengthDelimited) + + varintSize(encoded_len) + + encoded_len; + const std::size_t audio_data_len = + varintSize((kFieldCodec2Audio << 3U) | kWireLengthDelimited) + + varintSize(codec2_len) + + codec2_len; + const std::size_t total_len = + varintSize((kFieldAudioData << 3U) | kWireLengthDelimited) + + varintSize(audio_data_len) + + audio_data_len; + + if (total_len > capacity) + { + *inout_len = total_len; + return false; + } + + std::size_t used = 0; + if (!writeKey(kFieldAudioData, kWireLengthDelimited, out, capacity, used) || + !writeVarint(audio_data_len, out, capacity, used) || + !writeKey(kFieldCodec2Audio, kWireLengthDelimited, out, capacity, used) || + !writeVarint(codec2_len, out, capacity, used) || + !writeKey(kFieldCodec2Mode, kWireVarint, out, capacity, used) || + !writeVarint(static_cast(mode), out, capacity, used) || + !writeKey(kFieldCodec2Encoded, kWireLengthDelimited, out, capacity, used) || + !writeVarint(encoded_len, out, capacity, used) || + !writeBytes(encoded, encoded_len, out, capacity, used)) + { + return false; + } + + *inout_len = used; + return true; +} + +bool decodePayload(const uint8_t* data, + std::size_t len, + DecodedPayload* out) +{ + if (!data || len == 0 || !out) + { + return false; + } + + *out = DecodedPayload{}; + std::size_t offset = 0; + while (offset < len) + { + uint64_t key = 0; + if (!readVarint(data, len, offset, &key)) + { + return false; + } + const uint32_t field = static_cast(key >> 3U); + const uint8_t wire_type = static_cast(key & 0x07U); + if (field == kFieldAudioData && wire_type == kWireLengthDelimited) + { + uint64_t message_len = 0; + if (!readVarint(data, len, offset, &message_len) || + message_len > (len - offset)) + { + return false; + } + return decodeAudioData(data + offset, + static_cast(message_len), + out); + } + if (!skipField(wire_type, data, len, offset)) + { + return false; + } + } + return false; +} + +} // namespace chat::reticulum::audio_call diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index 8af059c4..5fc86328 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -321,6 +321,16 @@ MeshActionResult ChatService::triggerDiscoveryActionDetailed(MeshDiscoveryAction return adapter_.triggerDiscoveryActionDetailed(action); } +MeshActionResult ChatService::startReticulumAudioCall( + const ReticulumPeerIdentity& destination) +{ + if (active_protocol_ != MeshProtocol::Reticulum) + { + return MeshActionResult::fail(MeshOperationFailure::Unsupported); + } + return adapter_.startReticulumAudioCall(destination); +} + void ChatService::switchChannel(ChannelId channel) { current_channel_ = channel; @@ -417,6 +427,13 @@ void ChatService::clearAllMessages() recent_incoming_.clear(); } +void ChatService::clearConversation(const ConversationId& conv) +{ + model_.clearConversation(conv); + store_.clearConversation(conv); + recent_incoming_.clear(); +} + void ChatService::markConversationRead(const ConversationId& conv) { model_.markRead(conv); @@ -437,6 +454,7 @@ void ChatService::processIncoming() msg.timestamp = now_message_timestamp(); msg.text = incoming_text.text; msg.reticulum_identity = incoming_text.reticulum_identity; + msg.rx_origin = incoming_text.rx_meta.origin; msg.status = MessageStatus::Incoming; const ReticulumPeerIdentity* incoming_identity = diff --git a/modules/core_chat/src/usecase/contact_service.cpp b/modules/core_chat/src/usecase/contact_service.cpp index e7d39326..345e37cd 100644 --- a/modules/core_chat/src/usecase/contact_service.cpp +++ b/modules/core_chat/src/usecase/contact_service.cpp @@ -176,6 +176,49 @@ std::string ContactService::getContactName(uint32_t node_id) const return std::string(); } +std::string ContactService::getReticulumContactName( + const ReticulumPeerIdentity& identity) const +{ + if (!hasReticulumDestinationIdentity(identity)) + { + return std::string(); + } + + buildCache(); + for (const auto& node : cached_nodes_) + { + if (!sameReticulumDestinationHash(node.reticulum_identity, identity)) + { + continue; + } + + const std::string nickname = contact_store_.getNickname(node.node_id); + if (!nickname.empty()) + { + return nickname; + } + return preferred_node_name(node); + } + + const auto& entries = node_store_.getEntries(); + for (const auto& entry : entries) + { + if (!sameReticulumDestinationHash(entry.reticulum_identity, identity)) + { + continue; + } + + const std::string nickname = contact_store_.getNickname(entry.node_id); + if (!nickname.empty()) + { + return nickname; + } + return preferred_node_name(entry.short_name, entry.long_name); + } + + return std::string(); +} + std::vector ContactService::getContacts() const { buildCache(); diff --git a/modules/core_chat/tests/test_chat_service_resend.cpp b/modules/core_chat/tests/test_chat_service_resend.cpp index 01d055d8..a43c56cb 100644 --- a/modules/core_chat/tests/test_chat_service_resend.cpp +++ b/modules/core_chat/tests/test_chat_service_resend.cpp @@ -363,6 +363,52 @@ int main() assert(!service.resendFailed(88)); assert(mesh.send_count == before_cross_protocol_retry); + { + chat::ChatModel clear_model; + FakeMeshAdapter clear_mesh; + chat::RamStore clear_store; + chat::ChatService clear_service(clear_model, clear_mesh, clear_store); + + clear_mesh.next_send_ok = false; + clear_mesh.next_msg_id = 900; + const chat::MeshSendResult failed_clear_target = + clear_service.sendTextDetailed(chat::ChannelId::PRIMARY, + "remove me", + 0x01020304); + assert(!failed_clear_target.ok); + + clear_mesh.next_send_ok = true; + clear_mesh.next_msg_id = 901; + const chat::MeshSendResult kept_send = + clear_service.sendTextDetailed(chat::ChannelId::PRIMARY, + "keep me", + 0x05060708); + assert(kept_send.ok); + + const chat::ConversationId remove_conv(chat::ChannelId::PRIMARY, + 0x01020304, + chat::MeshProtocol::Meshtastic); + const chat::ConversationId keep_conv(chat::ChannelId::PRIMARY, + 0x05060708, + chat::MeshProtocol::Meshtastic); + + assert(clear_service.getRecentMessages(remove_conv, 10).size() == 1); + assert(clear_service.getRecentMessages(keep_conv, 10).size() == 1); + assert(clear_model.getFailedMessages().size() == 1); + + clear_service.clearConversation(remove_conv); + assert(clear_service.getRecentMessages(remove_conv, 10).empty()); + assert(clear_service.getRecentMessages(keep_conv, 10).size() == 1); + assert(clear_model.getFailedMessages().empty()); + + size_t total_after_clear = 0; + const auto remaining = + clear_service.getConversations(0, 0, &total_after_clear); + assert(total_after_clear == 1); + assert(remaining.size() == 1); + assert(remaining.front().id == keep_conv); + } + { chat::ChatModel incoming_model; FakeMeshAdapter incoming_mesh; diff --git a/modules/core_sys/include/platform/ui/reticulum_call_runtime.h b/modules/core_sys/include/platform/ui/reticulum_call_runtime.h new file mode 100644 index 00000000..f2bf9655 --- /dev/null +++ b/modules/core_sys/include/platform/ui/reticulum_call_runtime.h @@ -0,0 +1,100 @@ +/** + * @file reticulum_call_runtime.h + * @brief Shared Reticulum call state, UI facts, and audio packet queues. + */ + +#pragma once + +#include +#include + +namespace platform::ui::reticulum_call +{ + +constexpr std::size_t kHashSize = 16; +constexpr std::size_t kDisplayNameSize = 32; +constexpr std::size_t kAudioPacketMaxLen = 256; + +enum class State : uint8_t +{ + Idle = 0, + Incoming = 1, + Outgoing = 2, + Active = 3, + Ended = 4, + Failed = 5, +}; + +struct Peer +{ + uint8_t link_id[kHashSize] = {}; + uint8_t destination_hash[kHashSize] = {}; + uint8_t identity_hash[kHashSize] = {}; + const char* display_name = nullptr; + bool incoming = false; +}; + +struct Snapshot +{ + bool supported = true; + bool media_supported = false; + bool wifi_ready = false; + State state = State::Idle; + bool incoming = false; + bool accepted = false; + bool media_active = false; + uint8_t link_id[kHashSize] = {}; + uint8_t peer_destination_hash[kHashSize] = {}; + uint8_t peer_identity_hash[kHashSize] = {}; + char peer_name[kDisplayNameSize] = {}; + uint32_t updated_ms = 0; + uint32_t rx_packets = 0; + uint32_t tx_packets = 0; + uint32_t rx_dropped = 0; + uint32_t tx_dropped = 0; +}; + +struct AudioPacket +{ + uint8_t link_id[kHashSize] = {}; + uint8_t data[kAudioPacketMaxLen] = {}; + std::size_t len = 0; +}; + +struct MediaHooks +{ + bool (*is_supported)() = nullptr; + bool (*start)() = nullptr; + void (*stop)() = nullptr; +}; + +void set_media_hooks(const MediaHooks& hooks); +void set_wifi_ready(bool ready); + +bool begin_incoming(const Peer& peer); +bool begin_outgoing(const Peer& peer); +void update_peer(const Peer& peer); +void mark_link_active(const uint8_t link_id[kHashSize]); +void notify_link_closed(const uint8_t link_id[kHashSize]); + +bool accept(); +void reject(); +void hangup(); +bool consume_hangup_request(uint8_t out_link_id[kHashSize]); + +Snapshot snapshot(); +State state(); +bool media_should_run(); +bool realtime_mode_active(); + +bool enqueue_inbound_audio(const uint8_t link_id[kHashSize], + const uint8_t* data, + std::size_t len); +bool dequeue_inbound_audio(AudioPacket* out); +bool enqueue_outbound_audio(const uint8_t link_id[kHashSize], + const uint8_t* data, + std::size_t len); +bool dequeue_outbound_audio(AudioPacket* out); +void note_tx_sent(); + +} // namespace platform::ui::reticulum_call diff --git a/modules/core_sys/include/platform/ui/reticulum_directory_runtime.h b/modules/core_sys/include/platform/ui/reticulum_directory_runtime.h index df799559..6cb9cf2f 100644 --- a/modules/core_sys/include/platform/ui/reticulum_directory_runtime.h +++ b/modules/core_sys/include/platform/ui/reticulum_directory_runtime.h @@ -15,6 +15,7 @@ enum class AnnounceAspect : uint8_t Unknown = 0, LxmfDelivery = 1, LxmfPropagation = 2, + CallAudio = 3, }; enum class EntrySource : uint8_t diff --git a/modules/core_sys/include/platform/ui/wifi_access_runtime.h b/modules/core_sys/include/platform/ui/wifi_access_runtime.h index e88648ea..f0529aaa 100644 --- a/modules/core_sys/include/platform/ui/wifi_access_runtime.h +++ b/modules/core_sys/include/platform/ui/wifi_access_runtime.h @@ -55,6 +55,7 @@ enum class Decision : std::uint8_t DeferredForWake, DeferredForScreenOn, OtaExclusive, + CallExclusive, }; struct Request diff --git a/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp b/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp new file mode 100644 index 00000000..f4b6ef0a --- /dev/null +++ b/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp @@ -0,0 +1,458 @@ +/** + * @file reticulum_call_runtime.cpp + * @brief Shared Reticulum call state, UI facts, and audio packet queues. + */ + +#include "platform/ui/reticulum_call_runtime.h" + +#include "platform/ui/screen_runtime.h" +#include "sys/clock.h" +#include "sys/ringbuf.h" + +#include +#include + +namespace platform::ui::reticulum_call +{ +namespace +{ + +constexpr std::size_t kQueueDepth = 8; + +struct RuntimeState +{ + std::mutex mutex; + Snapshot snapshot; + MediaHooks hooks; + bool hangup_requested = false; + sys::RingBuffer inbound; + sys::RingBuffer outbound; +}; + +RuntimeState s_state; + +bool hashes_equal(const uint8_t* a, const uint8_t* b) +{ + return a && b && std::memcmp(a, b, kHashSize) == 0; +} + +bool hash_is_empty(const uint8_t* hash) +{ + if (!hash) + { + return true; + } + for (std::size_t index = 0; index < kHashSize; ++index) + { + if (hash[index] != 0) + { + return false; + } + } + return true; +} + +void copy_hash(uint8_t* out, const uint8_t* in) +{ + if (out && in) + { + std::memcpy(out, in, kHashSize); + } +} + +void copy_name(char* out, std::size_t out_len, const char* in) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (!in) + { + return; + } + std::strncpy(out, in, out_len - 1); + out[out_len - 1] = '\0'; +} + +uint32_t now_ms() +{ + return sys::millis_now(); +} + +void clear_queues_locked() +{ + s_state.inbound.clear(); + s_state.outbound.clear(); +} + +void apply_peer_locked(const Peer& peer) +{ + copy_hash(s_state.snapshot.link_id, peer.link_id); + if (!hash_is_empty(peer.destination_hash)) + { + copy_hash(s_state.snapshot.peer_destination_hash, peer.destination_hash); + } + if (!hash_is_empty(peer.identity_hash)) + { + copy_hash(s_state.snapshot.peer_identity_hash, peer.identity_hash); + } + if (peer.display_name && peer.display_name[0] != '\0') + { + copy_name(s_state.snapshot.peer_name, + sizeof(s_state.snapshot.peer_name), + peer.display_name); + } + s_state.snapshot.updated_ms = now_ms(); +} + +void stop_media_outside_lock(MediaHooks hooks) +{ + if (hooks.stop) + { + hooks.stop(); + } +} + +bool start_media_if_needed() +{ + MediaHooks hooks{}; + bool should_start = false; + { + std::lock_guard lock(s_state.mutex); + hooks = s_state.hooks; + s_state.snapshot.media_supported = + hooks.is_supported ? hooks.is_supported() : false; + should_start = s_state.snapshot.accepted && + s_state.snapshot.state == State::Active && + !s_state.snapshot.media_active; + } + if (!should_start) + { + return true; + } + if (!hooks.start || !hooks.start()) + { + std::lock_guard lock(s_state.mutex); + s_state.snapshot.state = State::Failed; + s_state.snapshot.media_active = false; + s_state.snapshot.updated_ms = now_ms(); + return false; + } + std::lock_guard lock(s_state.mutex); + s_state.snapshot.media_active = true; + s_state.snapshot.updated_ms = now_ms(); + return true; +} + +void close_call(State final_state, bool request_remote_close) +{ + MediaHooks hooks{}; + bool stop_media = false; + { + std::lock_guard lock(s_state.mutex); + hooks = s_state.hooks; + stop_media = s_state.snapshot.media_active; + s_state.snapshot.media_active = false; + s_state.snapshot.accepted = false; + s_state.snapshot.state = final_state; + s_state.snapshot.updated_ms = now_ms(); + s_state.hangup_requested = request_remote_close && + !hash_is_empty(s_state.snapshot.link_id); + clear_queues_locked(); + } + if (stop_media) + { + stop_media_outside_lock(hooks); + } +} + +bool enqueue_packet(sys::RingBuffer& queue, + const uint8_t link_id[kHashSize], + const uint8_t* data, + std::size_t len, + bool inbound) +{ + if (!link_id || !data || len == 0 || len > kAudioPacketMaxLen) + { + return false; + } + std::lock_guard lock(s_state.mutex); + if (!hashes_equal(link_id, s_state.snapshot.link_id)) + { + return false; + } + + AudioPacket packet{}; + copy_hash(packet.link_id, link_id); + std::memcpy(packet.data, data, len); + packet.len = len; + bool dropped = false; + queue.pushDropOldest(packet, &dropped); + if (inbound) + { + ++s_state.snapshot.rx_packets; + if (dropped) + { + ++s_state.snapshot.rx_dropped; + } + } + else if (dropped) + { + ++s_state.snapshot.tx_dropped; + } + s_state.snapshot.updated_ms = now_ms(); + return true; +} + +} // namespace + +void set_media_hooks(const MediaHooks& hooks) +{ + std::lock_guard lock(s_state.mutex); + s_state.hooks = hooks; + s_state.snapshot.media_supported = + hooks.is_supported ? hooks.is_supported() : false; +} + +void set_wifi_ready(bool ready) +{ + std::lock_guard lock(s_state.mutex); + s_state.snapshot.wifi_ready = ready; + s_state.snapshot.updated_ms = now_ms(); +} + +bool begin_incoming(const Peer& peer) +{ + if (hash_is_empty(peer.link_id)) + { + return false; + } + { + std::lock_guard lock(s_state.mutex); + if (s_state.snapshot.state == State::Active || + s_state.snapshot.state == State::Incoming || + s_state.snapshot.state == State::Outgoing) + { + return hashes_equal(s_state.snapshot.link_id, peer.link_id); + } + s_state.snapshot = Snapshot{}; + s_state.snapshot.supported = true; + s_state.snapshot.incoming = true; + s_state.snapshot.state = State::Incoming; + s_state.snapshot.media_supported = + s_state.hooks.is_supported ? s_state.hooks.is_supported() : false; + apply_peer_locked(peer); + clear_queues_locked(); + s_state.hangup_requested = false; + } + screen::wake_saver(); + return true; +} + +bool begin_outgoing(const Peer& peer) +{ + if (hash_is_empty(peer.link_id) || hash_is_empty(peer.destination_hash)) + { + return false; + } + std::lock_guard lock(s_state.mutex); + if (s_state.snapshot.state == State::Active || + s_state.snapshot.state == State::Incoming || + s_state.snapshot.state == State::Outgoing) + { + return false; + } + s_state.snapshot = Snapshot{}; + s_state.snapshot.supported = true; + s_state.snapshot.incoming = false; + s_state.snapshot.accepted = false; + s_state.snapshot.state = State::Outgoing; + s_state.snapshot.media_supported = + s_state.hooks.is_supported ? s_state.hooks.is_supported() : false; + apply_peer_locked(peer); + clear_queues_locked(); + s_state.hangup_requested = false; + return true; +} + +void update_peer(const Peer& peer) +{ + if (hash_is_empty(peer.link_id)) + { + return; + } + std::lock_guard lock(s_state.mutex); + if (!hashes_equal(s_state.snapshot.link_id, peer.link_id)) + { + return; + } + apply_peer_locked(peer); +} + +void mark_link_active(const uint8_t link_id[kHashSize]) +{ + if (hash_is_empty(link_id)) + { + return; + } + bool should_start = false; + { + std::lock_guard lock(s_state.mutex); + if (!hashes_equal(s_state.snapshot.link_id, link_id)) + { + return; + } + if (s_state.snapshot.state == State::Outgoing) + { + s_state.snapshot.accepted = true; + s_state.snapshot.state = State::Active; + should_start = true; + } + else if (s_state.snapshot.state == State::Incoming && + s_state.snapshot.accepted) + { + s_state.snapshot.state = State::Active; + should_start = true; + } + s_state.snapshot.updated_ms = now_ms(); + } + if (should_start) + { + (void)start_media_if_needed(); + } +} + +void notify_link_closed(const uint8_t link_id[kHashSize]) +{ + if (hash_is_empty(link_id)) + { + return; + } + { + std::lock_guard lock(s_state.mutex); + if (!hashes_equal(s_state.snapshot.link_id, link_id)) + { + return; + } + } + close_call(State::Ended, false); +} + +bool accept() +{ + { + std::lock_guard lock(s_state.mutex); + if (s_state.snapshot.state != State::Incoming && + s_state.snapshot.state != State::Active) + { + return false; + } + s_state.snapshot.accepted = true; + s_state.snapshot.state = State::Active; + s_state.snapshot.updated_ms = now_ms(); + } + return start_media_if_needed(); +} + +void reject() +{ + close_call(State::Ended, true); +} + +void hangup() +{ + close_call(State::Ended, true); +} + +bool consume_hangup_request(uint8_t out_link_id[kHashSize]) +{ + if (!out_link_id) + { + return false; + } + std::lock_guard lock(s_state.mutex); + if (!s_state.hangup_requested || + hash_is_empty(s_state.snapshot.link_id)) + { + return false; + } + copy_hash(out_link_id, s_state.snapshot.link_id); + s_state.hangup_requested = false; + return true; +} + +Snapshot snapshot() +{ + std::lock_guard lock(s_state.mutex); + Snapshot copy = s_state.snapshot; + copy.media_supported = + s_state.hooks.is_supported ? s_state.hooks.is_supported() : false; + return copy; +} + +State state() +{ + std::lock_guard lock(s_state.mutex); + return s_state.snapshot.state; +} + +bool media_should_run() +{ + std::lock_guard lock(s_state.mutex); + return s_state.snapshot.state == State::Active && + s_state.snapshot.accepted && + s_state.snapshot.media_active; +} + +bool realtime_mode_active() +{ + std::lock_guard lock(s_state.mutex); + return s_state.hangup_requested || + s_state.snapshot.state == State::Incoming || + s_state.snapshot.state == State::Outgoing || + s_state.snapshot.state == State::Active; +} + +bool enqueue_inbound_audio(const uint8_t link_id[kHashSize], + const uint8_t* data, + std::size_t len) +{ + return enqueue_packet(s_state.inbound, link_id, data, len, true); +} + +bool dequeue_inbound_audio(AudioPacket* out) +{ + if (!out) + { + return false; + } + std::lock_guard lock(s_state.mutex); + return s_state.inbound.popOldest(out); +} + +bool enqueue_outbound_audio(const uint8_t link_id[kHashSize], + const uint8_t* data, + std::size_t len) +{ + return enqueue_packet(s_state.outbound, link_id, data, len, false); +} + +bool dequeue_outbound_audio(AudioPacket* out) +{ + if (!out) + { + return false; + } + std::lock_guard lock(s_state.mutex); + return s_state.outbound.popOldest(out); +} + +void note_tx_sent() +{ + std::lock_guard lock(s_state.mutex); + ++s_state.snapshot.tx_packets; + s_state.snapshot.updated_ms = now_ms(); +} + +} // namespace platform::ui::reticulum_call diff --git a/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h b/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h index 222ef79f..84407ba5 100644 --- a/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h +++ b/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h @@ -40,6 +40,13 @@ enum class MessageDeliveryState : uint8_t Unknown, }; +enum class MessageIngressTransport : uint8_t +{ + Unknown, + LoRa, + WiFi, +}; + enum class MessageFailureKind : uint8_t { None, diff --git a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h index cf0a70f4..a63ddf80 100644 --- a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h +++ b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h @@ -87,6 +87,7 @@ struct MessageRow bool outgoing = false; MessageDeliveryState delivery = MessageDeliveryState::Unknown; MessageFailureKind failure = MessageFailureKind::None; + MessageIngressTransport ingress_transport = MessageIngressTransport::Unknown; uint32_t sender_node_id = 0; ui::FixedText<160> text; @@ -161,6 +162,7 @@ inline void resetMessageRow(MessageRow& row) row.outgoing = false; row.delivery = MessageDeliveryState::Unknown; row.failure = MessageFailureKind::None; + row.ingress_transport = MessageIngressTransport::Unknown; row.sender_node_id = 0; row.text.clear(); row.time_label.clear(); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h b/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h index 4915250f..5a45c6e9 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_message_list_components.h @@ -29,6 +29,8 @@ class ChatMessageListScreen enum class ActionIntent { SelectConversation, + ShowInfo, + DeleteConversation, Back }; @@ -60,6 +62,7 @@ class ChatMessageListScreen lv_obj_t* getItemButton(size_t index) const; int getSelectedIndex() const { return selected_index_; } bool isFilterPanelVisible() const { return filter_panel_visible_; } + bool openSelectedActionMenu(); private: enum class TimerDomain @@ -99,6 +102,20 @@ class ChatMessageListScreen Team }; + enum class ModalCommand + { + Chat, + Info, + Delete, + Cancel + }; + + struct ModalButtonContext + { + ChatMessageListScreen* screen = nullptr; + ModalCommand command = ModalCommand::Cancel; + }; + lv_obj_t* container_ = nullptr; ::ui::widgets::TopBar top_bar_{}; lv_obj_t* filter_panel_ = nullptr; @@ -107,6 +124,12 @@ class ChatMessageListScreen lv_obj_t* broadcast_btn_ = nullptr; lv_obj_t* team_btn_ = nullptr; lv_obj_t* list_back_btn_ = nullptr; + lv_obj_t* action_menu_modal_ = nullptr; + lv_obj_t* delete_confirm_modal_ = nullptr; + lv_group_t* modal_group_ = nullptr; + lv_group_t* modal_prev_group_ = nullptr; + chat::ConversationId modal_conv_{}; + ModalButtonContext modal_button_contexts_[4]{}; ::ui::components::air_status_footer::Footer air_status_footer_{}; int selected_index_ = -1; @@ -160,11 +183,24 @@ class ChatMessageListScreen static void search_clear_cb(void* user_data); static void search_cancel_cb(void* user_data); static void debug_touch_event_cb(lv_event_t* e); + static void action_menu_button_cb(lv_event_t* e); + static void action_menu_key_cb(lv_event_t* e); + static void delete_confirm_cb(lv_event_t* e); + static void delete_cancel_cb(lv_event_t* e); + static void modal_bg_key_cb(lv_event_t* e); static void async_action_cb(void* user_data); static void on_root_deleted(lv_event_t* e); static void handle_back(void* user_data); void handle_root_deleted(); + void openActionMenu(const chat::ConversationId& conv); + void openDeleteConfirm(const chat::ConversationId& conv); + void closeActionMenu(); + void closeDeleteConfirm(); + void closeModal(lv_obj_t*& modal); + void prepareModalGroup(); + void restoreModalGroup(); + bool isModalOpen() const; void schedule_action_async(ActionIntent intent, const chat::ConversationId& conv); lv_timer_t* add_timer(lv_timer_cb_t cb, uint32_t period_ms, void* user_data, TimerDomain domain); void clear_timers(TimerDomain domain); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_protocol_support.h b/modules/ui_shared/include/ui/screens/chat/chat_protocol_support.h index 1d70aedb..8ef7142f 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_protocol_support.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_protocol_support.h @@ -10,6 +10,7 @@ chat::MeshProtocol active_mesh_protocol(); chat::MeshCapabilities active_mesh_capabilities(); bool supports_local_text_chat(); bool supports_reticulum_destination_text(); +bool supports_reticulum_audio_call(); bool supports_team_chat(); const char* local_text_chat_unavailable_message(); const char* reticulum_destination_text_unavailable_message(); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h index 73e33418..bce3e587 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h @@ -86,6 +86,8 @@ class UiController : public IChatUiRefreshSink bool isTeamConversationActive() const { return team_conv_active_; } lv_obj_t* getParent() const { return parent_; } void onChannelClicked(chat::ConversationId conv); + void handleMessageListAction(ChatMessageListScreen::ActionIntent intent, + const chat::ConversationId& conv); void onRuntimeMessageArrived(chat::MessageId msg_id) override; void onRuntimeSendResult(chat::MessageId msg_id) override; void onRuntimeUnreadChanged() override; @@ -118,6 +120,9 @@ class UiController : public IChatUiRefreshSink bool team_conv_active_ = false; lv_timer_t* team_conv_timer_ = nullptr; bool exiting_ = false; + lv_obj_t* conversation_info_modal_ = nullptr; + lv_group_t* conversation_info_group_ = nullptr; + lv_group_t* conversation_info_prev_group_ = nullptr; ExitRequestCallback exit_request_ = nullptr; void* exit_request_user_data_ = nullptr; @@ -125,6 +130,11 @@ class UiController : public IChatUiRefreshSink void switchToConversation(chat::ConversationId conv); void switchToCompose(chat::ConversationId conv); void handleChannelSelected(const chat::ConversationId& conv); + void handleDeleteConversation(const chat::ConversationId& conv); + void openConversationInfoModal(const chat::ConversationId& conv); + void closeConversationInfoModal(bool restore_group); + void prepareConversationInfoGroup(); + void restoreConversationInfoGroup(); void handleSendMessage(const std::string& text); void handleComposeSendDone(bool ok, bool timeout); void refreshUnreadCounts(); @@ -172,6 +182,8 @@ class UiController : public IChatUiRefreshSink static void key_verify_submit_event_cb(lv_event_t* e); static void key_verify_close_event_cb(lv_event_t* e); static void key_verify_trust_event_cb(lv_event_t* e); + static void conversation_info_close_event_cb(lv_event_t* e); + static void conversation_info_key_event_cb(lv_event_t* e); }; } // namespace ui diff --git a/modules/ui_shared/include/ui/widgets/reticulum_call_overlay.h b/modules/ui_shared/include/ui/widgets/reticulum_call_overlay.h new file mode 100644 index 00000000..4386f8d0 --- /dev/null +++ b/modules/ui_shared/include/ui/widgets/reticulum_call_overlay.h @@ -0,0 +1,14 @@ +/** + * @file reticulum_call_overlay.h + * @brief Top-level Reticulum call answer/hangup overlay. + */ + +#pragma once + +namespace ui::widgets::reticulum_call_overlay +{ + +void tick(); +bool visible(); + +} // namespace ui::widgets::reticulum_call_overlay diff --git a/modules/ui_shared/library.json b/modules/ui_shared/library.json index fa4c6b94..c8e93f37 100644 --- a/modules/ui_shared/library.json +++ b/modules/ui_shared/library.json @@ -4,12 +4,15 @@ "build": { "includeDir": "include", "srcDir": "src", + "srcFilter": [ + "+<*>", + "-" + ], "flags": [ "-I../../platform/shared/include", "-I../../modules/core_sys/include", "-I../../modules/core_chat/include", "-I../../modules/core_gps/include", - "-I../../modules/core_hostlink/include", "-I../../modules/core_team/include", "-I../../modules/ui_presentation/include", "-I../../modules/chat_presentation_adapters/include", diff --git a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp index a0d15e84..df29317d 100644 --- a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp @@ -450,6 +450,11 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( chat_presentation_adapters::mapMessageStatus(message.status); row.failure = chat_presentation_adapters::mapMessageFailure(message.status); + row.ingress_transport = + message.status == ::chat::MessageStatus::Incoming + ? chat_presentation_adapters::mapMessageIngressTransport( + message.rx_origin) + : ui::chat::MessageIngressTransport::Unknown; if (delivery_read_model_ != nullptr) { ::chat::delivery::ChatDeliveryRecord delivery{}; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp index 277399b1..d3e57d23 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp @@ -263,6 +263,20 @@ std::string format_team_rich_payload_text( } return rich_payload_kind_label(payload.kind); } + +const char* message_ingress_label(::ui::chat::MessageIngressTransport transport) +{ + switch (transport) + { + case ::ui::chat::MessageIngressTransport::LoRa: + return "LoRa"; + case ::ui::chat::MessageIngressTransport::WiFi: + return "Wi-Fi"; + case ::ui::chat::MessageIngressTransport::Unknown: + break; + } + return nullptr; +} } // namespace static bool is_valid_epoch_ts(uint32_t ts) @@ -933,6 +947,7 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row time_buf, sizeof(time_buf), timestamp_from_presentation_label(row.time_label)); + const char* ingress_label = !is_self ? message_ingress_label(row.ingress_transport) : nullptr; if (conv_.peer == 0) { std::string sender; @@ -968,12 +983,28 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row sender = buf; } } - std::string line = sender + " " + time_buf; + std::string line = sender; + if (ingress_label && ingress_label[0] != '\0') + { + line += " / "; + line += ingress_label; + } + line += " / "; + line += time_buf; ::ui::i18n::set_content_label_text_raw(item.time_label, line.c_str()); } else { - ::ui::i18n::set_label_text_raw(item.time_label, time_buf); + if (ingress_label && ingress_label[0] != '\0') + { + char line[32] = {}; + std::snprintf(line, sizeof(line), "%s / %s", ingress_label, time_buf); + ::ui::i18n::set_label_text_raw(item.time_label, line); + } + else + { + ::ui::i18n::set_label_text_raw(item.time_label, time_buf); + } ::ui::fonts::apply_localized_font( item.time_label, lv_label_get_text(item.time_label), ::ui::fonts::ui_chrome_font()); } diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp index 1f561f9d..1fa00a00 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp @@ -9,7 +9,9 @@ #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/components/info_card.h" +#include "ui/components/two_pane_styles.h" #include "ui/localization.h" +#include "ui/page/page_profile.h" #include "ui/screens/chat/chat_message_list_input.h" #include "ui/screens/chat/chat_message_list_layout.h" #include "ui/screens/chat/chat_message_list_styles.h" @@ -62,6 +64,24 @@ static bool is_team_conversation(const chat::ConversationId& conv) return conv.channel == kTeamChatChannel && conv.peer == 0; } +static bool has_reticulum_destination(const chat::ConversationId& conv) +{ + return conv.protocol == chat::MeshProtocol::Reticulum && + chat::hasReticulumDestinationIdentity(conv.reticulum_identity); +} + +static bool is_direct_conversation(const chat::ConversationMeta& conv) +{ + return !is_team_conversation(conv.id) && + (conv.id.peer != 0 || has_reticulum_destination(conv.id)); +} + +static bool is_channel_conversation(const chat::ConversationMeta& conv) +{ + return !is_team_conversation(conv.id) && + conv.id.peer == 0 && !has_reticulum_destination(conv.id); +} + static bool conversation_meta_equal(const chat::ConversationMeta& lhs, const chat::ConversationMeta& rhs) { @@ -147,6 +167,41 @@ static bool contains_ci(const char* text, const char* query) return false; } +static const chat::ReticulumPeerIdentity& conversation_reticulum_identity( + const chat::ConversationMeta& conv) +{ + if (chat::hasReticulumDestinationIdentity(conv.reticulum_identity)) + { + return conv.reticulum_identity; + } + return conv.id.reticulum_identity; +} + +static void format_hash_text(const uint8_t* hash, char* out, size_t out_len) +{ + if (!hash || !out || out_len == 0) + { + return; + } + + size_t used = 0; + for (size_t index = 0; + index < chat::kReticulumPeerHashSize && used + 2U < out_len; + ++index) + { + const int written = std::snprintf(out + used, + out_len - used, + "%02X", + static_cast(hash[index])); + if (written != 2) + { + break; + } + used += 2U; + } + out[used < out_len ? used : out_len - 1U] = '\0'; +} + static bool is_search_shortcut_key(uint32_t key) { return key == '/' || key == 's' || key == 'S'; @@ -157,6 +212,108 @@ static bool is_filter_toggle_shortcut_key(uint32_t key) return key == 'f' || key == 'F'; } +static lv_style_selector_t selector_for_state(lv_state_t state) +{ + return static_cast(LV_PART_MAIN | state); +} + +static uint32_t color_panel_bg() +{ + return ::ui::components::two_pane_styles::kMainPanelBg; +} + +static uint32_t color_line() +{ + return ::ui::components::two_pane_styles::kBorder; +} + +static uint32_t color_accent() +{ + return ::ui::components::two_pane_styles::kAccent; +} + +static uint32_t color_text() +{ + return ::ui::components::two_pane_styles::kTextPrimary; +} + +static lv_coord_t action_button_height() +{ + return ::ui::page_profile::resolve_control_button_height(); +} + +static void apply_modal_label(lv_obj_t* label) +{ + if (!label) + { + return; + } + chat::ui::message_list::styles::apply_label_name(label); + lv_obj_set_style_text_color(label, lv_color_hex(color_text()), LV_PART_MAIN); +} + +static lv_obj_t* create_modal_root(lv_obj_t* parent, int width, int height) +{ + lv_obj_t* root_parent = parent ? parent : lv_screen_active(); + if (!root_parent) + { + return nullptr; + } + + lv_coord_t screen_w = lv_obj_get_width(root_parent); + lv_coord_t screen_h = lv_obj_get_height(root_parent); + if (screen_w <= 0 || screen_h <= 0) + { + root_parent = lv_screen_active(); + screen_w = root_parent ? lv_obj_get_width(root_parent) : 0; + screen_h = root_parent ? lv_obj_get_height(root_parent) : 0; + } + + lv_obj_t* bg = lv_obj_create(root_parent); + lv_obj_set_size(bg, screen_w, screen_h); + lv_obj_set_pos(bg, 0, 0); + lv_obj_set_style_bg_color(bg, lv_color_hex(color_text()), LV_PART_MAIN); + lv_obj_set_style_bg_opa(bg, LV_OPA_50, LV_PART_MAIN); + lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN); + lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(bg, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(bg, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_move_foreground(bg); + + const auto resolved = ::ui::page_profile::resolve_modal_size(width, height); + lv_obj_t* win = lv_obj_create(bg); + lv_obj_set_size(win, resolved.width, resolved.height); + lv_obj_center(win); + lv_obj_set_style_bg_color(win, lv_color_hex(color_panel_bg()), LV_PART_MAIN); + lv_obj_set_style_bg_opa(win, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(win, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(win, lv_color_hex(color_line()), LV_PART_MAIN); + lv_obj_set_style_radius(win, 8, LV_PART_MAIN); + lv_obj_set_style_pad_all(win, ::ui::page_profile::resolve_modal_pad(), LV_PART_MAIN); + lv_obj_clear_flag(win, LV_OBJ_FLAG_SCROLLABLE); + + return bg; +} + +static lv_obj_t* create_action_button(lv_obj_t* parent, const char* text) +{ + lv_obj_t* btn = lv_btn_create(parent); + lv_obj_set_size(btn, LV_PCT(100), action_button_height()); + ::ui::components::two_pane_styles::apply_btn_basic(btn); + lv_obj_set_style_bg_color(btn, lv_color_hex(color_panel_bg()), LV_PART_MAIN); + lv_obj_set_style_bg_color(btn, lv_color_hex(color_accent()), selector_for_state(LV_STATE_FOCUSED)); + lv_obj_set_style_bg_color(btn, lv_color_hex(color_accent()), selector_for_state(LV_STATE_FOCUS_KEY)); + lv_obj_set_style_bg_color(btn, lv_color_hex(0xC98118), selector_for_state(LV_STATE_PRESSED)); + lv_obj_set_style_border_color(btn, lv_color_hex(color_line()), LV_PART_MAIN); + + lv_obj_t* label = lv_label_create(btn); + ::ui::i18n::set_label_text(label, text); + apply_modal_label(label); + lv_obj_center(label); + return btn; +} + static const char* touch_event_name(lv_event_code_t code) { switch (code) @@ -430,6 +587,14 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) ChatMessageListScreen::~ChatMessageListScreen() { ::ui::components::floating_search_box::close(search_box_); + closeActionMenu(); + closeDeleteConfirm(); + restoreModalGroup(); + if (modal_group_) + { + lv_group_del(modal_group_); + modal_group_ = nullptr; + } if (container_ && lv_obj_is_valid(container_)) { lv_obj_del(container_); @@ -588,6 +753,22 @@ lv_obj_t* ChatMessageListScreen::getItemButton(size_t index) const return items_[index].btn; } +bool ChatMessageListScreen::openSelectedActionMenu() +{ + if (!guard_ || !guard_->alive) + { + return false; + } + + chat::ConversationId conv{}; + if (!tryGetSelectedConversation(&conv)) + { + return false; + } + openActionMenu(conv); + return true; +} + void ChatMessageListScreen::setActionCallback( void (*cb)(ActionIntent intent, const chat::ConversationId& conv, void*), void* user_data) @@ -628,8 +809,23 @@ bool ChatMessageListScreen::conversationMatchesSearch( sizeof(peer_hex), "%08lX", static_cast(conv.id.peer)); + char destination_hash[chat::kReticulumPeerHashSize * 2U + 1U] = {}; + char identity_hash[chat::kReticulumPeerHashSize * 2U + 1U] = {}; + const chat::ReticulumPeerIdentity& identity = + conversation_reticulum_identity(conv); + if (chat::hasReticulumDestinationIdentity(identity)) + { + format_hash_text(identity.destination_hash, + destination_hash, + sizeof(destination_hash)); + format_hash_text(identity.identity_hash, + identity_hash, + sizeof(identity_hash)); + } return contains_ci(conv.name.c_str(), search_query_) || - contains_ci(peer_hex, search_query_); + contains_ci(peer_hex, search_query_) || + contains_ci(destination_hash, search_query_) || + contains_ci(identity_hash, search_query_); } void ChatMessageListScreen::buildFilteredConversations( @@ -644,11 +840,13 @@ void ChatMessageListScreen::buildFilteredConversations( { mode_match = filter_mode_ == FilterMode::Team; } - else if (filter_mode_ == FilterMode::Direct && conv.id.peer != 0) + else if (filter_mode_ == FilterMode::Direct && + is_direct_conversation(conv)) { mode_match = true; } - else if (filter_mode_ == FilterMode::Broadcast && conv.id.peer == 0) + else if (filter_mode_ == FilterMode::Broadcast && + is_channel_conversation(conv)) { mode_match = true; } @@ -751,7 +949,7 @@ void ChatMessageListScreen::openSearchModal() } ::ui::components::floating_search_box::Config config{}; - config.title = "Search contacts"; + config.title = "Search chats"; config.initial_text = search_query_; config.max_length = sizeof(search_query_) - 1U; config.restore_group = input_controller_.group(); @@ -765,6 +963,204 @@ void ChatMessageListScreen::openSearchModal() config); } +void ChatMessageListScreen::prepareModalGroup() +{ + if (!modal_group_) + { + modal_group_ = lv_group_create(); + } + lv_group_remove_all_objs(modal_group_); + modal_prev_group_ = lv_group_get_default(); + if (!modal_prev_group_) + { + modal_prev_group_ = input_controller_.group(); + } + set_default_group(modal_group_); +} + +void ChatMessageListScreen::restoreModalGroup() +{ + lv_group_t* restore = modal_prev_group_; + if (!restore) + { + restore = input_controller_.group(); + } + if (restore) + { + set_default_group(restore); + } + modal_prev_group_ = nullptr; + if (use_group_navigation()) + { + chat::ui::message_list::input::on_ui_refreshed(&input_controller_); + chat::ui::message_list::input::focus_list(&input_controller_); + } +} + +void ChatMessageListScreen::closeModal(lv_obj_t*& modal) +{ + if (!modal) + { + return; + } + lv_obj_del(modal); + modal = nullptr; + restoreModalGroup(); +} + +void ChatMessageListScreen::closeActionMenu() +{ + closeModal(action_menu_modal_); +} + +void ChatMessageListScreen::closeDeleteConfirm() +{ + closeModal(delete_confirm_modal_); +} + +bool ChatMessageListScreen::isModalOpen() const +{ + return action_menu_modal_ != nullptr || delete_confirm_modal_ != nullptr; +} + +void ChatMessageListScreen::openActionMenu(const chat::ConversationId& conv) +{ + if (!guard_ || !guard_->alive || isModalOpen()) + { + return; + } + + modal_conv_ = conv; + prepareModalGroup(); + const int row_gap = ::ui::page_profile::current().large_touch_hitbox ? 8 : 4; + const int modal_h = 58 + 4 * (action_button_height() + row_gap); + action_menu_modal_ = create_modal_root(container_, 190, modal_h); + lv_obj_t* win = action_menu_modal_ ? lv_obj_get_child(action_menu_modal_, 0) : nullptr; + if (!win) + { + closeActionMenu(); + return; + } + lv_obj_add_event_cb(action_menu_modal_, modal_bg_key_cb, LV_EVENT_KEY, this); + lv_obj_set_flex_flow(win, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(win, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(win, row_gap, LV_PART_MAIN); + + std::string title = ::ui::i18n::tr("Conversation"); + for (const auto& conv_meta : convs_) + { + if (conv_meta.id == conv && !conv_meta.name.empty()) + { + title = conv_meta.name; + break; + } + } + + lv_obj_t* title_label = lv_label_create(win); + ::ui::i18n::set_label_text_raw(title_label, title.c_str()); + apply_modal_label(title_label); + lv_obj_set_width(title_label, LV_PCT(100)); + lv_label_set_long_mode(title_label, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(title_label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + + lv_obj_t* list = lv_obj_create(win); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_height(list, 0); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(list, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(list, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(list, 2, LV_PART_MAIN); + lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_AUTO); + + auto add_action = [&](ModalCommand command, const char* text) + { + lv_obj_t* btn = create_action_button(list, text); + const size_t context_index = static_cast(command); + modal_button_contexts_[context_index].screen = this; + modal_button_contexts_[context_index].command = command; + lv_obj_add_event_cb( + btn, + action_menu_button_cb, + LV_EVENT_CLICKED, + &modal_button_contexts_[context_index]); + lv_obj_add_event_cb(btn, action_menu_key_cb, LV_EVENT_KEY, this); + lv_group_add_obj(modal_group_, btn); + return btn; + }; + + lv_obj_t* first = add_action(ModalCommand::Chat, "Chat"); + add_action(ModalCommand::Info, "Info"); + add_action(ModalCommand::Delete, "Delete"); + add_action(ModalCommand::Cancel, "Cancel"); + if (first) + { + lv_group_focus_obj(first); + } +} + +void ChatMessageListScreen::openDeleteConfirm(const chat::ConversationId& conv) +{ + if (!guard_ || !guard_->alive || delete_confirm_modal_) + { + return; + } + + modal_conv_ = conv; + closeActionMenu(); + prepareModalGroup(); + delete_confirm_modal_ = create_modal_root(container_, 260, 138); + lv_obj_t* win = delete_confirm_modal_ ? lv_obj_get_child(delete_confirm_modal_, 0) : nullptr; + if (!win) + { + closeDeleteConfirm(); + return; + } + lv_obj_add_event_cb(delete_confirm_modal_, modal_bg_key_cb, LV_EVENT_KEY, this); + + lv_obj_t* label = lv_label_create(win); + apply_modal_label(label); + ::ui::i18n::set_label_text(label, "Delete this chat?"); + lv_obj_set_width(label, LV_PCT(100)); + lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 8); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + + lv_obj_t* btn_row = lv_obj_create(win); + lv_obj_set_size(btn_row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_align(btn_row, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_flex_flow(btn_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(btn_row, + LV_FLEX_ALIGN_SPACE_EVENLY, + LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(btn_row, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(btn_row, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(btn_row, 0, LV_PART_MAIN); + lv_obj_clear_flag(btn_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* confirm_btn = create_action_button(btn_row, "Delete"); + lv_obj_set_width(confirm_btn, ::ui::page_profile::resolve_compact_button_min_width()); + lv_obj_add_event_cb(confirm_btn, delete_confirm_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(confirm_btn, action_menu_key_cb, LV_EVENT_KEY, this); + + lv_obj_t* cancel_btn = create_action_button(btn_row, "Cancel"); + lv_obj_set_width(cancel_btn, ::ui::page_profile::resolve_compact_button_min_width()); + lv_obj_add_event_cb(cancel_btn, delete_cancel_cb, LV_EVENT_CLICKED, this); + lv_obj_add_event_cb(cancel_btn, action_menu_key_cb, LV_EVENT_KEY, this); + + lv_group_add_obj(modal_group_, confirm_btn); + lv_group_add_obj(modal_group_, cancel_btn); + lv_group_focus_obj(cancel_btn); +} + // ------------------------------------------------ // Core logic: rebuild list (behavior unchanged) // ------------------------------------------------ @@ -995,8 +1391,7 @@ void ChatMessageListScreen::item_event_cb(lv_event_t* e) screen->selected_index_, static_cast(screen->items_[i].conv.peer)); log_item_tree("item_click.current.after", item); - screen->schedule_action_async(ActionIntent::SelectConversation, - screen->items_[i].conv); + screen->openActionMenu(screen->items_[i].conv); break; } } @@ -1088,6 +1483,80 @@ void ChatMessageListScreen::filter_click_cb(lv_event_t* e) } } +void ChatMessageListScreen::action_menu_button_cb(lv_event_t* e) +{ + auto* ctx = static_cast(lv_event_get_user_data(e)); + ChatMessageListScreen* screen = ctx ? ctx->screen : nullptr; + if (!screen || !screen->guard_ || !screen->guard_->alive) + { + return; + } + + const chat::ConversationId conv = screen->modal_conv_; + switch (ctx->command) + { + case ModalCommand::Chat: + screen->closeActionMenu(); + screen->schedule_action_async(ActionIntent::SelectConversation, conv); + break; + case ModalCommand::Info: + screen->closeActionMenu(); + screen->schedule_action_async(ActionIntent::ShowInfo, conv); + break; + case ModalCommand::Delete: + screen->openDeleteConfirm(conv); + break; + case ModalCommand::Cancel: + default: + screen->closeActionMenu(); + break; + } +} + +void ChatMessageListScreen::action_menu_key_cb(lv_event_t* e) +{ + auto* screen = static_cast(lv_event_get_user_data(e)); + if (!screen || !screen->guard_ || !screen->guard_->alive || + lv_event_get_code(e) != LV_EVENT_KEY) + { + return; + } + const uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) + { + screen->closeActionMenu(); + screen->closeDeleteConfirm(); + lv_event_stop_processing(e); + } +} + +void ChatMessageListScreen::modal_bg_key_cb(lv_event_t* e) +{ + action_menu_key_cb(e); +} + +void ChatMessageListScreen::delete_confirm_cb(lv_event_t* e) +{ + auto* screen = static_cast(lv_event_get_user_data(e)); + if (!screen || !screen->guard_ || !screen->guard_->alive) + { + return; + } + const chat::ConversationId conv = screen->modal_conv_; + screen->closeDeleteConfirm(); + screen->schedule_action_async(ActionIntent::DeleteConversation, conv); +} + +void ChatMessageListScreen::delete_cancel_cb(lv_event_t* e) +{ + auto* screen = static_cast(lv_event_get_user_data(e)); + if (!screen || !screen->guard_ || !screen->guard_->alive) + { + return; + } + screen->closeDeleteConfirm(); +} + void ChatMessageListScreen::page_shortcut_cb(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); @@ -1100,8 +1569,38 @@ void ChatMessageListScreen::page_shortcut_cb(lv_event_t* e) { return; } + if (screen->isModalOpen()) + { + return; + } const uint32_t key = lv_event_get_key(e); + chat::ConversationId conv{}; + const bool has_selected = screen->tryGetSelectedConversation(&conv); + if ((key == LV_KEY_ENTER || key == ' ') && has_selected) + { + screen->openActionMenu(conv); + lv_event_stop_processing(e); + return; + } + if ((key == 'c' || key == 'C') && has_selected) + { + screen->schedule_action_async(ActionIntent::SelectConversation, conv); + lv_event_stop_processing(e); + return; + } + if ((key == 'i' || key == 'I') && has_selected) + { + screen->schedule_action_async(ActionIntent::ShowInfo, conv); + lv_event_stop_processing(e); + return; + } + if ((key == 'd' || key == 'D') && has_selected) + { + screen->openDeleteConfirm(conv); + lv_event_stop_processing(e); + return; + } if (is_search_shortcut_key(key)) { screen->openSearchModal(); @@ -1283,6 +1782,14 @@ void ChatMessageListScreen::handle_root_deleted() action_cb_user_data_ = nullptr; ::ui::components::floating_search_box::close(search_box_); + closeActionMenu(); + closeDeleteConfirm(); + restoreModalGroup(); + if (modal_group_) + { + lv_group_del(modal_group_); + modal_group_ = nullptr; + } if (use_group_navigation()) { chat::ui::message_list::input::cleanup(&input_controller_); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_protocol_support.cpp b/modules/ui_shared/src/ui/screens/chat/chat_protocol_support.cpp index e0cc220a..1e823a44 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_protocol_support.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_protocol_support.cpp @@ -27,6 +27,11 @@ bool supports_reticulum_destination_text() return active_mesh_capabilities().supports_reticulum_destination_text; } +bool supports_reticulum_audio_call() +{ + return active_mesh_capabilities().supports_reticulum_audio_call; +} + bool supports_team_chat() { return active_mesh_capabilities().supports_unicast_appdata; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index 05014b5d..2fc1b31f 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -14,7 +14,9 @@ #include "sys/event_bus.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" +#include "ui/components/two_pane_styles.h" #include "ui/localization.h" +#include "ui/page/page_profile.h" #include "ui/runtime/ui_feedback.h" #include "ui/screens/chat/chat_protocol_support.h" #include "ui/screens/chat/chat_team_workflow.h" @@ -75,8 +77,79 @@ const char* channel_display_name(chat::MeshProtocol protocol, chat::ChannelId ch } } +bool has_reticulum_destination(const chat::ConversationId& conv) +{ + return conv.protocol == chat::MeshProtocol::Reticulum && + chat::hasReticulumDestinationIdentity(conv.reticulum_identity); +} + +std::string reticulum_destination_label(const chat::ReticulumPeerIdentity& identity) +{ + if (!chat::hasReticulumDestinationIdentity(identity)) + { + return std::string(); + } + + char buf[9] = {}; + std::snprintf(buf, + sizeof(buf), + "%02X%02X%02X%02X", + static_cast(identity.destination_hash[0]), + static_cast(identity.destination_hash[1]), + static_cast(identity.destination_hash[2]), + static_cast(identity.destination_hash[3])); + return buf; +} + +std::string reticulum_contact_display_name(const chat::ConversationId& conv) +{ + if (!has_reticulum_destination(conv)) + { + return std::string(); + } + return app::messagingFacade() + .getContactService() + .getReticulumContactName(conv.reticulum_identity); +} + +bool same_conversation_party(const chat::ConversationId& lhs, + const chat::ConversationId& rhs) +{ + if (lhs.protocol != rhs.protocol) + { + return false; + } + + const bool lhs_reticulum = has_reticulum_destination(lhs); + const bool rhs_reticulum = has_reticulum_destination(rhs); + if (lhs_reticulum || rhs_reticulum) + { + return lhs_reticulum && rhs_reticulum && + chat::sameReticulumDestinationHash(lhs.reticulum_identity, + rhs.reticulum_identity); + } + + return lhs.peer == rhs.peer; +} + std::string base_conversation_name(const chat::ConversationId& conv) { + const std::string reticulum_name = reticulum_contact_display_name(conv); + if (!reticulum_name.empty()) + { + return reticulum_name; + } + + if (has_reticulum_destination(conv)) + { + const std::string destination_label = + reticulum_destination_label(conv.reticulum_identity); + if (!destination_label.empty()) + { + return destination_label; + } + } + if (conv.peer == 0) { return ::ui::i18n::tr("Broadcast"); @@ -93,6 +166,177 @@ std::string base_conversation_name(const chat::ConversationId& conv) return buf; } +void format_reticulum_hash(const uint8_t* hash, char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (!hash) + { + std::snprintf(out, out_len, "--"); + return; + } + + bool any = false; + size_t used = 0; + for (size_t index = 0; + index < chat::kReticulumPeerHashSize && used + 2U < out_len; + ++index) + { + any = any || hash[index] != 0; + const int written = std::snprintf(out + used, + out_len - used, + "%02X", + static_cast(hash[index])); + if (written != 2) + { + break; + } + used += 2U; + } + out[used < out_len ? used : out_len - 1U] = '\0'; + if (!any) + { + std::snprintf(out, out_len, "--"); + } +} + +const chat::ReticulumPeerIdentity& conversation_reticulum_identity( + const chat::ConversationId& conv, + const chat::contacts::NodeInfo* node) +{ + if (chat::hasReticulumDestinationIdentity(conv.reticulum_identity)) + { + return conv.reticulum_identity; + } + if (node && chat::hasReticulumDestinationIdentity(node->reticulum_identity)) + { + return node->reticulum_identity; + } + return conv.reticulum_identity; +} + +std::string node_display_name_for_info(const chat::ConversationId& conv, + const chat::contacts::NodeInfo* node) +{ + if (node) + { + if (!node->display_name.empty()) + { + return node->display_name; + } + if (node->long_name[0] != '\0') + { + return node->long_name; + } + if (node->short_name[0] != '\0') + { + return node->short_name; + } + } + return base_conversation_name(conv); +} + +void apply_info_label(lv_obj_t* label, bool muted = false) +{ + if (!label) + { + return; + } + ::ui::components::two_pane_styles::init_once(); + if (muted) + { + ::ui::components::two_pane_styles::apply_label_muted(label); + } + else + { + ::ui::components::two_pane_styles::apply_label_primary(label); + } + ::ui::fonts::apply_localized_font(label, + lv_label_get_text(label), + ::ui::fonts::ui_chrome_font()); +} + +void add_info_row(lv_obj_t* parent, const char* label, const char* value) +{ + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_color(row, + lv_color_hex(::ui::components::two_pane_styles::kMainPanelBg), + LV_PART_MAIN); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(row, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(row, + lv_color_hex(::ui::components::two_pane_styles::kBorder), + LV_PART_MAIN); + lv_obj_set_style_radius(row, 8, LV_PART_MAIN); + lv_obj_set_style_pad_all(row, 4, LV_PART_MAIN); + lv_obj_set_style_pad_row(row, 1, LV_PART_MAIN); + lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* key = lv_label_create(row); + ::ui::i18n::set_label_text(key, label); + apply_info_label(key, true); + lv_obj_set_width(key, LV_PCT(100)); + lv_label_set_long_mode(key, LV_LABEL_LONG_DOT); + + lv_obj_t* val = lv_label_create(row); + ::ui::i18n::set_label_text_raw(val, value && value[0] != '\0' ? value : "--"); + apply_info_label(val, false); + lv_obj_set_width(val, LV_PCT(100)); + lv_label_set_long_mode(val, LV_LABEL_LONG_DOT); +} + +lv_obj_t* create_info_modal_root(lv_obj_t* parent, int width, int height) +{ + lv_obj_t* root_parent = parent ? parent : lv_screen_active(); + if (!root_parent) + { + return nullptr; + } + + lv_obj_t* bg = lv_obj_create(root_parent); + lv_obj_set_size(bg, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(bg, 0, 0); + lv_obj_set_style_bg_color(bg, + lv_color_hex(::ui::components::two_pane_styles::kTextPrimary), + LV_PART_MAIN); + lv_obj_set_style_bg_opa(bg, LV_OPA_50, LV_PART_MAIN); + lv_obj_set_style_border_width(bg, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(bg, 0, LV_PART_MAIN); + lv_obj_clear_flag(bg, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(bg, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(bg, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_move_foreground(bg); + + const auto resolved = ::ui::page_profile::resolve_modal_size(width, height, root_parent); + lv_obj_t* win = lv_obj_create(bg); + lv_obj_set_size(win, resolved.width, resolved.height); + lv_obj_center(win); + lv_obj_set_flex_flow(win, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(win, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START); + lv_obj_set_style_bg_color(win, + lv_color_hex(::ui::components::two_pane_styles::kSidePanelBg), + LV_PART_MAIN); + lv_obj_set_style_bg_opa(win, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(win, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(win, + lv_color_hex(::ui::components::two_pane_styles::kBorder), + LV_PART_MAIN); + lv_obj_set_style_radius(win, 8, LV_PART_MAIN); + lv_obj_set_style_pad_all(win, ::ui::page_profile::resolve_modal_pad(), LV_PART_MAIN); + lv_obj_set_style_pad_row(win, 4, LV_PART_MAIN); + lv_obj_clear_flag(win, LV_OBJ_FLAG_SCROLLABLE); + return bg; +} + chat::ConversationId teamConversationId() { return chat::ConversationId(kTeamChatChannel, 0, chat_support::active_mesh_protocol()); @@ -298,15 +542,7 @@ void handle_message_list_action(chat::ui::ChatMessageListScreen::ActionIntent in { return; } - if (intent == chat::ui::ChatMessageListScreen::ActionIntent::SelectConversation) - { - controller->onChannelClicked(conv); - return; - } - if (intent == chat::ui::ChatMessageListScreen::ActionIntent::Back) - { - controller->exitToMenu(); - } + controller->handleMessageListAction(intent, conv); } void handle_conversation_action(chat::ui::ChatConversationScreen::ActionIntent intent, void* user_data) @@ -385,6 +621,7 @@ UiController::~UiController() closeTeamPositionPicker(false); team_position_picker_.reset(); closeKeyVerificationModal(false); + closeConversationInfoModal(false); stopTeamConversationTimer(); service_.setModelEnabled(false); channel_list_.reset(); @@ -441,6 +678,28 @@ void UiController::onChannelClicked(chat::ConversationId conv) } } +void UiController::handleMessageListAction( + ChatMessageListScreen::ActionIntent intent, + const chat::ConversationId& conv) +{ + switch (intent) + { + case ChatMessageListScreen::ActionIntent::SelectConversation: + onChannelClicked(conv); + break; + case ChatMessageListScreen::ActionIntent::ShowInfo: + openConversationInfoModal(conv); + break; + case ChatMessageListScreen::ActionIntent::DeleteConversation: + handleDeleteConversation(conv); + break; + case ChatMessageListScreen::ActionIntent::Back: + default: + exitToMenu(); + break; + } +} + void UiController::backToList() { switchToChannelList(); @@ -460,11 +719,7 @@ void UiController::onInput(const sys::InputEvent& event) { if (channel_list_) { - chat::ConversationId selected_conv{}; - if (channel_list_->tryGetSelectedConversation(&selected_conv)) - { - handleChannelSelected(selected_conv); - } + (void)channel_list_->openSelectedActionMenu(); } } else if (event.input_type == sys::InputEvent::KeyPress && event.value == 27) @@ -548,6 +803,7 @@ void UiController::showKeyVerification( void UiController::switchToChannelList() { + closeConversationInfoModal(true); closeTeamPositionPicker(true); state_ = State::ChannelList; stopTeamConversationTimer(); @@ -587,6 +843,7 @@ void UiController::switchToChannelList() void UiController::switchToConversation(chat::ConversationId conv) { + closeConversationInfoModal(true); closeTeamPositionPicker(true); state_ = State::Conversation; current_channel_ = conv.channel; @@ -713,6 +970,7 @@ void UiController::switchToConversation(chat::ConversationId conv) void UiController::switchToCompose(chat::ConversationId conv) { + closeConversationInfoModal(true); closeTeamPositionPicker(true); const bool is_team_conv = isTeamConversation(conv); if (!is_team_conv && conv.protocol != chat_support::active_mesh_protocol()) @@ -842,6 +1100,215 @@ void UiController::handleChannelSelected(const chat::ConversationId& conv) switchToConversation(conv); } +void UiController::handleDeleteConversation(const chat::ConversationId& conv) +{ + closeConversationInfoModal(true); + service_.clearConversation(conv); + conversation_list_dirty_ = true; + syncConversationListFromStore(); + applyConversationListToUi(); + ::ui::feedback::show_notice("Chat deleted", 1400); +} + +void UiController::prepareConversationInfoGroup() +{ + if (!conversation_info_group_) + { + conversation_info_group_ = lv_group_create(); + } + lv_group_remove_all_objs(conversation_info_group_); + conversation_info_prev_group_ = lv_group_get_default(); + set_default_group(conversation_info_group_); +} + +void UiController::restoreConversationInfoGroup() +{ + if (conversation_info_prev_group_) + { + set_default_group(conversation_info_prev_group_); + } + conversation_info_prev_group_ = nullptr; +} + +void UiController::closeConversationInfoModal(bool restore_group) +{ + if (conversation_info_modal_) + { + lv_obj_del(conversation_info_modal_); + conversation_info_modal_ = nullptr; + } + if (restore_group) + { + restoreConversationInfoGroup(); + } + else if (conversation_info_prev_group_) + { + set_default_group(conversation_info_prev_group_); + conversation_info_prev_group_ = nullptr; + } + if (conversation_info_group_ && !restore_group) + { + lv_group_del(conversation_info_group_); + conversation_info_group_ = nullptr; + conversation_info_prev_group_ = nullptr; + } +} + +void UiController::conversation_info_close_event_cb(lv_event_t* e) +{ + auto* controller = static_cast(lv_event_get_user_data(e)); + if (!controller) + { + return; + } + controller->closeConversationInfoModal(true); +} + +void UiController::conversation_info_key_event_cb(lv_event_t* e) +{ + auto* controller = static_cast(lv_event_get_user_data(e)); + if (!controller || lv_event_get_code(e) != LV_EVENT_KEY) + { + return; + } + const uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) + { + controller->closeConversationInfoModal(true); + lv_event_stop_processing(e); + } +} + +void UiController::openConversationInfoModal(const chat::ConversationId& conv) +{ + if (conversation_info_modal_) + { + closeConversationInfoModal(true); + } + + auto& contact_service = app::messagingFacade().getContactService(); + uint32_t node_id = conv.peer; + const chat::contacts::NodeInfo* node = nullptr; + if (has_reticulum_destination(conv)) + { + uint32_t resolved_node_id = 0; + if (contact_service.findNodeIdByReticulumDestinationHash( + conv.reticulum_identity.destination_hash, + &resolved_node_id)) + { + node_id = resolved_node_id; + } + } + if (node_id != 0) + { + node = contact_service.getNodeInfo(node_id); + } + + const std::string display_name = node_display_name_for_info(conv, node); + const chat::ReticulumPeerIdentity& identity = + conversation_reticulum_identity(conv, node); + + char node_id_text[16] = {}; + std::snprintf(node_id_text, + sizeof(node_id_text), + "%08lX", + static_cast(node_id)); + + char peer_text[16] = {}; + std::snprintf(peer_text, + sizeof(peer_text), + "%08lX", + static_cast(conv.peer)); + + char destination_hash[chat::kReticulumPeerHashSize * 2U + 1U] = {}; + char identity_hash[chat::kReticulumPeerHashSize * 2U + 1U] = {}; + format_reticulum_hash(identity.destination_hash, + destination_hash, + sizeof(destination_hash)); + format_reticulum_hash(identity.identity_hash, + identity_hash, + sizeof(identity_hash)); + + prepareConversationInfoGroup(); + conversation_info_modal_ = create_info_modal_root(parent_, 288, 214); + lv_obj_t* win = conversation_info_modal_ ? lv_obj_get_child(conversation_info_modal_, 0) : nullptr; + if (!win) + { + closeConversationInfoModal(true); + return; + } + lv_obj_add_event_cb(conversation_info_modal_, + conversation_info_key_event_cb, + LV_EVENT_KEY, + this); + + lv_obj_t* title = lv_label_create(win); + ::ui::i18n::set_label_text_raw(title, display_name.c_str()); + apply_info_label(title, false); + lv_obj_set_width(title, LV_PCT(100)); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + + lv_obj_t* content = lv_obj_create(win); + lv_obj_set_width(content, LV_PCT(100)); + lv_obj_set_height(content, 0); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(content, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(content, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(content, 3, LV_PART_MAIN); + lv_obj_set_scroll_dir(content, LV_DIR_VER); + lv_obj_set_scrollbar_mode(content, LV_SCROLLBAR_MODE_AUTO); + lv_obj_add_event_cb(content, conversation_info_key_event_cb, LV_EVENT_KEY, this); + + add_info_row(content, "Display Name", display_name.c_str()); + add_info_row(content, "Protocol", protocol_short_label(conv.protocol)); + if (has_reticulum_destination(conv) || + chat::hasReticulumDestinationIdentity(identity)) + { + add_info_row(content, "LXMF Address", destination_hash); + add_info_row(content, "Identity Hash", identity_hash); + add_info_row(content, "Node ID", node_id != 0 ? node_id_text : "--"); + if (node && node->hops_away != 0xFF) + { + char hops[16] = {}; + std::snprintf(hops, sizeof(hops), "%u", static_cast(node->hops_away)); + add_info_row(content, "Hops", hops); + } + } + else + { + add_info_row(content, "Peer", peer_text); + add_info_row(content, "Node ID", node_id != 0 ? node_id_text : "--"); + } + + lv_obj_t* close_btn = lv_btn_create(win); + lv_obj_set_size(close_btn, + ::ui::page_profile::resolve_control_button_min_width(), + ::ui::page_profile::resolve_control_button_height()); + ::ui::components::two_pane_styles::apply_btn_basic(close_btn); + lv_obj_set_style_bg_color(close_btn, + lv_color_hex(::ui::components::two_pane_styles::kAccent), + LV_PART_MAIN); + lv_obj_t* close_label = lv_label_create(close_btn); + ::ui::i18n::set_label_text(close_label, "Close"); + apply_info_label(close_label, false); + lv_obj_center(close_label); + lv_obj_add_event_cb(close_btn, + conversation_info_close_event_cb, + LV_EVENT_CLICKED, + this); + lv_obj_add_event_cb(close_btn, + conversation_info_key_event_cb, + LV_EVENT_KEY, + this); + + lv_group_add_obj(conversation_info_group_, content); + lv_group_add_obj(conversation_info_group_, close_btn); + lv_group_focus_obj(close_btn); +} + void UiController::handleSendMessage(const std::string& text) { if (text.empty()) @@ -931,7 +1398,13 @@ void UiController::normalizeConversationNames(std::vector kMaxPrefixedSenderLen) @@ -269,6 +283,14 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row { display_text = body_text; } + const char* ingress_label = + !row.outgoing ? message_ingress_label(row.ingress_transport) : nullptr; + if (ingress_label && ingress_label[0] != '\0') + { + display_text += " ["; + display_text += ingress_label; + display_text += "]"; + } item.text_label = lv_label_create(item.container); lv_label_set_long_mode(item.text_label, LV_LABEL_LONG_WRAP); diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index 64a9be44..8e5f16d4 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -3498,9 +3498,97 @@ enum class ActionMenuCommand : uint8_t Add = 5, Delete = 6, ToggleIgnore = 7, - Cancel = 8, + Call = 8, + Cancel = 9, }; +static const char* reticulum_call_failure_message(const chat::MeshActionResult& result) +{ + if (result.failure == chat::MeshOperationFailure::NotReady && result.detail == 1) + { + return "Path requested"; + } + switch (result.failure) + { + case chat::MeshOperationFailure::InvalidInput: + return "Invalid peer"; + case chat::MeshOperationFailure::Unsupported: + return "Call unsupported"; + case chat::MeshOperationFailure::NotReady: + return "Wi-Fi gateway unavailable"; + case chat::MeshOperationFailure::LocalIdentityMissing: + return "Identity missing"; + case chat::MeshOperationFailure::PeerKeyMissing: + return "Peer identity missing"; + case chat::MeshOperationFailure::Busy: + return "Call already active"; + case chat::MeshOperationFailure::EncodeFailed: + case chat::MeshOperationFailure::CryptoFailed: + return "Call setup failed"; + case chat::MeshOperationFailure::RadioTxFailed: + return "Gateway send failed"; + case chat::MeshOperationFailure::TxDisabled: + return "TX disabled"; + case chat::MeshOperationFailure::RadioOffline: + return "Radio offline"; + case chat::MeshOperationFailure::DutyCycleLimited: + return "TX rate limited"; + case chat::MeshOperationFailure::ChannelKeyMissing: + return "Key missing"; + case chat::MeshOperationFailure::None: + case chat::MeshOperationFailure::Unknown: + break; + } + return "Call failed"; +} + +static bool selected_node_supports_reticulum_call(const chat::contacts::NodeInfo* node) +{ + if (!node || chat_support::active_mesh_protocol() != chat::MeshProtocol::Reticulum) + { + return false; + } + if (g_contacts_state.current_mode != ContactsMode::Contacts && + g_contacts_state.current_mode != ContactsMode::Nearby && + g_contacts_state.current_mode != ContactsMode::Ignored) + { + return false; + } + return chat_support::supports_reticulum_audio_call() && + chat::hasReticulumDestinationIdentity(node->reticulum_identity); +} + +static void start_reticulum_call_for_selected_node() +{ + const auto* node = get_selected_node(); + if (!selected_node_supports_reticulum_call(node) || !g_contacts_state.chat_service) + { + ::ui::feedback::show_notice("Call unavailable", 1800); + contacts_focus_to_list(); + return; + } + + const chat::MeshActionResult result = + g_contacts_state.chat_service->startReticulumAudioCall(node->reticulum_identity); + if (result.ok) + { + const std::string name = node_display_name_for_contacts(*node); + const std::string msg = name.empty() + ? std::string("Calling") + : ::ui::i18n::format("Calling %s", name.c_str()); + ::ui::feedback::show_notice(msg.c_str(), 1600); + } + else if (result.failure == chat::MeshOperationFailure::NotReady && result.detail == 1) + { + ::ui::feedback::show_notice("Path requested", 2200); + } + else + { + ::ui::feedback::show_notice(reticulum_call_failure_message(result), 2200); + } + contacts_focus_to_list(); +} + static void toggle_selected_node_ignore() { const auto* node = get_selected_node(); @@ -3600,6 +3688,9 @@ static void on_action_menu_item_clicked(lv_event_t* e) case ActionMenuCommand::ToggleIgnore: toggle_selected_node_ignore(); break; + case ActionMenuCommand::Call: + start_reticulum_call_for_selected_node(); + break; case ActionMenuCommand::Cancel: default: contacts_focus_to_list(); @@ -3627,6 +3718,7 @@ static void open_action_menu_modal() const bool show_ignore = (node != nullptr) && (g_contacts_state.current_mode == ContactsMode::Contacts || g_contacts_state.current_mode == ContactsMode::Nearby); + const bool allow_reticulum_call = selected_node_supports_reticulum_call(node); const bool allow_chat_action = (g_contacts_state.current_mode == ContactsMode::Team) @@ -3635,6 +3727,10 @@ static void open_action_menu_modal() ? chat_support::supports_reticulum_destination_text() : chat_support::supports_local_text_chat(); int action_count = allow_chat_action ? 2 : 1; // Chat + Cancel + if (allow_reticulum_call) + { + action_count += 1; + } if (g_contacts_state.current_mode == ContactsMode::Contacts) { action_count += 3; // Edit/Delete/Info @@ -3748,6 +3844,10 @@ static void open_action_menu_modal() { add_action(ActionMenuCommand::Chat, "Chat"); } + if (allow_reticulum_call) + { + add_action(ActionMenuCommand::Call, "Call"); + } if (g_contacts_state.current_mode == ContactsMode::Contacts) { add_action(ActionMenuCommand::Edit, "Edit"); diff --git a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp index 4250c061..d9e44ba5 100644 --- a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp @@ -350,6 +350,8 @@ const char* aspect_label(rtdir::AnnounceAspect aspect) return "LXMF"; case rtdir::AnnounceAspect::LxmfPropagation: return "Prop"; + case rtdir::AnnounceAspect::CallAudio: + return "Call"; case rtdir::AnnounceAspect::Unknown: default: return "Node"; diff --git a/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp b/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp new file mode 100644 index 00000000..c824bdcb --- /dev/null +++ b/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp @@ -0,0 +1,355 @@ +/** + * @file reticulum_call_overlay.cpp + * @brief Top-level Reticulum call answer/hangup overlay. + */ + +#include "ui/widgets/reticulum_call_overlay.h" + +#include "lvgl.h" +#include "platform/ui/reticulum_call_runtime.h" +#include "ui/app_runtime.h" +#include "ui/assets/fonts/font_utils.h" +#include "ui/localization.h" +#include "ui/page/page_profile.h" +#include "ui/runtime/ui_feedback.h" + +#include +#include + +namespace ui::widgets::reticulum_call_overlay +{ +namespace +{ + +constexpr uint32_t kScrim = 0x1C1812; +constexpr uint32_t kPanelBg = 0xFAF0D8; +constexpr uint32_t kAmber = 0xEBA341; +constexpr uint32_t kAmberDark = 0xC98118; +constexpr uint32_t kLine = 0xE7C98F; +constexpr uint32_t kText = 0x6B4A1E; +constexpr uint32_t kTextDim = 0x8A6A3A; +constexpr uint32_t kWarn = 0xB94A2C; +constexpr uint32_t kOk = 0x3E7D3E; + +struct OverlayState +{ + lv_obj_t* root = nullptr; + lv_obj_t* panel = nullptr; + lv_obj_t* title = nullptr; + lv_obj_t* peer = nullptr; + lv_obj_t* status = nullptr; + lv_obj_t* answer_btn = nullptr; + lv_obj_t* hangup_btn = nullptr; + lv_obj_t* decline_btn = nullptr; + ::platform::ui::reticulum_call::State last_state = + ::platform::ui::reticulum_call::State::Idle; +}; + +OverlayState s_overlay; + +const lv_font_t* body_font() +{ + return ::ui::page_profile::resolve_body_font(); +} + +const lv_font_t* caption_font() +{ + return ::ui::page_profile::resolve_caption_font(); +} + +void apply_label(lv_obj_t* label, + const char* text, + uint32_t color, + const lv_font_t* font) +{ + if (!label) + { + return; + } + const char* value = text ? text : ""; + lv_label_set_text(label, value); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); + ::ui::fonts::apply_localized_font(label, value, font ? font : body_font()); +} + +void style_button(lv_obj_t* btn, uint32_t bg, uint32_t bg_pressed) +{ + lv_obj_set_style_radius(btn, 8, LV_PART_MAIN); + lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN); + lv_obj_set_style_bg_color(btn, lv_color_hex(bg_pressed), LV_STATE_PRESSED); + lv_obj_set_style_bg_color(btn, lv_color_hex(bg_pressed), LV_STATE_FOCUSED); + lv_obj_set_style_bg_color(btn, lv_color_hex(bg_pressed), LV_STATE_FOCUS_KEY); + lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(btn, lv_color_hex(kLine), LV_PART_MAIN); + lv_obj_set_style_outline_width(btn, 0, LV_STATE_FOCUSED); + lv_obj_set_style_outline_width(btn, 0, LV_STATE_FOCUS_KEY); +} + +lv_obj_t* make_button(lv_obj_t* parent, + const char* text, + uint32_t bg, + uint32_t bg_pressed, + lv_event_cb_t cb) +{ + lv_obj_t* btn = lv_btn_create(parent); + lv_obj_set_size(btn, + ::ui::page_profile::resolve_control_button_min_width(), + ::ui::page_profile::resolve_control_button_height()); + style_button(btn, bg, bg_pressed); + + lv_obj_t* label = lv_label_create(btn); + apply_label(label, text, kText, body_font()); + lv_obj_center(label); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, nullptr); + lv_obj_add_event_cb(btn, cb, LV_EVENT_KEY, nullptr); + if (app_g) + { + lv_group_add_obj(app_g, btn); + } + return btn; +} + +bool activation_event(lv_event_t* event) +{ + if (!event) + { + return false; + } + if (lv_event_get_code(event) == LV_EVENT_CLICKED) + { + return true; + } + if (lv_event_get_code(event) != LV_EVENT_KEY) + { + return false; + } + const uint32_t key = lv_event_get_key(event); + return key == LV_KEY_ENTER || key == LV_KEY_RIGHT; +} + +void answer_cb(lv_event_t* event) +{ + if (!activation_event(event)) + { + return; + } + if (!::platform::ui::reticulum_call::accept()) + { + ::ui::feedback::show_notice(::ui::i18n::tr("Call unavailable"), 1800); + } +} + +void hangup_cb(lv_event_t* event) +{ + if (!activation_event(event)) + { + return; + } + ::platform::ui::reticulum_call::hangup(); +} + +void decline_cb(lv_event_t* event) +{ + if (!activation_event(event)) + { + return; + } + ::platform::ui::reticulum_call::reject(); +} + +void root_key_cb(lv_event_t* event) +{ + if (!event || lv_event_get_code(event) != LV_EVENT_KEY) + { + 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) && + snapshot.state != ::platform::ui::reticulum_call::State::Idle) + { + ::platform::ui::reticulum_call::hangup(); + } +} + +void destroy_overlay() +{ + if (s_overlay.root) + { + lv_obj_del(s_overlay.root); + } + s_overlay = OverlayState{}; +} + +void ensure_overlay() +{ + if (s_overlay.root && lv_obj_is_valid(s_overlay.root)) + { + return; + } + + s_overlay.root = lv_obj_create(lv_layer_top()); + lv_obj_set_size(s_overlay.root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(s_overlay.root, lv_color_hex(kScrim), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_overlay.root, LV_OPA_70, LV_PART_MAIN); + lv_obj_set_style_border_width(s_overlay.root, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(s_overlay.root, 8, LV_PART_MAIN); + lv_obj_add_event_cb(s_overlay.root, root_key_cb, LV_EVENT_KEY, nullptr); + lv_obj_add_flag(s_overlay.root, LV_OBJ_FLAG_CLICKABLE); + + s_overlay.panel = lv_obj_create(s_overlay.root); + const bool dense = ::ui::page_profile::current().dense; + lv_obj_set_size(s_overlay.panel, dense ? 220 : 260, LV_SIZE_CONTENT); + lv_obj_align(s_overlay.panel, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_flex_flow(s_overlay.panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(s_overlay.panel, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_color(s_overlay.panel, lv_color_hex(kPanelBg), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_overlay.panel, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(s_overlay.panel, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(s_overlay.panel, lv_color_hex(kLine), LV_PART_MAIN); + lv_obj_set_style_radius(s_overlay.panel, 8, LV_PART_MAIN); + lv_obj_set_style_pad_all(s_overlay.panel, dense ? 8 : 12, LV_PART_MAIN); + lv_obj_set_style_pad_row(s_overlay.panel, dense ? 4 : 6, LV_PART_MAIN); + + s_overlay.title = lv_label_create(s_overlay.panel); + lv_obj_set_width(s_overlay.title, LV_PCT(100)); + lv_label_set_long_mode(s_overlay.title, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(s_overlay.title, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + + s_overlay.peer = lv_label_create(s_overlay.panel); + lv_obj_set_width(s_overlay.peer, LV_PCT(100)); + lv_label_set_long_mode(s_overlay.peer, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(s_overlay.peer, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + + s_overlay.status = lv_label_create(s_overlay.panel); + lv_obj_set_width(s_overlay.status, LV_PCT(100)); + lv_label_set_long_mode(s_overlay.status, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(s_overlay.status, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + + lv_obj_t* actions = lv_obj_create(s_overlay.panel); + lv_obj_set_width(actions, LV_PCT(100)); + lv_obj_set_height(actions, + ::ui::page_profile::resolve_control_button_height() + 4); + lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(actions, + LV_FLEX_ALIGN_SPACE_EVENLY, + LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_bg_opa(actions, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(actions, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(actions, 0, LV_PART_MAIN); + + 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); +} + +void format_peer_name(const ::platform::ui::reticulum_call::Snapshot& snapshot, + char* out, + std::size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + if (snapshot.peer_name[0] != '\0') + { + std::snprintf(out, out_len, "%s", snapshot.peer_name); + return; + } + std::snprintf(out, + out_len, + "%.2X%.2X%.2X%.2X", + static_cast(snapshot.peer_destination_hash[0]), + static_cast(snapshot.peer_destination_hash[1]), + static_cast(snapshot.peer_destination_hash[2]), + static_cast(snapshot.peer_destination_hash[3])); +} + +void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot) +{ + ensure_overlay(); + const auto state = snapshot.state; + const bool incoming = state == ::platform::ui::reticulum_call::State::Incoming; + const bool active = state == ::platform::ui::reticulum_call::State::Active; + const bool outgoing = state == ::platform::ui::reticulum_call::State::Outgoing; + + apply_label(s_overlay.title, + incoming ? "Incoming call" : (outgoing ? "Calling" : "Reticulum call"), + kText, + body_font()); + + char peer_name[48] = {}; + format_peer_name(snapshot, peer_name, sizeof(peer_name)); + apply_label(s_overlay.peer, peer_name, kText, body_font()); + + char status[96] = {}; + if (!snapshot.wifi_ready) + { + std::snprintf(status, sizeof(status), "%s", "Wi-Fi gateway unavailable"); + } + else if (!snapshot.media_supported) + { + std::snprintf(status, sizeof(status), "%s", "Audio hardware unavailable"); + } + else if (active) + { + std::snprintf(status, + sizeof(status), + "RX %lu TX %lu", + static_cast(snapshot.rx_packets), + static_cast(snapshot.tx_packets)); + } + else + { + std::snprintf(status, sizeof(status), "%s", "Wi-Fi call.audio"); + } + apply_label(s_overlay.status, status, kTextDim, caption_font()); + + if (incoming) + { + lv_obj_clear_flag(s_overlay.answer_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(s_overlay.decline_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_overlay.hangup_btn, LV_OBJ_FLAG_HIDDEN); + if (app_g) + { + lv_group_focus_obj(s_overlay.answer_btn); + } + } + else + { + lv_obj_add_flag(s_overlay.answer_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_overlay.decline_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(s_overlay.hangup_btn, LV_OBJ_FLAG_HIDDEN); + if (app_g) + { + lv_group_focus_obj(s_overlay.hangup_btn); + } + } + s_overlay.last_state = state; +} + +} // namespace + +void tick() +{ + const auto snapshot = ::platform::ui::reticulum_call::snapshot(); + if (snapshot.state == ::platform::ui::reticulum_call::State::Idle || + snapshot.state == ::platform::ui::reticulum_call::State::Ended || + snapshot.state == ::platform::ui::reticulum_call::State::Failed) + { + destroy_overlay(); + return; + } + update_overlay(snapshot); +} + +bool visible() +{ + return s_overlay.root && lv_obj_is_valid(s_overlay.root); +} + +} // namespace ui::widgets::reticulum_call_overlay diff --git a/modules/ui_shared/tests/test_chat_presentation_source.cpp b/modules/ui_shared/tests/test_chat_presentation_source.cpp index b0a058a5..d7382fcc 100644 --- a/modules/ui_shared/tests/test_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_chat_presentation_source.cpp @@ -119,6 +119,14 @@ class FakeNodeStore final : public ::chat::contacts::INodeStore { entry->last_seen = update.last_seen; } + if (update.has_protocol) + { + entry->protocol = update.protocol; + } + if (update.reticulum_identity.valid) + { + entry->reticulum_identity = update.reticulum_identity; + } if (update.has_position) { updatePosition(node_id, update.position); @@ -442,6 +450,7 @@ int main() incoming.to = 0xFFFFFFFFUL; incoming.msg_id = 900; incoming.text = "broadcast hello"; + incoming.rx_meta.origin = ::chat::RxOrigin::LoRa; contacts.updateNodeInfo(0x648144D4, "44D4", "Mother", @@ -463,6 +472,8 @@ int main() assert(snapshot.message_count == 1); assert(snapshot.messages[0].conversation == broadcast); assert(!snapshot.messages[0].outgoing); + assert(snapshot.messages[0].ingress_transport == + ui::chat::MessageIngressTransport::LoRa); assert(snapshot.messages[0].sender_node_id == 0x648144D4); assert(std::strcmp(snapshot.messages[0].sender_label.c_str(), "Mother") == 0); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h index e398e204..be0e5756 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h @@ -52,6 +52,8 @@ class LxmfAdapter : public IMeshAdapter bool broadcastSelfIdentity() override; NodeId getNodeId() const override; bool getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* out) const override; + MeshActionResult startReticulumAudioCall( + const ReticulumPeerIdentity& destination) override; void applyConfig(const MeshConfig& config) override; void setUserInfo(const char* long_name, const char* short_name) override; bool isReady() const override; @@ -182,19 +184,26 @@ class LxmfAdapter : public IMeshAdapter bool handleDataPacket(const uint8_t* raw_packet, size_t raw_len, const reticulum::ParsedPacket& packet); bool handleProofPacket(const uint8_t* raw_packet, size_t raw_len, - const reticulum::ParsedPacket& packet); - bool handleLinkRequestPacket(const uint8_t* raw_packet, size_t raw_len, - const reticulum::ParsedPacket& packet); + const reticulum::ParsedPacket& packet, + reticulum::interfaces::InterfaceKind ingress_interface); + bool handleLinkRequestPacket( + const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet, + reticulum::interfaces::InterfaceKind ingress_interface); bool handlePathRequestPacket(const reticulum::ParsedPacket& packet); bool handleCacheRequestPacket(const reticulum::ParsedPacket& packet); bool maybeForwardTransportPacket(const uint8_t* raw_packet, size_t raw_len, const reticulum::ParsedPacket& packet); bool maybeForwardLinkPacket(const uint8_t* raw_packet, size_t raw_len, const reticulum::ParsedPacket& packet); - bool handleLocalLinkPacket(const uint8_t* raw_packet, size_t raw_len, - const reticulum::ParsedPacket& packet); + bool handleLocalLinkPacket( + const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet, + reticulum::interfaces::InterfaceKind ingress_interface); bool sendProofForPacket(const uint8_t* raw_packet, size_t raw_len); bool sendPathRequest(PeerInfo& peer); + bool sendPathRequestForDestination( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]); bool shouldRequestPath(const PeerInfo& peer) const; LinkSession* ensureOutboundLinkSession(PeerInfo& peer, LocalDestinationKind kind, @@ -213,7 +222,8 @@ class LxmfAdapter : public IMeshAdapter const uint8_t* plaintext, size_t plaintext_len, uint8_t* out_packet, size_t* inout_len); bool routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, - bool allow_transport); + bool allow_transport, + bool wifi_only = false); bool sendCachedAnnounceResponse(const PathEntry& path, reticulum::PacketContext context); bool sendCachedPacketReplay(const uint8_t packet_hash[reticulum::kFullHashSize]); @@ -253,6 +263,7 @@ class LxmfAdapter : public IMeshAdapter LocalDestinationKind kind); PeerInfo* findPeerByNodeId(NodeId node_id); const PeerInfo* findPeerByDestinationHash(const uint8_t hash[reticulum::kTruncatedHashSize]) const; + const PeerInfo* findPeerByIdentityHash(const uint8_t hash[reticulum::kTruncatedHashSize]) const; const ReticulumGroupDestinationConfig* findConfiguredGroupDestination( const uint8_t hash[reticulum::kTruncatedHashSize]) const; bool isConfiguredGroupDestination( @@ -298,8 +309,10 @@ class LxmfAdapter : public IMeshAdapter bool sendLinkRtt(LinkSession& session); bool sendLinkKeepalive(LinkSession& session); bool sendLinkKeepaliveAck(LinkSession& session); + bool sendLinkIdentify(LinkSession& session); bool sendLinkPacketProof(LinkSession& session, const uint8_t* raw_packet, size_t raw_len); + void pumpReticulumAudioCall(); void closeLinkSession(LinkSession& session, LinkCloseReason reason = LinkCloseReason::LocalClose); void flushDeferredLinkPayloads(LinkSession& session); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h index 960665cf..4e740867 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h @@ -73,7 +73,8 @@ struct LinkRelayEntry enum class LocalDestinationKind : uint8_t { Delivery = 0, - Propagation = 1 + Propagation = 1, + CallAudio = 2 }; enum class LinkState : uint8_t diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h index 8bcb7b1d..43ebaf87 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h @@ -58,6 +58,8 @@ class MeshAdapterRouter : public IMeshAdapter bool hasPkiKey(NodeId dest) const override; bool triggerDiscoveryAction(MeshDiscoveryAction action) override; MeshActionResult triggerDiscoveryActionDetailed(MeshDiscoveryAction action) override; + MeshActionResult startReticulumAudioCall( + const ReticulumPeerIdentity& destination) override; void applyConfig(const MeshConfig& config) override; void setUserInfo(const char* long_name, const char* short_name) override; void setNetworkLimits(bool duty_cycle_enabled, uint8_t util_percent) override; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h index 2a96b289..0e08f621 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h @@ -57,6 +57,8 @@ class ReticulumAdapter final : public IMeshAdapter bool broadcastSelfIdentity() override; NodeId getNodeId() const override; bool getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* out) const override; + MeshActionResult startReticulumAudioCall( + const ReticulumPeerIdentity& destination) override; void applyConfig(const MeshConfig& config) override; void setUserInfo(const char* long_name, const char* short_name) override; bool isReady() const override; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h index 7bc4239a..7800560d 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h @@ -153,7 +153,10 @@ class ReticulumInterfaceSet void applyConfig(const MeshConfig& config); void maintain(); bool hasReadyInterface() const; + bool hasReadyWifiGateway() const; + bool wifiGatewayConfigured() const; bool sendPacket(const uint8_t* data, size_t len); + bool sendPacketWifiOnly(const uint8_t* data, size_t len); bool pollIncomingPacket(RxPacket* out); bool pollLegacyIncomingData(MeshIncomingData* out); void handleRawPacket(const uint8_t* data, size_t size); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h index 278e23a4..973de4c5 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h @@ -67,12 +67,29 @@ class SdStore final : public IChatStore char text[kMaxTextLen] = {}; } __attribute__((packed)); + struct RecordV3 + { + uint8_t protocol = 0; + uint8_t channel = 0; + uint8_t status = 0; + uint8_t flags = 0; + uint16_t text_len = 0; + uint32_t from = 0; + uint32_t peer = 0; + uint32_t msg_id = 0; + uint32_t timestamp = 0; + uint8_t reticulum_destination_hash[kReticulumPeerHashSize] = {}; + uint8_t reticulum_identity_hash[kReticulumPeerHashSize] = {}; + char text[kMaxTextLen] = {}; + } __attribute__((packed)); + struct Record { uint8_t protocol = 0; uint8_t channel = 0; uint8_t status = 0; uint8_t flags = 0; + uint8_t rx_origin = 0; uint16_t text_len = 0; uint32_t from = 0; uint32_t peer = 0; @@ -124,7 +141,9 @@ class SdStore final : public IChatStore static constexpr uint32_t kFileMagic = 0x474F4C43; // "CLOG" static constexpr uint32_t kIndexMagic = 0x54414843; // "CHAT" static constexpr uint16_t kLegacyVersion = 2; - static constexpr uint16_t kVersion = 3; + static constexpr uint16_t kReticulumIdentityVersion = 3; + static constexpr uint16_t kFileVersion = 4; + static constexpr uint16_t kIndexVersion = 3; bool ensureFs() const; bool ensureDir() const; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/reticulum_call_audio_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/reticulum_call_audio_runtime.h new file mode 100644 index 00000000..b0c770d6 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/reticulum_call_audio_runtime.h @@ -0,0 +1,13 @@ +/** + * @file reticulum_call_audio_runtime.h + * @brief ESP media hook registration for Reticulum call.audio. + */ + +#pragma once + +namespace platform::esp::arduino_common +{ + +void ensureReticulumCallAudioRuntimeRegistered(); + +} // namespace platform::esp::arduino_common diff --git a/platform/esp/arduino_common/library.json b/platform/esp/arduino_common/library.json index 958bd20e..0963f7ee 100644 --- a/platform/esp/arduino_common/library.json +++ b/platform/esp/arduino_common/library.json @@ -9,6 +9,9 @@ "srcDir": "src", "srcFilter": [ "+<*>", + "-", + "-", + "-", "-" ], "flags": [ @@ -25,7 +28,6 @@ "-I../../../modules/core_sys/include", "-I../../../modules/core_chat/include", "-I../../../modules/core_gps/include", - "-I../../../modules/core_hostlink/include", "-I../../../modules/core_team/include", "-I../../../modules/core_chat/generated", "-I../../../modules/core_chat/third_party/nanopb", diff --git a/platform/esp/arduino_common/src/app_event_runtime_support.cpp b/platform/esp/arduino_common/src/app_event_runtime_support.cpp index 0e9d4c72..40c19663 100644 --- a/platform/esp/arduino_common/src/app_event_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_event_runtime_support.cpp @@ -9,7 +9,6 @@ #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "platform/esp/arduino_common/app_runtime_support.h" -#include "platform/esp/arduino_common/hostlink/hostlink_bridge_radio.h" #include "platform/ui/settings_store.h" #include "sys/event_bus.h" #include "team/protocol/team_chat.h" @@ -192,8 +191,6 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) return true; } - hostlink::bridge::on_event(*event); - switch (event->type) { case sys::EventType::ChatSendResult: @@ -291,10 +288,6 @@ bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) if (team_runtime_event || event->type == sys::EventType::SystemTick) { team::ui::shell::handle_event(nullptr, event); - if (team_runtime_event) - { - hostlink::bridge::on_team_state_changed(); - } delete event; return true; } diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index 1091bb69..0a723b97 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -14,7 +14,9 @@ #include "platform/esp/arduino_common/app_tasks.h" #include "platform/esp/arduino_common/chat/infra/mesh_mqtt_client_runtime.h" #include "platform/esp/arduino_common/device_identity.h" -#include "platform/esp/arduino_common/hostlink/hostlink_service.h" +#include "platform/esp/arduino_common/reticulum_call_audio_runtime.h" +#include "platform/ui/gps_runtime.h" +#include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/settings_store.h" #include "platform/ui/tracker_runtime.h" #include "sys/clock.h" @@ -50,6 +52,24 @@ constexpr int kContactAlertsContacts = 1; constexpr int kContactAlertsAll = 2; constexpr uint32_t kContactAlertModeCacheMs = 10000; +bool s_call_realtime_resources_held = false; + +bool applyCallRealtimeResourceGuard() +{ + const bool active = ::platform::ui::reticulum_call::realtime_mode_active(); + if (active && !s_call_realtime_resources_held) + { + ::platform::ui::gps::suspend_runtime(); + s_call_realtime_resources_held = true; + } + else if (!active && s_call_realtime_resources_held) + { + ::platform::ui::gps::resume_runtime(); + s_call_realtime_resources_held = false; + } + return active; +} + int contactAlertMode() { static int cached_mode = -1; @@ -173,6 +193,11 @@ void tickBoundLifecycle(std::size_t max_events) void tickRuntime(app::IAppFacade& app_context) { + ensureReticulumCallAudioRuntimeRegistered(); + if (applyCallRealtimeResourceGuard()) + { + return; + } mesh_mqtt::update(app_context); ble::BleManager* ble_manager = app_context.getBleManager(); @@ -184,7 +209,10 @@ void tickRuntime(app::IAppFacade& app_context) void updateCoreServices(app::IAppFacade& app_context) { - hostlink::process_pending_commands(); + if (::platform::ui::reticulum_call::realtime_mode_active()) + { + return; + } platform::ui::tracker::poll(); diff --git a/platform/esp/arduino_common/src/app_tasks.cpp b/platform/esp/arduino_common/src/app_tasks.cpp index 67419610..f816af3c 100644 --- a/platform/esp/arduino_common/src/app_tasks.cpp +++ b/platform/esp/arduino_common/src/app_tasks.cpp @@ -4,7 +4,9 @@ */ #include "platform/esp/arduino_common/app_tasks.h" + #include "platform/esp/common/shared_spi_lock.h" +#include "platform/ui/reticulum_call_runtime.h" #include #include #include @@ -390,6 +392,13 @@ void AppTasks::radioTask(void* pvParameters) while (true) { + if (::platform::ui::reticulum_call::realtime_mode_active()) + { + requestRadioReceiveRestart(); + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + bool should_restart_rx = radio_receive_restart_pending_; bool handled_tx = false; @@ -563,6 +572,22 @@ void AppTasks::meshTask(void* pvParameters) { // Process received packets RadioPacket rx_packet; + if (::platform::ui::reticulum_call::realtime_mode_active()) + { + while (xQueueReceive(mesh_queue_, &rx_packet, 0) == pdPASS) + { + if (rx_packet.data) + { + free(rx_packet.data); + } + } + if (adapter_) + { + adapter_->processSendQueue(); + } + vTaskDelay(poll_delay); + continue; + } if (xQueueReceive(mesh_queue_, &rx_packet, 0) == pdPASS) { if (!rx_packet.is_tx && rx_packet.data && adapter_) diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp index 381b775e..3a53840e 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -16,6 +16,7 @@ #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_service_runtime.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_resource_runtime.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h" +#include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/reticulum_directory_runtime.h" #include "platform/ui/screen_runtime.h" #include "sys/event_bus.h" @@ -153,7 +154,16 @@ void formatHashHex(const uint8_t* hash, size_t hash_len, char* out, size_t out_l const char* localDestinationKindLabel(runtime::LocalDestinationKind kind) { - return kind == runtime::LocalDestinationKind::Propagation ? "propagation" : "delivery"; + switch (kind) + { + case runtime::LocalDestinationKind::Propagation: + return "propagation"; + case runtime::LocalDestinationKind::CallAudio: + return "call_audio"; + case runtime::LocalDestinationKind::Delivery: + default: + return "delivery"; + } } bool appendMsgpackByte(uint8_t value, uint8_t* out, size_t out_len, size_t& used) @@ -299,20 +309,36 @@ bool packPropagationAnnounceAppData(uint32_t timebase_s, return true; } -void destinationHashForAspect(const uint8_t identity_hash[reticulum::kTruncatedHashSize], - const char* aspect, - uint8_t out_hash[reticulum::kTruncatedHashSize]) +void destinationHashForServiceAspect( + const uint8_t identity_hash[reticulum::kTruncatedHashSize], + const char* app_name, + const char* aspect, + uint8_t out_hash[reticulum::kTruncatedHashSize]) { - if (!identity_hash || !aspect || !out_hash) + if (!identity_hash || !app_name || !aspect || !out_hash) { return; } uint8_t name_hash[reticulum::kNameHashSize] = {}; - reticulum::computeNameHash("lxmf", aspect, name_hash); + reticulum::computeNameHash(app_name, aspect, name_hash); reticulum::computeDestinationHash(name_hash, identity_hash, out_hash); } +void destinationHashForAspect(const uint8_t identity_hash[reticulum::kTruncatedHashSize], + const char* aspect, + uint8_t out_hash[reticulum::kTruncatedHashSize]) +{ + destinationHashForServiceAspect(identity_hash, "lxmf", aspect, out_hash); +} + +void callAudioDestinationHashForIdentity( + const uint8_t identity_hash[reticulum::kTruncatedHashSize], + uint8_t out_hash[reticulum::kTruncatedHashSize]) +{ + destinationHashForServiceAspect(identity_hash, "call", "audio", out_hash); +} + void fillRandomBytes(uint8_t* out, size_t len) { if (!out || len == 0) @@ -478,6 +504,18 @@ bool isLxmfPropagationAnnounce(const reticulum::ParsedAnnounce& announce) return hashesEqual(expected_name_hash, announce.name_hash, sizeof(expected_name_hash)); } +bool isCallAudioAnnounce(const reticulum::ParsedAnnounce& announce) +{ + if (!announce.valid || !announce.name_hash) + { + return false; + } + + uint8_t expected_name_hash[reticulum::kNameHashSize] = {}; + reticulum::computeNameHash("call", "audio", expected_name_hash); + return hashesEqual(expected_name_hash, announce.name_hash, sizeof(expected_name_hash)); +} + bool packetContextUsesRawLinkPayload(uint8_t context) { return context == static_cast(reticulum::PacketContext::Keepalive) || @@ -670,6 +708,7 @@ MeshCapabilities LxmfAdapter::getCapabilities() const caps.supports_broadcast_appdata = true; caps.provides_appdata_sender = true; caps.supports_node_info = true; + caps.supports_reticulum_audio_call = true; return caps; } @@ -1201,13 +1240,17 @@ bool LxmfAdapter::broadcastSelfIdentity() const bool delivery_complete = lastAnnounceTxReachedRequiredInterfaces(delivery_ok); const bool propagation_ok = sendAnnounce(LocalDestinationKind::Propagation); const bool propagation_complete = lastAnnounceTxReachedRequiredInterfaces(propagation_ok); - if (delivery_ok || propagation_ok) + const bool call_audio_ok = sendAnnounce(LocalDestinationKind::CallAudio); + const bool call_audio_complete = + !interfaces_.wifiGatewayConfigured() || + lastAnnounceTxReachedRequiredInterfaces(call_audio_ok); + if (delivery_ok || propagation_ok || call_audio_ok) { last_announce_ms_ = millis(); } last_announce_attempt_ms_ = millis(); - announce_pending_ = !(delivery_complete && propagation_complete); - return delivery_ok || propagation_ok; + announce_pending_ = !(delivery_complete && propagation_complete && call_audio_complete); + return delivery_ok || propagation_ok || call_audio_ok; } NodeId LxmfAdapter::getNodeId() const @@ -1243,6 +1286,130 @@ bool LxmfAdapter::getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* out) return true; } +MeshActionResult LxmfAdapter::startReticulumAudioCall( + const ReticulumPeerIdentity& destination) +{ + if (config_.reticulum_anonymous_peer) + { + return MeshActionResult::fail(MeshOperationFailure::Unsupported); + } + if (!chat::hasReticulumDestinationIdentity(destination) || !identity_.isReady()) + { + return MeshActionResult::fail(MeshOperationFailure::InvalidInput); + } + + interfaces_.maintain(); + if (!interfaces_.wifiGatewayConfigured() || !interfaces_.hasReadyWifiGateway()) + { + return MeshActionResult::fail(MeshOperationFailure::NotReady); + } + + const PeerInfo* peer = findPeerByDestinationHash(destination.destination_hash); + if (!peer && !isZeroBytes(destination.identity_hash, sizeof(destination.identity_hash))) + { + peer = findPeerByIdentityHash(destination.identity_hash); + } + + uint8_t remote_identity_hash[reticulum::kTruncatedHashSize] = {}; + if (peer && !isZeroBytes(peer->identity_hash, sizeof(peer->identity_hash))) + { + copyHash(remote_identity_hash, peer->identity_hash, sizeof(remote_identity_hash)); + } + else if (!isZeroBytes(destination.identity_hash, sizeof(destination.identity_hash))) + { + copyHash(remote_identity_hash, + destination.identity_hash, + sizeof(remote_identity_hash)); + } + else + { + return MeshActionResult::fail(MeshOperationFailure::PeerKeyMissing); + } + + uint8_t call_destination_hash[reticulum::kTruncatedHashSize] = {}; + callAudioDestinationHashForIdentity(remote_identity_hash, call_destination_hash); + if (isZeroBytes(call_destination_hash, sizeof(call_destination_hash))) + { + return MeshActionResult::fail(MeshOperationFailure::EncodeFailed); + } + + if (runtime::findOpenLinkSessionByDestination( + links_, call_destination_hash, LocalDestinationKind::CallAudio)) + { + return MeshActionResult::fail(MeshOperationFailure::Busy); + } + + const PathEntry* path = findPath(call_destination_hash); + if (!path) + { + (void)sendPathRequestForDestination(call_destination_hash); + return MeshActionResult::fail(MeshOperationFailure::NotReady, 1); + } + + LinkSession& session = runtime::appendLinkSession(links_, kMaxLinkSessions); + session.created_ms = millis(); + session.request_ms = session.created_ms; + session.last_inbound_ms = session.created_ms; + session.initiator = true; + session.destination = LocalDestinationKind::CallAudio; + session.state = LinkState::Pending; + session.close_reason = LinkCloseReason::None; + session.expected_hops = path->hops; + session.remote_identity_known = + !isZeroBytes(remote_identity_hash, sizeof(remote_identity_hash)); + session.validated = false; + session.keepalive_interval_ms = kLinkKeepaliveMaxMs; + session.stale_timeout_ms = kLinkKeepaliveMaxMs * 2U; + copyHash(session.remote_destination_hash, + call_destination_hash, + sizeof(session.remote_destination_hash)); + copyHash(session.remote_identity_hash, + remote_identity_hash, + sizeof(session.remote_identity_hash)); + if (peer) + { + memcpy(session.peer_identity_sig_pub, + peer->sig_pub, + sizeof(session.peer_identity_sig_pub)); + } + + Curve25519::dh1(session.local_enc_pub, session.local_enc_priv); + if (isZeroBytes(session.local_enc_priv, sizeof(session.local_enc_priv)) || + !generateLinkSigningKey(session.local_sig_pub, session.local_sig_priv) || + !sendLinkRequest(session)) + { + links_.sessions.pop_back(); + return MeshActionResult::fail(MeshOperationFailure::RadioTxFailed); + } + + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); + if (peer) + { + copyHash(call_peer.destination_hash, + peer->destination_hash, + sizeof(call_peer.destination_hash)); + copyHash(call_peer.identity_hash, + peer->identity_hash, + sizeof(call_peer.identity_hash)); + call_peer.display_name = + peer->display_name[0] != '\0' ? peer->display_name : nullptr; + } + else + { + copyHash(call_peer.destination_hash, + destination.destination_hash, + sizeof(call_peer.destination_hash)); + copyHash(call_peer.identity_hash, + remote_identity_hash, + sizeof(call_peer.identity_hash)); + } + call_peer.incoming = false; + (void)::platform::ui::reticulum_call::begin_outgoing(call_peer); + + return MeshActionResult::success(); +} + void LxmfAdapter::applyConfig(const MeshConfig& config) { config_ = config; @@ -1325,6 +1492,18 @@ void LxmfAdapter::processSendQueue() LxmfAdapter::RuntimeBudget LxmfAdapter::makeRuntimeBudget() const { RuntimeBudget budget{}; + if (::platform::ui::reticulum_call::realtime_mode_active()) + { + budget.live_packet_limit = 2; + budget.deferred_discovery_limit = 0; + budget.allow_public_discovery = false; + budget.allow_persistence = false; + budget.allow_peer_projection = false; + budget.allow_announce_tx = false; + budget.phase = "call"; + return budget; + } + const bool maintenance_window = screen_runtime::is_sleeping() && !screen_runtime::is_saver_active(); if (maintenance_window) @@ -1352,6 +1531,13 @@ LxmfAdapter::RuntimeBudget LxmfAdapter::makeRuntimeBudget() const void LxmfAdapter::processRuntime() { const RuntimeBudget budget = makeRuntimeBudget(); + if (::platform::ui::reticulum_call::realtime_mode_active()) + { + processRadioPackets(budget); + pumpReticulumAudioCall(); + return; + } + cullTransportState(); cullLinkSessions(); const runtime::PropagationRuntimeLimits propagation_limits{ @@ -1366,6 +1552,7 @@ void LxmfAdapter::processRuntime() propagation_limits); processRadioPackets(budget); + pumpReticulumAudioCall(); processDeferredDiscoveryPackets(budget); if (budget.allow_peer_projection) @@ -1494,17 +1681,17 @@ bool LxmfAdapter::processOneRadioPacket( } if (parsed.packet_type == reticulum::PacketType::Proof) { - return handleProofPacket(packet, packet_len, parsed); + return handleProofPacket(packet, packet_len, parsed, ingress_interface); } if (parsed.packet_type == reticulum::PacketType::LinkRequest) { - return handleLinkRequestPacket(packet, packet_len, parsed); + return handleLinkRequestPacket(packet, packet_len, parsed, ingress_interface); } if (parsed.packet_type == reticulum::PacketType::Data) { if (!handlePathRequestPacket(parsed) && !handleCacheRequestPacket(parsed) && - !handleLocalLinkPacket(packet, packet_len, parsed) && + !handleLocalLinkPacket(packet, packet_len, parsed, ingress_interface) && !maybeForwardLinkPacket(packet, packet_len, parsed) && !maybeForwardTransportPacket(packet, packet_len, parsed)) { @@ -1513,7 +1700,8 @@ bool LxmfAdapter::processOneRadioPacket( return true; } - const bool handled_local = handleLocalLinkPacket(packet, packet_len, parsed); + const bool handled_local = + handleLocalLinkPacket(packet, packet_len, parsed, ingress_interface); const bool handled_link = maybeForwardLinkPacket(packet, packet_len, parsed); const bool handled_transport = maybeForwardTransportPacket(packet, packet_len, parsed); return handled_local || handled_link || handled_transport; @@ -1646,12 +1834,16 @@ void LxmfAdapter::maybeAnnounce() const bool delivery_complete = lastAnnounceTxReachedRequiredInterfaces(delivery_ok); const bool propagation_ok = sendAnnounce(LocalDestinationKind::Propagation); const bool propagation_complete = lastAnnounceTxReachedRequiredInterfaces(propagation_ok); + const bool call_audio_ok = sendAnnounce(LocalDestinationKind::CallAudio); + const bool call_audio_complete = + !interfaces_.wifiGatewayConfigured() || + lastAnnounceTxReachedRequiredInterfaces(call_audio_ok); last_announce_attempt_ms_ = now_ms; - if (delivery_ok || propagation_ok) + if (delivery_ok || propagation_ok || call_audio_ok) { last_announce_ms_ = now_ms; } - announce_pending_ = !(delivery_complete && propagation_complete); + announce_pending_ = !(delivery_complete && propagation_complete && call_audio_complete); if (!announce_pending_) { last_announce_attempt_ms_ = 0; @@ -1669,7 +1861,20 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, static_cast(context)); return false; } - if (!isReady()) + const bool call_audio = kind == LocalDestinationKind::CallAudio; + if (call_audio && !interfaces_.wifiGatewayConfigured()) + { + return false; + } + if (call_audio) + { + interfaces_.maintain(); + if (!identity_.isReady() || !interfaces_.hasReadyWifiGateway()) + { + return false; + } + } + else if (!isReady()) { return false; } @@ -1677,7 +1882,17 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, uint8_t app_data[96] = {}; size_t app_data_len = sizeof(app_data); bool app_data_ok = false; - if (kind == LocalDestinationKind::Propagation) + if (kind == LocalDestinationKind::CallAudio) + { + const char* display_name = effectiveDisplayName(); + app_data_len = display_name ? std::min(strlen(display_name), sizeof(app_data)) : 0; + if (app_data_len != 0) + { + memcpy(app_data, display_name, app_data_len); + } + app_data_ok = true; + } + else if (kind == LocalDestinationKind::Propagation) { app_data_ok = packPropagationAnnounceAppData(currentTimestampSeconds(), true, @@ -1707,9 +1922,16 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, localDestinationHash(kind, destination_hash); uint8_t name_hash[reticulum::kNameHashSize] = {}; - reticulum::computeNameHash("lxmf", - (kind == LocalDestinationKind::Propagation) ? "propagation" : "delivery", - name_hash); + if (kind == LocalDestinationKind::CallAudio) + { + reticulum::computeNameHash("call", "audio", name_hash); + } + else + { + reticulum::computeNameHash("lxmf", + (kind == LocalDestinationKind::Propagation) ? "propagation" : "delivery", + name_hash); + } uint8_t random_hash[10] = {}; fillRandomBytes(random_hash, 5); @@ -1780,7 +2002,9 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, sizeof(destination_hash), destination_hex, sizeof(destination_hex)); - const bool sent = routeAndSendPacket(packet, packet_len, false); + const bool sent = call_audio + ? interfaces_.sendPacketWifiOnly(packet, packet_len) + : routeAndSendPacket(packet, packet_len, false); const auto& tx_result = interfaces_.lastTxResult(); const char* display_name = effectiveDisplayName(); Serial.printf("[LXMF][AnnounceTX] kind=%s context=%u dest=%s name=%s app_len=%u packet_len=%u ok=%u complete=%u lora=%u/%u wifi=%u/%u\n", @@ -1940,6 +2164,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len const bool delivery_announce = isLxmfDeliveryAnnounce(announce); const bool propagation_announce = isLxmfPropagationAnnounce(announce); + const bool call_audio_announce = isCallAudioAnnounce(announce); LocalDestinationKind local_kind = LocalDestinationKind::Delivery; const bool local_destination = isLocalDestinationHash(packet.destination_hash, &local_kind); @@ -1957,13 +2182,27 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len char announce_display_name[32] = {}; bool has_stamp_cost = false; uint8_t stamp_cost = 0; - if (delivery_announce && announce.app_data && announce.app_data_len != 0 && - unpackPeerAnnounceAppData(announce.app_data, - announce.app_data_len, - announce_display_name, - sizeof(announce_display_name), - &has_stamp_cost, - &stamp_cost)) + if (call_audio_announce && announce.app_data && announce.app_data_len != 0) + { + const size_t name_len = + std::min(announce.app_data_len, sizeof(announce_display_name) - 1U); + memcpy(announce_display_name, announce.app_data, name_len); + announce_display_name[name_len] = '\0'; + for (char& ch : announce_display_name) + { + if (ch == '\t' || ch == '\r' || ch == '\n') + { + ch = ' '; + } + } + } + else if (delivery_announce && announce.app_data && announce.app_data_len != 0 && + unpackPeerAnnounceAppData(announce.app_data, + announce.app_data_len, + announce_display_name, + sizeof(announce_display_name), + &has_stamp_cost, + &stamp_cost)) { (void)has_stamp_cost; (void)stamp_cost; @@ -1978,9 +2217,12 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len identity_hash, sizeof(directory_announce.identity_hash)); directory_announce.aspect = - delivery_announce ? rtdir::AnnounceAspect::LxmfDelivery - : (propagation_announce ? rtdir::AnnounceAspect::LxmfPropagation - : rtdir::AnnounceAspect::Unknown); + delivery_announce + ? rtdir::AnnounceAspect::LxmfDelivery + : (propagation_announce + ? rtdir::AnnounceAspect::LxmfPropagation + : (call_audio_announce ? rtdir::AnnounceAspect::CallAudio + : rtdir::AnnounceAspect::Unknown)); directory_announce.source = packet.context == static_cast(reticulum::PacketContext::PathResponse) ? rtdir::EntrySource::PathResponse @@ -2016,7 +2258,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len local_destination; if (log_announce_detail) { - Serial.printf("[LXMF][AnnounceRX] seen dest=%s identity=%s hops=%u context=%u app_len=%u delivery=%u propagation=%u local=%u kind=%s\n", + Serial.printf("[LXMF][AnnounceRX] seen dest=%s identity=%s hops=%u context=%u app_len=%u delivery=%u propagation=%u call=%u local=%u kind=%s\n", packet_hash_hex, identity_hash_hex, static_cast(packet.hops), @@ -2024,6 +2266,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len static_cast(announce.app_data_len), delivery_announce ? 1U : 0U, propagation_announce ? 1U : 0U, + call_audio_announce ? 1U : 0U, local_destination ? 1U : 0U, localDestinationKindLabel(local_kind)); } @@ -2041,18 +2284,28 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len runtime::markPropagationPeerSeen(propagation_peer, now_s); } - if (!delivery_announce || local_destination) + if (!(delivery_announce || call_audio_announce) || local_destination) { if (log_announce_detail) { Serial.printf("[LXMF][AnnounceRX] ignore reason=%s dest=%s\n", - local_destination ? "local_destination" : "not_delivery", + local_destination ? "local_destination" : "not_contact_announce", packet_hash_hex); } return true; } - PeerInfo& peer = upsertPeer(packet.destination_hash); + uint8_t peer_destination_hash[reticulum::kTruncatedHashSize] = {}; + if (delivery_announce) + { + copyHash(peer_destination_hash, packet.destination_hash, sizeof(peer_destination_hash)); + } + else + { + destinationHashForAspect(identity_hash, "delivery", peer_destination_hash); + } + + PeerInfo& peer = upsertPeer(peer_destination_hash); const uint32_t previous_seen_s = peer.last_seen_s; const bool identity_changed = isZeroBytes(peer.identity_hash, sizeof(peer.identity_hash)) || @@ -2273,8 +2526,10 @@ bool LxmfAdapter::handleDataPacket(const uint8_t* raw_packet, size_t raw_len, return true; } -bool LxmfAdapter::handleProofPacket(const uint8_t* raw_packet, size_t raw_len, - const reticulum::ParsedPacket& packet) +bool LxmfAdapter::handleProofPacket( + const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet, + reticulum::interfaces::InterfaceKind ingress_interface) { if (!raw_packet || !packet.destination_hash || packet.packet_type != reticulum::PacketType::Proof) { @@ -2282,7 +2537,7 @@ bool LxmfAdapter::handleProofPacket(const uint8_t* raw_packet, size_t raw_len, } if (packet.destination_type == reticulum::DestinationType::Link && - handleLocalLinkPacket(raw_packet, raw_len, packet)) + handleLocalLinkPacket(raw_packet, raw_len, packet, ingress_interface)) { return true; } @@ -2324,8 +2579,10 @@ bool LxmfAdapter::handleProofPacket(const uint8_t* raw_packet, size_t raw_len, return interfaces_.sendPacket(forward_packet, forward_len); } -bool LxmfAdapter::handleLinkRequestPacket(const uint8_t* raw_packet, size_t raw_len, - const reticulum::ParsedPacket& packet) +bool LxmfAdapter::handleLinkRequestPacket( + const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet, + reticulum::interfaces::InterfaceKind ingress_interface) { if (!raw_packet || raw_len == 0 || !packet.destination_hash || packet.packet_type != reticulum::PacketType::LinkRequest) @@ -2342,6 +2599,12 @@ bool LxmfAdapter::handleLinkRequestPacket(const uint8_t* raw_packet, size_t raw_ LocalDestinationKind local_kind = LocalDestinationKind::Delivery; if (isLocalDestinationHash(packet.destination_hash, &local_kind)) { + if (local_kind == LocalDestinationKind::CallAudio && + ingress_interface != reticulum::interfaces::InterfaceKind::WifiGateway) + { + return false; + } + if (!packet.payload || (packet.payload_len != kLinkRequestBaseLen && packet.payload_len != (kLinkRequestBaseLen + kLinkSignallingLen))) @@ -2396,6 +2659,15 @@ bool LxmfAdapter::handleLinkRequestPacket(const uint8_t* raw_packet, size_t raw_ links_.sessions.pop_back(); return false; } + + if (local_kind == LocalDestinationKind::CallAudio) + { + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, session->link_id, sizeof(call_peer.link_id)); + call_peer.display_name = "Incoming call"; + call_peer.incoming = true; + (void)::platform::ui::reticulum_call::begin_incoming(call_peer); + } } else { @@ -2535,8 +2807,10 @@ bool LxmfAdapter::handleCacheRequestPacket(const reticulum::ParsedPacket& packet return sendCachedPacketReplay(packet.payload); } -bool LxmfAdapter::handleLocalLinkPacket(const uint8_t* raw_packet, size_t raw_len, - const reticulum::ParsedPacket& packet) +bool LxmfAdapter::handleLocalLinkPacket( + const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet, + reticulum::interfaces::InterfaceKind ingress_interface) { if (!packet.destination_hash || packet.destination_type != reticulum::DestinationType::Link) @@ -2549,6 +2823,11 @@ bool LxmfAdapter::handleLocalLinkPacket(const uint8_t* raw_packet, size_t raw_le { return false; } + if (session->destination == LocalDestinationKind::CallAudio && + ingress_interface != reticulum::interfaces::InterfaceKind::WifiGateway) + { + return false; + } session->last_inbound_ms = millis(); if (packet.packet_type == reticulum::PacketType::Proof) @@ -2595,6 +2874,13 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, { handled = handlePropagationBatch(session, payload_ptr, payload_len); } + else if (session.destination == LocalDestinationKind::CallAudio) + { + handled = ::platform::ui::reticulum_call::enqueue_inbound_audio( + session.link_id, + payload_ptr, + payload_len); + } else { handled = acceptVerifiedEnvelope(payload_ptr, payload_len, raw_packet, raw_len); @@ -2619,9 +2905,12 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, PeerInfo* peer = rememberPeerIdentity(combined_pub); if (peer) { - copyHash(session.remote_destination_hash, - peer->destination_hash, - sizeof(session.remote_destination_hash)); + if (session.destination != LocalDestinationKind::CallAudio) + { + copyHash(session.remote_destination_hash, + peer->destination_hash, + sizeof(session.remote_destination_hash)); + } copyHash(session.remote_identity_hash, peer->identity_hash, sizeof(session.remote_identity_hash)); @@ -2629,6 +2918,21 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, peer->sig_pub, sizeof(session.peer_identity_sig_pub)); session.remote_identity_known = true; + if (session.destination == LocalDestinationKind::CallAudio) + { + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); + copyHash(call_peer.destination_hash, + peer->destination_hash, + sizeof(call_peer.destination_hash)); + copyHash(call_peer.identity_hash, + peer->identity_hash, + sizeof(call_peer.identity_hash)); + call_peer.display_name = + peer->display_name[0] != '\0' ? peer->display_name : nullptr; + call_peer.incoming = !session.initiator; + ::platform::ui::reticulum_call::update_peer(call_peer); + } } handled = true; } @@ -2695,6 +2999,11 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, session.stale_timeout_ms = session.keepalive_interval_ms * 2U; session.last_keepalive_ms = 0; session.state = LinkState::Active; + if (session.destination == LocalDestinationKind::CallAudio) + { + (void)sendLinkIdentify(session); + ::platform::ui::reticulum_call::mark_link_active(session.link_id); + } handled = true; } } @@ -2798,8 +3107,19 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, : nullptr; const size_t signalling_len = signalling ? kLinkSignallingLen : 0; - const PeerInfo* peer = findPeerByDestinationHash(session.remote_destination_hash); - if (!peer) + const PeerInfo* peer = + session.destination == LocalDestinationKind::CallAudio + ? findPeerByIdentityHash(session.remote_identity_hash) + : findPeerByDestinationHash(session.remote_destination_hash); + const uint8_t* peer_sig_pub = peer ? peer->sig_pub : nullptr; + if (!peer_sig_pub && + session.destination == LocalDestinationKind::CallAudio && + !isZeroBytes(session.peer_identity_sig_pub, + sizeof(session.peer_identity_sig_pub))) + { + peer_sig_pub = session.peer_identity_sig_pub; + } + if (!peer_sig_pub) { return false; } @@ -2812,7 +3132,7 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, used += reticulum::kTruncatedHashSize; memcpy(signed_data.data() + used, peer_enc_pub, LxmfIdentity::kEncPubKeySize); used += LxmfIdentity::kEncPubKeySize; - memcpy(signed_data.data() + used, peer->sig_pub, LxmfIdentity::kSigPubKeySize); + memcpy(signed_data.data() + used, peer_sig_pub, LxmfIdentity::kSigPubKeySize); used += LxmfIdentity::kSigPubKeySize; if (signalling_len != 0) { @@ -2820,14 +3140,21 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, used += signalling_len; } - if (!LxmfIdentity::verify(peer->sig_pub, signature, signed_data.data(), used)) + if (!LxmfIdentity::verify(peer_sig_pub, signature, signed_data.data(), used)) { return false; } memcpy(session.peer_enc_pub, peer_enc_pub, sizeof(session.peer_enc_pub)); - memcpy(session.peer_identity_sig_pub, peer->sig_pub, sizeof(session.peer_identity_sig_pub)); - copyHash(session.remote_identity_hash, peer->identity_hash, sizeof(session.remote_identity_hash)); + memcpy(session.peer_identity_sig_pub, + peer_sig_pub, + sizeof(session.peer_identity_sig_pub)); + if (peer) + { + copyHash(session.remote_identity_hash, + peer->identity_hash, + sizeof(session.remote_identity_hash)); + } session.remote_identity_known = true; session.mtu = signalling ? mtuFromLinkSignalling(signalling, signalling_len) : reticulum::kReticulumMtu; session.mdu = linkMduForMtu(session.mtu); @@ -2843,6 +3170,26 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, session.last_keepalive_ms = 0; session.state = LinkState::Active; const bool rtt_sent = sendLinkRtt(session); + if (session.destination == LocalDestinationKind::CallAudio) + { + (void)sendLinkIdentify(session); + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); + copyHash(call_peer.identity_hash, + session.remote_identity_hash, + sizeof(call_peer.identity_hash)); + if (peer) + { + copyHash(call_peer.destination_hash, + peer->destination_hash, + sizeof(call_peer.destination_hash)); + call_peer.display_name = + peer->display_name[0] != '\0' ? peer->display_name : nullptr; + } + call_peer.incoming = false; + ::platform::ui::reticulum_call::update_peer(call_peer); + ::platform::ui::reticulum_call::mark_link_active(session.link_id); + } flushDeferredLinkPayloads(session); return rtt_sent; } @@ -3731,6 +4078,58 @@ bool LxmfAdapter::sendPathRequest(PeerInfo& peer) return true; } +bool LxmfAdapter::sendPathRequestForDestination( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) +{ + if (!destination_hash || + isZeroBytes(destination_hash, reticulum::kTruncatedHashSize) || + !identity_.isReady()) + { + return false; + } + + const uint32_t now_ms = millis(); + if (const PendingPathRequest* pending = findPendingPathRequest(destination_hash)) + { + if (!pending->resolved && + pending->last_attempt_ms != 0 && + (now_ms - pending->last_attempt_ms) < kPathRequestMinIntervalMs) + { + return false; + } + } + + uint8_t request_payload[reticulum::kTruncatedHashSize + kPathRequestTagSize] = {}; + memcpy(request_payload, destination_hash, reticulum::kTruncatedHashSize); + fillRandomBytes(request_payload + reticulum::kTruncatedHashSize, kPathRequestTagSize); + + uint8_t control_hash[reticulum::kTruncatedHashSize] = {}; + pathRequestDestinationHash(control_hash); + + uint8_t packet[kMaxPacketLen] = {}; + size_t packet_len = sizeof(packet); + if (!reticulum::buildHeader1Packet(reticulum::PacketType::Data, + reticulum::DestinationType::Plain, + reticulum::PacketContext::None, + false, + control_hash, + request_payload, + sizeof(request_payload), + packet, + &packet_len)) + { + return false; + } + + if (!routeAndSendPacket(packet, packet_len, false, true)) + { + return false; + } + + notePendingPathRequest(destination_hash, now_ms); + return true; +} + bool LxmfAdapter::shouldRequestPath(const PeerInfo& peer) const { if (isZeroBytes(peer.destination_hash, sizeof(peer.destination_hash))) @@ -3878,7 +4277,11 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) } session.request_ms = millis(); - const bool sent = routeAndSendPacket(packet, packet_len, true); + const bool sent = + routeAndSendPacket(packet, + packet_len, + true, + session.destination == LocalDestinationKind::CallAudio); if (sent) { session.last_outbound_ms = session.request_ms; @@ -4066,22 +4469,29 @@ bool LxmfAdapter::buildEncryptedPacketForPeer(const PeerInfo& peer, } bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, - bool allow_transport) + bool allow_transport, + bool wifi_only) { if (!raw_packet || raw_len == 0) { return false; } + auto send_packet = [&](const uint8_t* data, size_t len) -> bool + { + return wifi_only ? interfaces_.sendPacketWifiOnly(data, len) + : interfaces_.sendPacket(data, len); + }; + if (!allow_transport) { - return interfaces_.sendPacket(raw_packet, raw_len); + return send_packet(raw_packet, raw_len); } reticulum::ParsedPacket parsed{}; if (!reticulum::parsePacket(raw_packet, raw_len, &parsed) || !parsed.destination_hash) { - return interfaces_.sendPacket(raw_packet, raw_len); + return send_packet(raw_packet, raw_len); } if (parsed.packet_type == reticulum::PacketType::Announce || @@ -4089,13 +4499,13 @@ bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, parsed.destination_type == reticulum::DestinationType::Plain || parsed.destination_type == reticulum::DestinationType::Group) { - return interfaces_.sendPacket(raw_packet, raw_len); + return send_packet(raw_packet, raw_len); } const PathEntry* path = findPath(parsed.destination_hash); if (!path || path->hops <= 1 || path->direct) { - return interfaces_.sendPacket(raw_packet, raw_len); + return send_packet(raw_packet, raw_len); } uint8_t routed_packet[kMaxPacketLen] = {}; @@ -4115,7 +4525,7 @@ bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, return false; } - return interfaces_.sendPacket(routed_packet, routed_len); + return send_packet(routed_packet, routed_len); } bool LxmfAdapter::sendCachedAnnounceResponse(const PathEntry& path, @@ -4510,6 +4920,10 @@ void LxmfAdapter::localDestinationHash(LocalDestinationKind kind, { reticulum::computeNameHash("lxmf", "propagation", name_hash); } + else if (kind == LocalDestinationKind::CallAudio) + { + reticulum::computeNameHash("call", "audio", name_hash); + } else { reticulum::computeNameHash("lxmf", "delivery", name_hash); @@ -4547,6 +4961,17 @@ bool LxmfAdapter::isLocalDestinationHash(const uint8_t hash[reticulum::kTruncate return true; } + uint8_t call_audio_hash[reticulum::kTruncatedHashSize] = {}; + localDestinationHash(LocalDestinationKind::CallAudio, call_audio_hash); + if (hashesEqual(hash, call_audio_hash, sizeof(call_audio_hash))) + { + if (out_kind) + { + *out_kind = LocalDestinationKind::CallAudio; + } + return true; + } + return false; } @@ -4705,7 +5130,9 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, return false; } - const bool ok = interfaces_.sendPacket(packet, packet_len); + const bool ok = session.destination == LocalDestinationKind::CallAudio + ? interfaces_.sendPacketWifiOnly(packet, packet_len) + : interfaces_.sendPacket(packet, packet_len); if (ok) { session.last_outbound_ms = millis(); @@ -4797,6 +5224,40 @@ bool LxmfAdapter::sendLinkKeepaliveAck(LinkSession& session) false); } +bool LxmfAdapter::sendLinkIdentify(LinkSession& session) +{ + if (!identity_.isReady() || session.state != LinkState::Active) + { + return false; + } + + uint8_t combined_pub[reticulum::kCombinedPublicKeySize] = {}; + identity_.combinedPublicKey(combined_pub); + + std::array + signed_data{}; + memcpy(signed_data.data(), session.link_id, reticulum::kTruncatedHashSize); + memcpy(signed_data.data() + reticulum::kTruncatedHashSize, + combined_pub, + reticulum::kCombinedPublicKeySize); + + uint8_t payload[reticulum::kCombinedPublicKeySize + reticulum::kSignatureSize] = {}; + memcpy(payload, combined_pub, sizeof(combined_pub)); + if (!identity_.sign(signed_data.data(), + signed_data.size(), + payload + reticulum::kCombinedPublicKeySize)) + { + return false; + } + + return sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkIdentify, + payload, + sizeof(payload), + true); +} + bool LxmfAdapter::sendLinkPacketProof(LinkSession& session, const uint8_t* raw_packet, size_t raw_len) { @@ -5065,6 +5526,55 @@ bool LxmfAdapter::queueOutgoingResource(LinkSession& session, return true; } +void LxmfAdapter::pumpReticulumAudioCall() +{ + ::platform::ui::reticulum_call::set_wifi_ready( + interfaces_.hasReadyWifiGateway()); + + uint8_t hangup_link_id[reticulum::kTruncatedHashSize] = {}; + if (::platform::ui::reticulum_call::consume_hangup_request(hangup_link_id)) + { + if (LinkSession* session = findLinkSession(hangup_link_id)) + { + if (session->destination == LocalDestinationKind::CallAudio) + { + (void)sendLinkPacket(*session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkClose, + session->link_id, + sizeof(session->link_id), + true); + closeLinkSession(*session, LinkCloseReason::LocalClose); + } + } + } + + uint8_t sent_packets = 0; + ::platform::ui::reticulum_call::AudioPacket audio{}; + while (sent_packets < 2 && + ::platform::ui::reticulum_call::dequeue_outbound_audio(&audio)) + { + LinkSession* session = findLinkSession(audio.link_id); + if (!session || + session->destination != LocalDestinationKind::CallAudio || + session->state != LinkState::Active) + { + continue; + } + + if (sendLinkPacket(*session, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + audio.data, + audio.len, + true)) + { + ::platform::ui::reticulum_call::note_tx_sent(); + ++sent_packets; + } + } +} + void LxmfAdapter::closeLinkSession(LinkSession& session, LinkCloseReason reason) { const bool transitioned = runtime::closeLinkSession(session, reason, millis()); @@ -5073,6 +5583,11 @@ void LxmfAdapter::closeLinkSession(LinkSession& session, LinkCloseReason reason) return; } + if (session.destination == LocalDestinationKind::CallAudio) + { + ::platform::ui::reticulum_call::notify_link_closed(session.link_id); + } + transport_.link_relays.erase( std::remove_if(transport_.link_relays.begin(), transport_.link_relays.end(), @@ -5536,6 +6051,23 @@ const LxmfAdapter::PeerInfo* LxmfAdapter::findPeerByDestinationHash( return nullptr; } +const LxmfAdapter::PeerInfo* LxmfAdapter::findPeerByIdentityHash( + const uint8_t hash[reticulum::kTruncatedHashSize]) const +{ + if (!hash) + { + return nullptr; + } + for (const auto& peer : peers_) + { + if (hashesEqual(peer.identity_hash, hash, reticulum::kTruncatedHashSize)) + { + return &peer; + } + } + return nullptr; +} + const ReticulumGroupDestinationConfig* LxmfAdapter::findConfiguredGroupDestination( const uint8_t hash[reticulum::kTruncatedHashSize]) const { @@ -5952,7 +6484,7 @@ void LxmfAdapter::populateRxMeta(RxMeta* out) const out->time_source = is_valid_epoch(out->rx_timestamp_s) ? RxTimeSource::DeviceUtc : RxTimeSource::Uptime; - out->origin = RxOrigin::Mesh; + out->origin = RxOrigin::LoRa; out->direct = true; out->from_is = false; out->rssi_dbm_x10 = static_cast(lround(interfaces_.lastRxRssi() * 10.0f)); diff --git a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp index a9753312..5c30817c 100644 --- a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp +++ b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp @@ -209,6 +209,17 @@ MeshActionResult MeshAdapterRouter::triggerDiscoveryActionDetailed(MeshDiscovery return core_.triggerDiscoveryActionDetailed(action); } +MeshActionResult MeshAdapterRouter::startReticulumAudioCall( + const ReticulumPeerIdentity& destination) +{ + LockGuard lock(mutex_); + if (!lock.locked()) + { + return MeshActionResult::fail(MeshOperationFailure::Busy); + } + return core_.startReticulumAudioCall(destination); +} + void MeshAdapterRouter::applyConfig(const MeshConfig& config) { LockGuard lock(mutex_); diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp index 5e2d8ab6..7f93284f 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp @@ -88,6 +88,12 @@ bool ReticulumAdapter::getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* return service_->getReticulumLocalIdentityInfo(out); } +MeshActionResult ReticulumAdapter::startReticulumAudioCall( + const ReticulumPeerIdentity& destination) +{ + return service_->startReticulumAudioCall(destination); +} + void ReticulumAdapter::applyConfig(const MeshConfig& config) { service_->applyConfig(config); diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp index df0e006e..5d0155fd 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp @@ -113,7 +113,7 @@ bool LoRaReticulumInterface::pollPacket(RxPacket* out) out->len = len; out->interface_kind = InterfaceKind::LoRa; fillTimestamp(&out->rx_meta); - out->rx_meta.origin = RxOrigin::Mesh; + out->rx_meta.origin = RxOrigin::LoRa; out->rx_meta.direct = true; out->rx_meta.from_is = false; out->rx_meta.rssi_dbm_x10 = static_cast(std::lround(raw_.lastRxRssi() * 10.0f)); @@ -631,7 +631,7 @@ void WifiGatewayReticulumInterface::fillRxMeta(RxMeta* out) const return; } fillTimestamp(out); - out->origin = RxOrigin::Mesh; + out->origin = RxOrigin::WiFi; out->direct = true; out->from_is = false; out->rssi_dbm_x10 = 0; @@ -669,6 +669,16 @@ bool ReticulumInterfaceSet::hasReadyInterface() const (wifiAllowed() && wifi_.isReady()); } +bool ReticulumInterfaceSet::hasReadyWifiGateway() const +{ + return wifiAllowed() && wifi_.isReady(); +} + +bool ReticulumInterfaceSet::wifiGatewayConfigured() const +{ + return wifiAllowed() && wifi_.isConfigured(); +} + bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) { last_tx_result_ = {}; @@ -706,6 +716,33 @@ bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) return sent; } +bool ReticulumInterfaceSet::sendPacketWifiOnly(const uint8_t* data, size_t len) +{ + last_tx_result_ = {}; + if (!data || len == 0) + { + return false; + } + + maintain(); + + last_tx_result_.wifi_required = wifiAllowed() && wifi_.isConfigured(); + last_tx_result_.wifi_ready = last_tx_result_.wifi_required && wifi_.isReady(); + if (last_tx_result_.wifi_ready) + { + last_tx_result_.wifi_ok = wifi_.sendPacket(data, len); + } + + const bool sent = last_tx_result_.sent(); + Serial.printf("[Reticulum][IF][TX] raw_len=%u mode=wifi_only wifi_req=%u wifi_ready=%u wifi=%u sent=%u\n", + static_cast(len), + last_tx_result_.wifi_required ? 1U : 0U, + last_tx_result_.wifi_ready ? 1U : 0U, + last_tx_result_.wifi_ok ? 1U : 0U, + sent ? 1U : 0U); + return sent; +} + bool ReticulumInterfaceSet::pollIncomingPacket(RxPacket* out) { if (!out) diff --git a/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp b/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp index d834c701..956fc40c 100644 --- a/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp +++ b/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp @@ -528,7 +528,7 @@ bool SdStore::readIndex(std::vector& entries) const IndexHeader header{}; if (!readExact(file, &header, sizeof(header)) || header.magic != kIndexMagic || - (header.version != kVersion && header.version != kLegacyVersion)) + (header.version != kIndexVersion && header.version != kLegacyVersion)) { file.close(); return false; @@ -598,7 +598,7 @@ bool SdStore::writeIndex(const std::vector& entries) const IndexHeader header{}; header.magic = kIndexMagic; - header.version = kVersion; + header.version = kIndexVersion; header.count = static_cast(std::min(entries.size(), 0xFFFFU)); bool ok = writeExact(file, &header, sizeof(header)); for (size_t index = 0; ok && index < header.count; ++index) @@ -833,7 +833,9 @@ bool SdStore::loadFileHeader(storage::SdRuntimeFile& file, FileHeader& header) c return false; } return header.magic == kFileMagic && - (header.version == kVersion || header.version == kLegacyVersion) && + (header.version == kFileVersion || + header.version == kReticulumIdentityVersion || + header.version == kLegacyVersion) && header.head < kMaxMessagesPerConv && header.count <= kMaxMessagesPerConv; } @@ -841,7 +843,7 @@ bool SdStore::initFileHeader(storage::SdRuntimeFile& file) const { FileHeader header{}; header.magic = kFileMagic; - header.version = kVersion; + header.version = kFileVersion; if (!file.seek(0) || !writeExact(file, &header, sizeof(header))) { return false; @@ -853,11 +855,13 @@ bool SdStore::upgradeConversationFile(storage::SdRuntimeFile& file, const char* path, FileHeader& header) const { - if (header.version == kVersion) + if (header.version == kFileVersion) { return true; } - if (header.version != kLegacyVersion || !path || path[0] == '\0') + if ((header.version != kLegacyVersion && + header.version != kReticulumIdentityVersion) || + !path || path[0] == '\0') { return false; } @@ -884,7 +888,7 @@ bool SdStore::upgradeConversationFile(storage::SdRuntimeFile& file, FileHeader upgraded{}; upgraded.magic = kFileMagic; - upgraded.version = kVersion; + upgraded.version = kFileVersion; upgraded.count = static_cast(std::min(records.size(), kMaxMessagesPerConv)); upgraded.head = static_cast(upgraded.count % kMaxMessagesPerConv); if (!file.seek(0) || !writeExact(file, &upgraded, sizeof(upgraded))) @@ -905,7 +909,7 @@ bool SdStore::upgradeConversationFile(storage::SdRuntimeFile& file, file.close(); return false; } - return loadFileHeader(file, header) && header.version == kVersion; + return loadFileHeader(file, header) && header.version == kFileVersion; } bool SdStore::readRecord(storage::SdRuntimeFile& file, @@ -917,8 +921,15 @@ bool SdStore::readRecord(storage::SdRuntimeFile& file, { return false; } - const bool legacy_format = header.version == kLegacyVersion; - const size_t record_size = legacy_format ? sizeof(RecordV2) : sizeof(Record); + size_t record_size = sizeof(Record); + if (header.version == kLegacyVersion) + { + record_size = sizeof(RecordV2); + } + else if (header.version == kReticulumIdentityVersion) + { + record_size = sizeof(RecordV3); + } const uint64_t offset = sizeof(FileHeader) + static_cast(slot) * record_size; if (file.size() < offset + record_size) { @@ -928,13 +939,40 @@ bool SdStore::readRecord(storage::SdRuntimeFile& file, { return false; } - if (!legacy_format) + if (header.version == kFileVersion) { return readExact(file, &rec, sizeof(rec)); } + if (header.version == kReticulumIdentityVersion) + { + RecordV3 rec_v3{}; + if (!readExact(file, &rec_v3, sizeof(rec_v3))) + { + return false; + } + rec.protocol = rec_v3.protocol; + rec.channel = rec_v3.channel; + rec.status = rec_v3.status; + rec.flags = rec_v3.flags; + rec.text_len = rec_v3.text_len; + rec.from = rec_v3.from; + rec.peer = rec_v3.peer; + rec.msg_id = rec_v3.msg_id; + rec.timestamp = rec_v3.timestamp; + std::memcpy(rec.reticulum_destination_hash, + rec_v3.reticulum_destination_hash, + sizeof(rec.reticulum_destination_hash)); + std::memcpy(rec.reticulum_identity_hash, + rec_v3.reticulum_identity_hash, + sizeof(rec.reticulum_identity_hash)); + std::memcpy(rec.text, rec_v3.text, sizeof(rec.text)); + return true; + } + RecordV2 legacy_rec{}; - if (!readExact(file, &legacy_rec, sizeof(legacy_rec))) + if (header.version != kLegacyVersion || + !readExact(file, &legacy_rec, sizeof(legacy_rec))) { return false; } @@ -1052,6 +1090,7 @@ ChatMessage SdStore::messageFromRecord(const Record& rec) msg.timestamp = rec.timestamp; msg.text.assign(rec.text, std::min(rec.text_len, sizeof(rec.text))); msg.status = static_cast(rec.status); + msg.rx_origin = static_cast(rec.rx_origin); if ((rec.flags & kRecordHasReticulumIdentityFlag) != 0) { readReticulumIdentityFromStorage(msg.reticulum_identity, @@ -1072,6 +1111,7 @@ SdStore::Record SdStore::recordFromMessage(const ChatMessage& msg) rec.peer = msg.peer; rec.msg_id = msg.msg_id; rec.timestamp = msg.timestamp; + rec.rx_origin = static_cast(msg.rx_origin); if (msg.protocol == MeshProtocol::Reticulum && hasReticulumDestinationIdentity(msg.reticulum_identity)) { diff --git a/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp index 2062d5fe..0f60cf36 100644 --- a/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp @@ -843,6 +843,8 @@ std::string aspect_text(AnnounceAspect aspect) return "lxmf.delivery"; case AnnounceAspect::LxmfPropagation: return "lxmf.propagation"; + case AnnounceAspect::CallAudio: + return "call.audio"; case AnnounceAspect::Unknown: default: return "unknown"; @@ -859,6 +861,10 @@ AnnounceAspect parse_aspect(std::string_view aspect) { return AnnounceAspect::LxmfPropagation; } + if (aspect == "call.audio") + { + return AnnounceAspect::CallAudio; + } return AnnounceAspect::Unknown; } diff --git a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp index 763e5653..2f85d705 100644 --- a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp @@ -1,5 +1,6 @@ #include "platform/ui/wifi_access_runtime.h" +#include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/screen_runtime.h" #include "platform/ui/wifi_runtime.h" #include "sys/clock.h" @@ -111,6 +112,11 @@ bool ota_active_for_other(const Request& request) bool acquire_http(const Request& request, ScreenPhase phase, Lease& out) { + if (::platform::ui::reticulum_call::realtime_mode_active()) + { + out.decision = Decision::CallExclusive; + return false; + } if (phase == ScreenPhase::WakeProtected) { out.decision = Decision::DeferredForWake; @@ -159,6 +165,12 @@ bool long_lived_allowed(const Request& request, ScreenPhase phase, Lease& out) out.decision = Decision::InvalidRequest; return false; } + if (::platform::ui::reticulum_call::realtime_mode_active() && + request.client != Client::ReticulumGateway) + { + out.decision = Decision::CallExclusive; + return false; + } if (ota_active_for_other(request)) { out.decision = Decision::OtaExclusive; @@ -175,6 +187,22 @@ TrafficBudget base_budget(Client client, ScreenPhase phase) TrafficBudget budget{}; budget.screen_phase = phase; + if (::platform::ui::reticulum_call::realtime_mode_active()) + { + if (client == Client::ReticulumGateway) + { + budget.allow_connect = true; + budget.allow_read = true; + budget.allow_write = true; + budget.rx_packet_budget = 0; + budget.tx_packet_budget = 8; + budget.rx_byte_budget = 768; + budget.tx_byte_budget = 1024; + budget.min_read_interval_ms = 0; + } + return budget; + } + if (phase == ScreenPhase::WakeProtected) { budget.allow_connect = false; @@ -228,6 +256,11 @@ bool ensure_connected(const Request& request, Decision* out_decision) { decision = Decision::OtaExclusive; } + else if (::platform::ui::reticulum_call::realtime_mode_active() && + request.client != Client::ReticulumGateway) + { + decision = Decision::CallExclusive; + } else { const ::platform::ui::wifi::Status wifi_status = ::platform::ui::wifi::status(); @@ -501,6 +534,8 @@ const char* decision_name(Decision decision) return "deferred_for_screen_on"; case Decision::OtaExclusive: return "ota_exclusive"; + case Decision::CallExclusive: + return "call_exclusive"; default: return "unknown"; } diff --git a/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp b/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp new file mode 100644 index 00000000..14f33323 --- /dev/null +++ b/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp @@ -0,0 +1,426 @@ +/** + * @file reticulum_call_audio_runtime.cpp + * @brief Pager ES8311/Codec2 media hooks for MeshChat-compatible Reticulum calls. + */ + +#include "platform/esp/arduino_common/reticulum_call_audio_runtime.h" + +#include "platform/ui/reticulum_call_runtime.h" + +#if defined(ARDUINO_T_LORA_PAGER) && \ + (defined(ARDUINO_LILYGO_LORA_SX1262) || defined(ARDUINO_LILYGO_LORA_LR1121)) + +#include "boards/tlora_pager/tlora_pager_board.h" +#include "chat/infra/reticulum/audio_call_wire.h" +#include "platform/esp/boards/board_runtime.h" + +#include +#include +#include +#include + +#include +#include +#include + +namespace platform::esp::arduino_common +{ +namespace +{ + +using ::chat::reticulum::audio_call::Codec2Mode; +using ::chat::reticulum::audio_call::DecodedPayload; + +constexpr uint32_t kSampleRate = 8000; +constexpr uint8_t kBitsPerSample = 16; +constexpr uint8_t kI2sChannels = 2; +constexpr uint8_t kCodecFramesPerPacket = 2; +constexpr uint8_t kJitterMinFrames = 3; +constexpr uint8_t kJitterMaxFrames = 16; +constexpr uint8_t kDefaultVolume = 78; +constexpr float kDefaultGainDb = 36.0f; +constexpr float kTxPcmGain = 1.5f; +constexpr float kRxPcmGain = 2.0f; +constexpr uint32_t kTaskStackBytes = 18 * 1024; +constexpr UBaseType_t kTaskPriority = tskIDLE_PRIORITY + 2; + +TaskHandle_t s_task = nullptr; +volatile bool s_stop_requested = false; +bool s_registered = false; +::boards::tlora_pager::TLoRaPagerBoard* s_board = nullptr; + +uint32_t now_ms() +{ + return static_cast(pdTICKS_TO_MS(xTaskGetTickCount())); +} + +void* audio_alloc(std::size_t bytes) +{ + return heap_caps_malloc_prefer(bytes, + 2, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); +} + +void audio_free(void* ptr) +{ + if (ptr) + { + heap_caps_free(ptr); + } +} + +::boards::tlora_pager::TLoRaPagerBoard* resolve_board() +{ + ::platform::esp::boards::AppContextInitHandles handles; + if (!::platform::esp::boards::tryResolveAppContextInitHandles(&handles) || + !handles.board) + { + return nullptr; + } + return static_cast<::boards::tlora_pager::TLoRaPagerBoard*>(handles.board); +} + +bool media_supported() +{ + auto* board = resolve_board(); + return board && ((board->getDevicesProbe() & HW_CODEC_ONLINE) != 0); +} + +int16_t clamp_sample(int32_t sample) +{ + if (sample > 32767) + { + return 32767; + } + if (sample < -32768) + { + return -32768; + } + return static_cast(sample); +} + +void free_buffers(int16_t* pcm_in_i2s, + int16_t* pcm_out_i2s, + int16_t* pcm_in, + int16_t* pcm_out, + int16_t* silence_i2s, + uint8_t* codec_frame, + uint8_t* tx_frames, + uint8_t* rx_frames, + uint8_t* packet_buf) +{ + audio_free(pcm_in_i2s); + audio_free(pcm_out_i2s); + audio_free(pcm_in); + audio_free(pcm_out); + audio_free(silence_i2s); + audio_free(codec_frame); + audio_free(tx_frames); + audio_free(rx_frames); + audio_free(packet_buf); +} + +void close_codec() +{ + if (s_board) + { + s_board->codec.setMute(true); + s_board->codec.close(); + } + s_board = nullptr; +} + +void call_audio_task(void*) +{ + CODEC2* codec2_state = codec2_create(CODEC2_MODE_1200); + if (!codec2_state) + { + close_codec(); + s_task = nullptr; + vTaskDelete(nullptr); + return; + } + codec2_set_lpc_post_filter(codec2_state, 1, 0, 0.8f, 0.2f); + + const int samples_per_frame = codec2_samples_per_frame(codec2_state); + const int bytes_per_frame = codec2_bytes_per_frame(codec2_state); + const int i2s_samples_per_frame = samples_per_frame * kI2sChannels; + const uint32_t frame_interval_ms = + static_cast((samples_per_frame * 1000U) / kSampleRate); + + auto* pcm_in_i2s = + static_cast(audio_alloc(i2s_samples_per_frame * sizeof(int16_t))); + auto* pcm_out_i2s = + static_cast(audio_alloc(i2s_samples_per_frame * sizeof(int16_t))); + auto* pcm_in = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); + auto* pcm_out = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); + auto* silence_i2s = + static_cast(audio_alloc(i2s_samples_per_frame * sizeof(int16_t))); + auto* codec_frame = static_cast(audio_alloc(bytes_per_frame)); + auto* tx_frames = + static_cast(audio_alloc(bytes_per_frame * kCodecFramesPerPacket)); + auto* rx_frames = + static_cast(audio_alloc(bytes_per_frame * kJitterMaxFrames)); + auto* packet_buf = + static_cast(audio_alloc(::platform::ui::reticulum_call::kAudioPacketMaxLen)); + + if (!pcm_in_i2s || !pcm_out_i2s || !pcm_in || !pcm_out || !silence_i2s || + !codec_frame || !tx_frames || !rx_frames || !packet_buf) + { + free_buffers(pcm_in_i2s, + pcm_out_i2s, + pcm_in, + pcm_out, + silence_i2s, + codec_frame, + tx_frames, + rx_frames, + packet_buf); + codec2_destroy(codec2_state); + close_codec(); + s_task = nullptr; + vTaskDelete(nullptr); + return; + } + std::memset(silence_i2s, 0, i2s_samples_per_frame * sizeof(int16_t)); + + uint8_t tx_frame_count = 0; + uint8_t rx_frame_count = 0; + uint8_t rx_head = 0; + uint8_t rx_tail = 0; + uint32_t last_play_ms = now_ms(); + + while (!s_stop_requested && + ::platform::ui::reticulum_call::media_should_run()) + { + const auto snapshot = ::platform::ui::reticulum_call::snapshot(); + + ::platform::ui::reticulum_call::AudioPacket inbound{}; + uint8_t inbound_drains = 0; + while (inbound_drains < 2 && + ::platform::ui::reticulum_call::dequeue_inbound_audio(&inbound)) + { + DecodedPayload decoded{}; + if (::chat::reticulum::audio_call::decodePayload( + inbound.data, + inbound.len, + &decoded) && + decoded.mode == Codec2Mode::Mode1200 && + decoded.encoded && + decoded.encoded_len >= static_cast(bytes_per_frame)) + { + const std::size_t frames = + decoded.encoded_len / static_cast(bytes_per_frame); + for (std::size_t index = 0; index < frames; ++index) + { + if (rx_frame_count >= kJitterMaxFrames) + { + break; + } + uint8_t* dst = rx_frames + (rx_tail * bytes_per_frame); + std::memcpy(dst, + decoded.encoded + (index * bytes_per_frame), + bytes_per_frame); + rx_tail = static_cast((rx_tail + 1U) % kJitterMaxFrames); + ++rx_frame_count; + } + } + ++inbound_drains; + } + + if (s_board && + s_board->codec.read(reinterpret_cast(pcm_in_i2s), + i2s_samples_per_frame * sizeof(int16_t)) == 0) + { + for (int index = 0; index < samples_per_frame; ++index) + { + const int32_t left = pcm_in_i2s[index * 2]; + const int32_t right = pcm_in_i2s[(index * 2) + 1]; + const int32_t mixed = static_cast(((left + right) / 2) * kTxPcmGain); + pcm_in[index] = clamp_sample(mixed); + } + + codec2_encode(codec2_state, codec_frame, pcm_in); + std::memcpy(tx_frames + (tx_frame_count * bytes_per_frame), + codec_frame, + bytes_per_frame); + ++tx_frame_count; + + if (tx_frame_count >= kCodecFramesPerPacket) + { + std::size_t packet_len = + ::platform::ui::reticulum_call::kAudioPacketMaxLen; + if (::chat::reticulum::audio_call::encodePayload( + Codec2Mode::Mode1200, + tx_frames, + bytes_per_frame * tx_frame_count, + packet_buf, + &packet_len)) + { + (void)::platform::ui::reticulum_call::enqueue_outbound_audio( + snapshot.link_id, + packet_buf, + packet_len); + } + tx_frame_count = 0; + } + } + + const uint32_t current_ms = now_ms(); + if (current_ms - last_play_ms >= frame_interval_ms) + { + last_play_ms = current_ms; + if (rx_frame_count >= kJitterMinFrames) + { + const uint8_t* frame = rx_frames + (rx_head * bytes_per_frame); + codec2_decode(codec2_state, pcm_out, const_cast(frame)); + rx_head = static_cast((rx_head + 1U) % kJitterMaxFrames); + --rx_frame_count; + + for (int index = 0; index < samples_per_frame; ++index) + { + const int32_t sample = + static_cast(pcm_out[index] * kRxPcmGain); + const int16_t out = clamp_sample(sample); + pcm_out_i2s[index * 2] = out; + pcm_out_i2s[(index * 2) + 1] = out; + } + if (s_board) + { + (void)s_board->codec.write(reinterpret_cast(pcm_out_i2s), + i2s_samples_per_frame * sizeof(int16_t)); + } + } + else if (s_board) + { + (void)s_board->codec.write(reinterpret_cast(silence_i2s), + i2s_samples_per_frame * sizeof(int16_t)); + } + } + + vTaskDelay(pdMS_TO_TICKS(2)); + } + + free_buffers(pcm_in_i2s, + pcm_out_i2s, + pcm_in, + pcm_out, + silence_i2s, + codec_frame, + tx_frames, + rx_frames, + packet_buf); + codec2_destroy(codec2_state); + close_codec(); + s_stop_requested = false; + s_task = nullptr; + vTaskDelete(nullptr); +} + +bool media_start() +{ + if (s_task) + { + return !s_stop_requested; + } + if (!media_supported()) + { + return false; + } + + s_board = resolve_board(); + if (!s_board) + { + return false; + } + if (s_board->codec.open(kBitsPerSample, kI2sChannels, kSampleRate) != 0) + { + s_board = nullptr; + return false; + } + s_board->codec.setVolume(kDefaultVolume); + s_board->codec.setGain(kDefaultGainDb); + s_board->codec.setMute(false); + + s_stop_requested = false; + const BaseType_t rc = xTaskCreate(call_audio_task, + "rt_call_audio", + kTaskStackBytes, + nullptr, + kTaskPriority, + &s_task); + if (rc != pdPASS) + { + close_codec(); + s_task = nullptr; + return false; + } + std::printf("[Reticulum][CallAudio] media started codec2=1200\n"); + return true; +} + +void media_stop() +{ + s_stop_requested = true; +} + +} // namespace + +void ensureReticulumCallAudioRuntimeRegistered() +{ + if (s_registered) + { + return; + } + ::platform::ui::reticulum_call::MediaHooks hooks{}; + hooks.is_supported = media_supported; + hooks.start = media_start; + hooks.stop = media_stop; + ::platform::ui::reticulum_call::set_media_hooks(hooks); + s_registered = true; +} + +} // namespace platform::esp::arduino_common + +#else + +namespace platform::esp::arduino_common +{ +namespace +{ +bool s_registered = false; + +bool media_supported() +{ + return false; +} + +bool media_start() +{ + return false; +} + +void media_stop() +{ +} + +} // namespace + +void ensureReticulumCallAudioRuntimeRegistered() +{ + if (s_registered) + { + return; + } + ::platform::ui::reticulum_call::MediaHooks hooks{}; + hooks.is_supported = media_supported; + hooks.start = media_start; + hooks.stop = media_stop; + ::platform::ui::reticulum_call::set_media_hooks(hooks); + s_registered = true; +} + +} // namespace platform::esp::arduino_common + +#endif diff --git a/platform/esp/boards/library.json b/platform/esp/boards/library.json index 211cfc26..e59ff31a 100644 --- a/platform/esp/boards/library.json +++ b/platform/esp/boards/library.json @@ -13,7 +13,6 @@ "-I../../../modules/core_sys/include", "-I../../../modules/core_chat/include", "-I../../../modules/core_gps/include", - "-I../../../modules/core_hostlink/include", "-I../../../modules/core_team/include", "-I../../../platform/esp/arduino_common/include" ] diff --git a/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp b/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp index 2909b9c4..ac64ab26 100644 --- a/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp +++ b/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp @@ -301,6 +301,8 @@ std::string aspect_text(AnnounceAspect aspect) return "lxmf.delivery"; case AnnounceAspect::LxmfPropagation: return "lxmf.propagation"; + case AnnounceAspect::CallAudio: + return "call.audio"; case AnnounceAspect::Unknown: default: return "unknown"; @@ -317,6 +319,10 @@ AnnounceAspect parse_aspect(std::string_view aspect) { return AnnounceAspect::LxmfPropagation; } + if (aspect == "call.audio") + { + return AnnounceAspect::CallAudio; + } return AnnounceAspect::Unknown; } diff --git a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp index 224b587d..b345db4e 100644 --- a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp +++ b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp @@ -733,6 +733,22 @@ void sortConversations(std::vector<::chat::ConversationMeta>& conversations, return out; } +[[nodiscard]] const char* ingressTransportLabel(::chat::RxOrigin origin) +{ + switch (origin) + { + case ::chat::RxOrigin::Mesh: + case ::chat::RxOrigin::LoRa: + return "LoRa"; + case ::chat::RxOrigin::External: + case ::chat::RxOrigin::WiFi: + return "Wi-Fi"; + case ::chat::RxOrigin::Unknown: + break; + } + return nullptr; +} + [[nodiscard]] ChatMessageItem makeMessageItem( const ::chat::ChatMessage& message, ::chat::NodeId self_node, @@ -747,6 +763,13 @@ void sortConversations(std::vector<::chat::ConversationMeta>& conversations, item.meta = statusLabel(message.status); item.meta += " / "; item.meta += formatAge(message.timestamp); + const char* ingress_label = + !item.outgoing ? ingressTransportLabel(message.rx_origin) : nullptr; + if (ingress_label && ingress_label[0] != '\0') + { + item.meta += " / "; + item.meta += ingress_label; + } if (message.msg_id != 0) { char buffer[32] = {}; diff --git a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h index a7330f08..57534a00 100644 --- a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h +++ b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h @@ -92,7 +92,7 @@ class InternalFsStore final : public ::chat::IChatStore char text[220] = {}; } __attribute__((packed)); - struct MessageRecord + struct MessageRecordV3 { uint8_t protocol = 0; uint8_t channel = 0; @@ -112,9 +112,31 @@ class InternalFsStore final : public ::chat::IChatStore char text[220] = {}; } __attribute__((packed)); + struct MessageRecord + { + uint8_t protocol = 0; + uint8_t channel = 0; + uint8_t status = 0; + uint8_t flags = 0; + uint8_t rx_origin = 0; + uint32_t from = 0; + uint32_t peer = 0; + uint32_t msg_id = 0; + uint32_t timestamp = 0; + uint32_t sequence = 0; + uint8_t team_location_icon = 0; + int32_t geo_lat_e7 = 0; + int32_t geo_lon_e7 = 0; + uint16_t text_len = 0; + uint8_t reticulum_destination_hash[::chat::kReticulumPeerHashSize] = {}; + uint8_t reticulum_identity_hash[::chat::kReticulumPeerHashSize] = {}; + char text[220] = {}; + } __attribute__((packed)); + static constexpr uint32_t kMagic = 0x54534D43; // CMST static constexpr uint16_t kLegacyVersion = 2; - static constexpr uint16_t kVersion = 3; + static constexpr uint16_t kReticulumIdentityVersion = 3; + static constexpr uint16_t kVersion = 4; bool ensureFs() const; bool loadFromFs(); diff --git a/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp b/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp index dbcd52e1..aa974230 100644 --- a/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp +++ b/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp @@ -276,7 +276,9 @@ bool InternalFsStore::loadFromFs() return false; } - if (header.version != kVersion && header.version != kLegacyVersion) + if (header.version != kVersion && + header.version != kReticulumIdentityVersion && + header.version != kLegacyVersion) { file.close(); InternalFS.remove(path_); @@ -286,6 +288,8 @@ bool InternalFsStore::loadFromFs() const uint16_t conversation_count = header.conversation_count; next_sequence_ = std::max(1U, header.next_sequence); const bool legacy_format = header.version == kLegacyVersion; + const bool reticulum_identity_format = + header.version == kReticulumIdentityVersion; for (uint16_t index = 0; index < conversation_count; ++index) { @@ -341,6 +345,7 @@ bool InternalFsStore::loadFromFs() for (uint16_t message_index = 0; message_index < message_count; ++message_index) { MessageRecord rec{}; + MessageRecordV3 rec_v3{}; MessageRecordV2 legacy_rec{}; if (legacy_format) { @@ -352,6 +357,16 @@ bool InternalFsStore::loadFromFs() return false; } } + else if (reticulum_identity_format) + { + if (file.read(&rec_v3, sizeof(rec_v3)) != sizeof(rec_v3)) + { + file.close(); + InternalFS.remove(path_); + conversations_.clear(); + return false; + } + } else if (file.read(&rec, sizeof(rec)) != sizeof(rec)) { file.close(); @@ -380,6 +395,31 @@ bool InternalFsStore::loadFromFs() sizeof(legacy_rec.text))); sequence = legacy_rec.sequence; } + else if (reticulum_identity_format) + { + msg.protocol = static_cast<::chat::MeshProtocol>(rec_v3.protocol); + msg.channel = static_cast<::chat::ChannelId>(rec_v3.channel); + msg.from = rec_v3.from; + msg.peer = rec_v3.peer; + msg.msg_id = rec_v3.msg_id; + msg.timestamp = rec_v3.timestamp; + msg.team_location_icon = rec_v3.team_location_icon; + msg.has_geo = (rec_v3.flags & kMessageHasGeoFlag) != 0; + msg.geo_lat_e7 = rec_v3.geo_lat_e7; + msg.geo_lon_e7 = rec_v3.geo_lon_e7; + msg.status = static_cast<::chat::MessageStatus>(rec_v3.status); + if ((rec_v3.flags & kMessageHasReticulumIdentityFlag) != 0) + { + readReticulumIdentityFromRecord( + msg.reticulum_identity, + rec_v3.reticulum_destination_hash, + rec_v3.reticulum_identity_hash); + } + msg.text.assign(rec_v3.text, + std::min(rec_v3.text_len, + sizeof(rec_v3.text))); + sequence = rec_v3.sequence; + } else { msg.protocol = static_cast<::chat::MeshProtocol>(rec.protocol); @@ -393,6 +433,7 @@ bool InternalFsStore::loadFromFs() msg.geo_lat_e7 = rec.geo_lat_e7; msg.geo_lon_e7 = rec.geo_lon_e7; msg.status = static_cast<::chat::MessageStatus>(rec.status); + msg.rx_origin = static_cast<::chat::RxOrigin>(rec.rx_origin); if ((rec.flags & kMessageHasReticulumIdentityFlag) != 0) { readReticulumIdentityFromRecord( @@ -490,6 +531,7 @@ bool InternalFsStore::saveToFs() const rec.channel = static_cast(msg->channel); rec.status = static_cast(msg->status); rec.flags = msg->has_geo ? kMessageHasGeoFlag : 0x00U; + rec.rx_origin = static_cast(msg->rx_origin); if (msg->protocol == ::chat::MeshProtocol::Reticulum && ::chat::hasReticulumDestinationIdentity(msg->reticulum_identity)) { diff --git a/platformio.ini b/platformio.ini index abab9bbe..44b4a5e1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -73,6 +73,7 @@ lib_ignore = trailmate-nrf52-node-app-shell ui_mono platform_nrf52_arduino_common + core_hostlink SD lib_deps = lvgl/lvgl @ 9.4.0 diff --git a/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp b/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp index a39c8d5d..78c67eaf 100644 --- a/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp +++ b/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp @@ -54,6 +54,8 @@ constexpr const char* kProofPacket = constexpr const char* kTextPayload = "94cb40934a0000000000c405547261696cc40f68656c6c6f207265746963756c756d80"; +constexpr const char* kPeerAnnounceAppData = + "92a87669636c69752d31c0"; constexpr const char* kAppDataPayload = "544d4150010100001234aabbccdd01020304deadbeef"; constexpr const char* kMessageHash = @@ -360,6 +362,59 @@ void expectLxmfEnvelopeVectors() assert(decoded_text.content == "hello reticulum"); assert(decoded_text.fields_empty); + std::vector peer_announce = encodeBuffer(32); + std::size_t peer_announce_len = peer_announce.size(); + assert(lxmf::packPeerAnnounceAppData("vicliu-1", + false, + 0, + peer_announce.data(), + &peer_announce_len)); + peer_announce.resize(peer_announce_len); + expectBytes(peer_announce.data(), peer_announce.size(), kPeerAnnounceAppData); + + char display_name[32] = {}; + bool has_stamp_cost = true; + uint8_t stamp_cost = 0xFFU; + assert(lxmf::unpackPeerAnnounceAppData(peer_announce.data(), + peer_announce.size(), + display_name, + sizeof(display_name), + &has_stamp_cost, + &stamp_cost)); + assert(std::strcmp(display_name, "vicliu-1") == 0); + assert(!has_stamp_cost); + assert(stamp_cost == 0U); + + const std::vector legacy_bin_announce = + fromHex("92c4087669636c69752d31c0"); + assert(lxmf::unpackPeerAnnounceAppData(legacy_bin_announce.data(), + legacy_bin_announce.size(), + display_name, + sizeof(display_name), + &has_stamp_cost, + &stamp_cost)); + assert(std::strcmp(display_name, "vicliu-1") == 0); + + const std::vector str8_announce = + fromHex("92d9087669636c69752d31c0"); + assert(lxmf::unpackPeerAnnounceAppData(str8_announce.data(), + str8_announce.size(), + display_name, + sizeof(display_name), + &has_stamp_cost, + &stamp_cost)); + assert(std::strcmp(display_name, "vicliu-1") == 0); + + const std::vector str16_announce = + fromHex("92da00087669636c69752d31c0"); + assert(lxmf::unpackPeerAnnounceAppData(str16_announce.data(), + str16_announce.size(), + display_name, + sizeof(display_name), + &has_stamp_cost, + &stamp_cost)); + assert(std::strcmp(display_name, "vicliu-1") == 0); + const uint8_t app_payload[] = {0xDE, 0xAD, 0xBE, 0xEF}; std::vector app_data = encodeBuffer(64); std::size_t app_data_len = app_data.size();