From 0c6068ecee0685d7ef58f2197a1f370fb9abbd2d Mon Sep 17 00:00:00 2001 From: liu weikai Date: Mon, 10 Aug 2026 14:30:20 +0800 Subject: [PATCH] feat(chat): persist VMP voice messages as IM attachments --- CHANGELOG.md | Bin 144800 -> 147218 bytes docs/design/lr1121_voice_message_protocol.md | 60 ++- .../chat/infra/voice/vmp_voice_inbox.h | 155 +++++- .../include/chat/infra/voice/vmp_wire.h | 7 +- .../src/infra/voice/vmp_voice_inbox.cpp | 277 ++++++++++- .../core_chat/src/infra/voice/vmp_wire.cpp | 9 +- ...st_vmp_attachment_persistence_contract.cpp | 94 +++- .../core_chat/tests/test_vmp_voice_inbox.cpp | 195 +++++++- modules/core_chat/tests/test_vmp_wire.cpp | 7 +- .../ui_shared/include/ui/chat_voice_runtime.h | 79 ++- .../ui/screens/chat/chat_compose_components.h | 5 +- .../chat/chat_conversation_components.h | 7 +- .../ui/screens/chat/chat_ui_controller.h | 7 + .../ui_shared/src/ui/chat_voice_runtime.cpp | 32 +- .../screens/chat/chat_compose_components.cpp | 62 ++- .../chat/chat_conversation_components.cpp | 165 ++++++- .../ui/screens/chat/chat_ui_controller.cpp | 463 ++++++++++++++++-- .../tests/test_chat_voice_runtime.cpp | 89 +++- .../arduino_common/voice/vmp_pager_session.h | 27 +- .../esp/arduino_common/src/app_context.cpp | 57 ++- .../infra/store/message_attachment_store.cpp | 75 ++- .../src/voice/vmp_pager_audio.cpp | 26 + .../src/voice/vmp_pager_session.cpp | 408 ++++++++++++++- 23 files changed, 2121 insertions(+), 185 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d3536d64ae6bdca0cff5a6d06bbc7b3d28e51b..48dd1d8d81ccb5ae97c815052003461955ae77b3 100644 GIT binary patch delta 1979 zcmZ`)Z%i9y7=I7U{zAdjz$ls>(TvK(76M(ubeVB7GwHRx7J*cOyXzHjr6t#vt(b8n zq9a)i!&`QXiE|(Pml(|WqT!R-lFbkPiTHIkG5E!348|oS#?1PA?mP-x%$vUVzVGw@ zd4A8+YrpMz=bt^-$E&$6v%^=_$oI1})0oOq_(?2_J;8Y9!>?^@Em}Qn{&ARVGm2gN zjIQqb!!NTS3xhVzGHik=;O5yR_AHjf#;g<1lAvmabn~$mg^lAq$DRgX3}@n3K%Hb$ z*kR4AeVl8WBnRkOgYhg_@*wjN%V1B!(6l8;&JkDoKduaDJbNB535(K+n*`YeBr@zM zZhcH-9ax?Ckyg@>H6DJesWyt6%-Y#W_9B}x*Ph_qdwENBiWQ6%ce7#}%E6@!a^tgd zMDg0rXb>f*B^WC}bsFa}aOOa#c*%h@dkU1C6?GaXGVZu;y&5a)Zhd zYdB3n2PLHwS?t2sav5LZwL_)shH)p~$U0?tyWR;)-nQNjdIsLxH=ef+3WN*DIf*7L zx6i=s2?B+wAF67syHA^0*Iji`pR#h80-s_PScG&XMFb-UE*a$dHH&J8BEcRxNok|n zQkY3t%V1G0Z6Amyg@6h>UhE{LQODAe-Ny0ou2xDjVTV$c-m+r@nOvA%r8<=-#dH5Vo`qnf1EW&-3RtlH3@vEqgk8dnsmatgo08}z@LbONlr4tPr0&tq0#*Ji%Ydb7S1GR-_^^oJa$a<|d+1Zm7Aohh3Ob|>w3{Xps zL!2g(9fC7P$XF!_gkNg(tc6(OB)UBgB%`C-eKd?DiR?LO_Th8_RMMTpPJ@;PF*$3? z$mof)IG=$A*f&Z)H=3KDaW1zLrEH0vrW_atpKU}Lr?6b`Dn$>OA7saQ0#DRf7%RW9Sb^NqUQd6Z)6;!59DAV8?3=F{bKi!Na>{zk4IseWI z@~lCozYV8&8*k5#G?xP^^$59I3Ju1+1BZr-@toQ^+A>~zw;0|=8wo5(bcKH WBg=_n>Kj7!d7+y7La441JO2gePu#Wu delta 22 ecmbRAk7L1Wjtv>i&9`{kZ}Bj0zs193VFUnpW(g+% diff --git a/docs/design/lr1121_voice_message_protocol.md b/docs/design/lr1121_voice_message_protocol.md index 86bc3da9..d6110592 100644 --- a/docs/design/lr1121_voice_message_protocol.md +++ b/docs/design/lr1121_voice_message_protocol.md @@ -103,7 +103,44 @@ The Pager implementation deliberately separates RF control state from bulk media VMP is persistent whenever the active text chat uses the existing `SdStore`; it follows the **same deferred-storage hydration boundary**. Until that shared hydration completes, VMP cannot record, receive, display, or play an object, so a restore cannot overwrite a newly received voice. If text falls back to `RamStore` because SD persistence is unavailable, VMP explicitly follows the same volatile policy rather than pretending that received media is durable. -The first implemented adapter stores voice under `/data/v2/attachments/voice/inbox.v1`. It is an atomic bounded snapshot with a temporary file, a retained previous snapshot, schema/version, per-media CRC-32, and whole-payload CRC-32. The committed primary and previous backup require at most 21,280 B for a full eight-object V1 inbox; writing a new temporary snapshot raises the bounded peak to 31,920 B. A completed media object is first validated by VMP, then placed in the PSRAM inbox, and is exposed to the UI only if the snapshot commit succeeds. A failed commit removes that tentative inbox entry. This mirrors text incoming-message behavior: the UI never presents an entry as received before its authoritative persistent record is durable. Restore validates the primary first and, on I/O or integrity failure, attempts the retained backup before declaring the inbox unavailable. It preserves each local playback ID and the `(sender_id, session_id)` deduplication key; it never feeds a restored item to any RF, MQTT, or LXMF transmit path. +The first implemented adapter stores VMP local message attachments under `/data/v2/attachments/voice/inbox.v1`. The historical file name is retained for compatibility, but the bounded PSRAM store is no longer receive-only: it is the authoritative local VMP message-object index for both incoming and outgoing voice. It is an atomic bounded snapshot with a temporary file, a retained previous snapshot, schema/version, per-media CRC-32, and whole-payload CRC-32. The committed primary and previous backup require at most 21,280 B for a full eight-object V1 store; writing a new temporary snapshot raises the bounded peak to 31,920 B. + +### 3.1.1 Voice as a durable local chat-message extension + +Text and VMP must not be made equivalent by inserting a fake `"[voice]"` text packet into an MT, MC, or RT journal. That would corrupt protocol ownership, create a message that other clients interpret as ordinary text, and make a received VMP object appear relayable. Instead, VMP follows the **same durability and projection rules** as text through a typed local attachment-message record: + +| Field | VMP attachment-message rule | Text-message analogue | +| --- | --- | --- | +| Stable identity | `VoiceMessageMetadata.local_id` is the local attachment/message ID; `(sender_id, session_id)` remains its VMP duplicate key | protocol `message_id` plus protocol-specific dedup identity | +| Conversation ownership | persisted `{presentation protocol, logical channel, peer}`: peer is `sender_id` for incoming private, `target_id` for outgoing private, or zero for broadcast; the logical channel is authenticated/CRC-covered in VMP control and is distinct from the 2.4 GHz RF channel | `ConversationId` in the protocol message slot | +| Direction/read state | packed local `outgoing` and persistent `read` bits | `from`/delivery direction plus read projection in `ChatMessage` | +| Delivery state | `Received`, `Sending`, `Sent`, `Failed` | `Incoming`, `Queued`, `Sent`, `Failed` in the message/status projections | +| Body | Codec2 bytes in the typed voice attachment slot | UTF-8 text in protocol-specific fixed slot | +| Presentation | Chat projection merges attachment messages with text rows by conversation and timestamp | Chat workspace projection | + +`Sent` is deliberately a **local carrier-result** state, not a remote delivery receipt. For direct private RF it means authenticated `ACCEPT`/`READY` completed and the bounded shard train was handed to the LR1121; for broadcast it means the public train was emitted once; for MQTT/LXMF it means the bounded carrier plan was accepted. No receiver forwards, acknowledges full media, or changes the sender's state. An inbound object remains unread until its own bound conversation is opened; an outgoing object is born read. + +The durable transactions are intentionally symmetric with text-message lifecycle rules: + +```text +incoming: validate/auth/FEC + -> insert local attachment object + -> atomically commit voice snapshot + -> publish/projection/play eligibility + +outgoing: capture + encode + prepare control + -> insert local `Sending` attachment object + -> atomically commit voice snapshot + -> choose exactly one VMP carrier and transmit once + -> atomically commit `Sent` or `Failed` + -> refresh conversation projection +``` + +If the **pre-send** snapshot commit fails, VMP MUST NOT use any carrier: a clip is never transmitted without a recoverable local message record. If a terminal-state commit fails, the UI retains the safe `Sending` state rather than inventing a terminal result. A reboot cannot resume a VMP transmission; hydration converts a retained outgoing `Sending` entry to `Failed` and immediately attempts a healing snapshot commit. This gives a truthful, recoverable result without adding a retry queue, extra audio buffer, or a forbidden receiver-side transmit path. + +The attachment record remains 48 bytes. Direction/read state are encoded in compact flags; delivery state, presentation protocol, and logical chat channel occupy existing reserved bytes. V2 snapshots retain the V1 record ABI; V1 records are hydrated as already-read, unbound legacy objects, then safely bound to the boot-time active protocol/primary channel and rewritten as V2. Snapshot records are serialized newest-first, and hydration explicitly restores their descending insertion sequence, so a reboot retains both chronological conversation order and oldest-object eviction behavior. No payload is duplicated: the same existing eight PSRAM slots hold both incoming and outgoing clips, and oldest-object replacement is across the combined local VMP history. + +Restore validates the primary first and, on I/O or integrity failure, attempts the retained backup before declaring the attachment store unavailable. It preserves each local playback ID and VMP duplicate key; it never feeds a restored item to any RF, MQTT, or LXMF transmit path. The attachment store shares the SD runtime's controlled file access, bounded transfer slices, and temporary/backup recovery protocol. It does not create another uncoordinated SPI client. The current product SD policy, like existing text-chat storage, does not provide a claim of at-rest encryption for a device whose removable storage is physically compromised. Private VMP provides end-to-end confidentiality over RF/LXMF/MQTT carriers; a storage-encryption product requirement must be implemented at the common storage layer for text and all attachments together, not as a voice-only cipher. @@ -113,11 +150,11 @@ Text remains in the protocol-partitioned chat journal. Binary and structured bod | Attachment kind | Storage role | Intended chat record linkage | Current status | | --- | --- | --- | --- | -| Voice | Codec2 encoded object, codec/mode/identity metadata, play action | local voice ID projected into the owning conversation | implemented | +| Voice | Codec2 encoded object plus direction, delivery, codec/mode/identity metadata and play action | stable local attachment/message ID projected into the owning conversation; never a fake MT/MC/RT text packet | implemented | | Image | immutable compressed image/blob with MIME, dimensions, content hash, thumbnail policy | attachment ID in a normal chat message record | storage family reserved; transport/UI not yet implemented | | Location | compact structured coordinates, timestamp, accuracy, and optional text preview | inline metadata where small, attachment ID only if extended history/track payload is required | storage family reserved; transport/UI not yet implemented | -Images and location messages MUST use this attachment boundary rather than inventing protocol-specific caches or direct SD paths. The future chat message schema should carry an attachment descriptor (kind, local attachment ID, content hash, presentation metadata) rather than a full image/audio byte vector. Retention, eviction, export, delete, and at-rest encryption then remain common storage concerns. The attachment layer has no mesh/radio/MQTT/LXMF send function by design; bearer adapters may create a local attachment only after their own validation. +Images and location messages MUST use this attachment boundary rather than inventing protocol-specific caches or direct SD paths. Their attachment descriptor MUST carry `kind`, stable local attachment/message ID, conversation identity, direction, delivery state, content hash, and presentation metadata rather than a full image/audio byte vector. Retention, eviction, export, delete, and at-rest encryption then remain common storage concerns. The attachment layer has no mesh/radio/MQTT/LXMF send function by design; bearer adapters may create a local attachment only after their own validation. If a message cannot be stored, the receiver reports a local storage failure and returns to Sub-GHz; it never asks another node to retransmit. @@ -136,7 +173,7 @@ Before a private sender emits any source or parity shard, it performs a bounded VMP uses a separate, versioned **voice key domain**. It MUST NOT reinterpret MT channel keys or MC forwarding keys as VMP keys. * A private contact has a VMP-specific, verified 32-byte static contact secret `K_contact`. The Pager keeps only a bounded RAM cache of this derived VMP value. It is never an MT channel key, MC forwarding key, or a key copied from an unrelated protocol packet. -* A broadcast session is public: it has **no group key, no key exchange, no encryption, and no sender authentication**. Its `key_or_profile_id` is zero and the `public-broadcast` flag is mandatory. +* A broadcast session is public: it has **no group key, no key exchange, no encryption, and no sender authentication**. Its logical chat-channel byte is public, bounds projection to the originating channel, and the `public-broadcast` flag is mandatory. * Key material is never placed in a chat event, a diagnostic log, or an MQTT topic name. For a **private** session, `OFFER` carries the sender's fresh X25519 ephemeral public key `E_s` and `ACCEPT` carries the receiver's fresh ephemeral public key `E_r`. The private portions never go on air and MUST be erased immediately after completion, failure, or timeout. Given a 64-bit `session_id` and 96-bit `session_nonce`, derive: @@ -188,7 +225,7 @@ VMP control bytes use the binary envelope below. They are carried by the `VoiceC | 2 | 1 | version | `1` | | 3 | 1 | type | `1=OFFER`, `2=ACCEPT`, `3=ANNOUNCE`, `4=CANCEL` | | 4 | 1 | flags | bit 0 private, bit 1 broadcast, bit 2 public-broadcast, bit 3 RT-carrier hint | -| 5 | 1 | key/profile ID | private key slot; public broadcast MUST use `0` | +| 5 | 1 | logical chat channel | local conversation channel `0..7`; authenticated for private control and corruption-covered for public broadcast; **not** the 2.4 GHz RF channel | | 6 | 4 | sender ID | Trail Mate node identity short ID | | 10 | 4 | target ID | recipient short ID, or `0xFFFFFFFF` for broadcast | | 14 | 8 | session ID | cryptographically random, nonzero | @@ -404,11 +441,12 @@ This directly enforces the requirement that a voice message received from MQTT i `chat_compose` displays a microphone action only when the isolated VMP runtime reports `recordAndSend` currently possible. On LR1121 this is available after local inbox hydration. On SX1262 it becomes available only while the MT MQTT uplink is enabled and is immediately hidden when that uplink is disabled. It is available for both a selected private conversation and the broadcast/channel conversation when their applicable carrier condition is met. -1. Selecting the microphone enters a recording state with a visible five-second countdown and a stop/cancel action. -2. At 5.0 seconds it stops automatically and encodes/sends in the background. The destination is derived from the active conversation: peer means private; channel/group means one-hop broadcast. +1. The microphone is **push-to-talk**, never click-to-start: `LV_EVENT_PRESSED` immediately starts capture and visibly changes the action to `Release 0.0s`; the compose header shows `REC /5`. `LV_EVENT_RELEASED` and `LV_EVENT_PRESS_LOST` immediately request capture stop after the current Codec2 frame. The post-release synthetic click is ignored. +2. At 5.0 seconds the worker stops capture automatically, changes the in-place state to `Sending`, and encodes/sends in the background. Release before five seconds follows the same path; a tap shorter than one complete Codec2 frame is discarded without creating a message. The destination is derived from the active conversation: peer means private; channel/group means one-hop broadcast. 3. If the active protocol/board cannot provide VMP, the action is hidden or disabled with a reason; it does not silently fall back to text, MT, or MC payloads. -4. The conversation renders a voice bubble with direction, duration, mode (private/broadcast), delivery state, integrity state (`complete` or `incomplete`), and a play button. Playback decodes on demand through the audio port. -5. A received message is added only after cryptographic validation and bounded local-inbox storage. The notification can be a normal incoming-message tone; it must never auto-play voice. +4. Once capture ends, the compose page returns to the conversation **without waiting for RF, MQTT, or LXMF to finish**. The worker first creates the durable outgoing attachment object; while a local voice worker is active, the conversation projection polls at 150 ms so the new `Sending` bubble appears as soon as that commit exists, then changes to `Sent` or `Failed` after the one carrier attempt. A discarded sub-frame tap creates no bubble. Incoming and outgoing private clips use the same persisted `{protocol, channel, peer}` conversation; a broadcast clip uses the originating broadcast channel. This is not a toast-only result. +5. Tapping a playable bubble starts asynchronous decoder output and changes that bubble to `playing` for the clip duration. It returns to `tap to play` afterward; playback has no radio or MQTT effect. The UI rejects only the genuinely unavailable/busy speaker case with the specific `Voice audio is busy` notice. +6. A received message is added only after cryptographic validation and bounded durable attachment-message storage. It appears in the conversation list with a `Voice message` preview and unread count even when that thread is closed; opening precisely that bound thread persists its read state. The notification can be a normal incoming-message tone; it must never auto-play voice. ## 12. Error behavior and observability @@ -431,12 +469,12 @@ No delivery receipt is added to V1 because receiving one would require another r | Area | Current implementation status | Remaining release gate | | --- | --- | --- | | Core VMP v1 | Implemented: binary control/data codecs, private/public validation, RS(10,8), replay/session state, `K_contact` domain derivation, bounded inbox, and host test sources. | Run the OpenSSL-enabled private crypto/transport tests in CI; this workstation's CMake OpenSSL discovery is unavailable. | -| Pager audio/UI | Implemented: 5-second Codec2 capture, chat-compose microphone action, local inbox projection, and on-demand playback. | Hardware exercise capture/playback ownership alongside a real call and a text conversation. | +| Pager audio/UI | Implemented: push-to-talk press/release capture with a visible 5-second in-place countdown, local outgoing/incoming attachment-message projection with `Sending`/`Sent`/`Failed`, and on-demand playback progress. | Hardware exercise press/release, auto-cap, capture/playback ownership, and a text conversation. | | LR1121 direct carrier | Implemented: Sub-GHz control, authenticated private `ACCEPT`, repeated `READY_PROBE`, 2.4 GHz ten-shard train, timeout cleanup, and Sub-GHz restoration. | Two-Pager over-the-air timing, packet-loss, coexistence, regional channel, EIRP, and current-consumption validation. | | SX1262 MQTT-only carrier | Implemented: VMP service/audio/inbox and UI runtime initialize for the SX1262 Pager; it records only while MT MQTT uplink is enabled, queues the same bounded VQ publication, and rejects direct RF and LXMF VMP paths. | Build and hardware test with MQTT enabled/disabled, publish failure/reconnect, and proof that no VMP frame reaches SX1262 LoRa TX. | | MT MQTT | Implemented: isolated VQ topic, optional QoS 0 upload plan, local-only inbound termination, and no-MT-downlink contract test. | Broker interoperability and retained/duplicate/partition test on hardware. | | RT/LXMF | Implemented: reserved AppData VQ port, private carrier, nested VMP encryption, local-only inbound termination, and isolation contract test. | LXMF path/identity lifecycle and delayed-delivery validation on two devices. | -| Persistent storage | Implemented: VMP voice uses the common SD attachment-store boundary, atomic snapshot/backup recovery, CRC validation, stable playback IDs, and the same delayed hydration/durable-incoming behavior as text chat. Bulk live state is PSRAM; only active PCM scratch is internal DMA RAM. | Hardware power-loss/SD-removal recovery and common text+attachment at-rest encryption policy validation. | +| Persistent storage | Implemented: VMP voice uses the common typed attachment-message boundary for both incoming and outgoing clips, atomic snapshot/backup recovery, CRC validation, stable playback IDs, durable delivery-state transitions, interrupted-send recovery, and the same delayed hydration/durable-incoming behavior as text chat. Bulk live state is PSRAM; only active PCM scratch is internal DMA RAM. | Hardware power-loss/SD-removal recovery and common text+attachment at-rest encryption policy validation. | Completed automated gates in this workspace are the Pager release build, clang-format 14, ESP stack-hygiene validation, and the MT MQTT/LXMF local-only ingress contract tests. RF and audio behavior still require the two-device hardware gates above before a production-default rollout. diff --git a/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h b/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h index 9be39415..c88ca15e 100644 --- a/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h +++ b/modules/core_chat/include/chat/infra/voice/vmp_voice_inbox.h @@ -1,11 +1,11 @@ /** * @file vmp_voice_inbox.h - * @brief Fixed local-only VMP voice-object inbox. + * @brief Fixed local-only VMP voice-object store. * * This deliberately stores already-validated encoded media and has no mesh, - * radio, MQTT, or forwarding API. A receive-side VMP implementation can only - * persist, present, or play an object through this type; it cannot re-originate - * it onto any air interface. + * radio, MQTT, or forwarding API. A VMP implementation can only persist, + * present, or play an object through this type; it cannot re-originate an + * accepted object onto any air interface. */ #pragma once @@ -20,6 +20,47 @@ namespace chat::voice::vmp inline constexpr std::size_t kVoiceInboxCapacity = 8U; +/** + * Local presentation state for a voice object. + * + * `Sent` means the selected one-hop carrier accepted the finished VMP object; + * VMP intentionally has no post-media delivery receipt or resend protocol. + */ +enum class VoiceDeliveryState : uint8_t +{ + Received = 0U, + Sending = 1U, + Sent = 2U, + Failed = 3U, +}; + +/** + * Local chat-presentation binding for a VMP object. + * + * These values deliberately mirror the stable persisted values of + * `chat::MeshProtocol` without making VMP a Meshtastic, MeshCore, or + * Reticulum payload. The binding only selects the local chat conversation + * in which an accepted VMP object is displayed. + */ +enum class VoicePresentationProtocol : uint8_t +{ + Unknown = 0U, + Meshtastic = 1U, + MeshCore = 2U, + Reticulum = 4U, +}; + +inline constexpr uint8_t kVoicePresentationPrimaryChannel = 0U; +inline constexpr uint8_t kVoicePresentationMaxChannel = 7U; + +enum VoiceMessageFlag : uint8_t +{ + VoiceMessageFlagSourceUnverified = 1U << 0U, + VoiceMessageFlagComplete = 1U << 1U, + VoiceMessageFlagOutgoing = 1U << 2U, + VoiceMessageFlagRead = 1U << 3U, +}; + struct VoiceMessageMetadata { uint64_t local_id = 0U; @@ -31,10 +72,74 @@ struct VoiceMessageMetadata uint16_t encoded_media_len = 0U; Codec codec = Codec::Codec2_1300; DeliveryMode mode = DeliveryMode::Private; - bool source_unverified = false; - bool complete = false; + /** Packed source/complete/direction/read state; see VoiceMessageFlag. */ + uint8_t flags = 0U; + VoiceDeliveryState delivery = VoiceDeliveryState::Received; + VoicePresentationProtocol presentation_protocol = + VoicePresentationProtocol::Unknown; + uint8_t presentation_channel = kVoicePresentationPrimaryChannel; }; +// Eight metadata entries are projected to the chat UI. Keep status/direction +// extension budget-neutral rather than silently increasing Pager RAM use. +static_assert(sizeof(VoiceMessageMetadata) == 40U, + "VMP metadata must remain within its fixed PSRAM budget"); + +inline bool voiceMessageHasFlag(const VoiceMessageMetadata& metadata, + VoiceMessageFlag flag) +{ + return (metadata.flags & static_cast(flag)) != 0U; +} + +inline void setVoiceMessageFlag(VoiceMessageMetadata* metadata, + VoiceMessageFlag flag, + bool enabled) +{ + if (!metadata) + { + return; + } + if (enabled) + { + metadata->flags |= static_cast(flag); + } + else + { + metadata->flags &= static_cast(~static_cast(flag)); + } +} + +inline bool voiceMessageSourceUnverified(const VoiceMessageMetadata& metadata) +{ + return voiceMessageHasFlag(metadata, VoiceMessageFlagSourceUnverified); +} + +inline bool voiceMessageComplete(const VoiceMessageMetadata& metadata) +{ + return voiceMessageHasFlag(metadata, VoiceMessageFlagComplete); +} + +/** True for a locally composed object; false for an accepted inbound object. */ +inline bool voiceMessageOutgoing(const VoiceMessageMetadata& metadata) +{ + return voiceMessageHasFlag(metadata, VoiceMessageFlagOutgoing); +} + +inline bool voiceMessageRead(const VoiceMessageMetadata& metadata) +{ + return voiceMessageHasFlag(metadata, VoiceMessageFlagRead); +} + +inline bool isValidVoicePresentationBinding(VoicePresentationProtocol protocol, + uint8_t channel) +{ + return protocol == VoicePresentationProtocol::Meshtastic || + protocol == VoicePresentationProtocol::MeshCore || + protocol == VoicePresentationProtocol::Reticulum + ? channel <= kVoicePresentationMaxChannel + : false; +} + struct VoiceMessageView { VoiceMessageMetadata metadata{}; @@ -63,8 +168,38 @@ class VoiceMessageInbox final std::size_t encoded_media_len, bool complete, uint32_t received_at_seconds, + VoicePresentationProtocol presentation_protocol, + uint8_t presentation_channel, uint64_t* out_local_id = nullptr); + /** + * Stores one locally composed, already-encoded voice object before carrier + * transmission begins. This creates the IM-style outgoing bubble without + * duplicating the media buffer; caller updates its state after the carrier + * attempt completes. + */ + VoiceInboxStoreResult storeOutgoing(const ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len, + uint32_t created_at_seconds, + VoicePresentationProtocol presentation_protocol, + uint8_t presentation_channel, + uint64_t* out_local_id = nullptr); + + /** Updates local-only delivery state for a previously stored outgoing object. */ + bool updateDeliveryState(uint64_t local_id, VoiceDeliveryState delivery); + + /** Marks accepted inbound objects in one local conversation as read. */ + bool markConversationRead(VoicePresentationProtocol protocol, + uint8_t channel, + uint32_t peer_id, + bool broadcast); + + /** Assigns a safe binding to legacy snapshots that predate this metadata. */ + bool bindUnassignedMessages(VoicePresentationProtocol protocol, + uint8_t channel, + bool mark_incoming_read); + bool get(uint64_t local_id, VoiceMessageView* out_view) const; /** @@ -85,11 +220,15 @@ class VoiceMessageInbox final * * The original local ID is retained so a chat projection's playback * target survives a reboot. Restored records participate in the ordinary - * duplicate check and bounded oldest-entry replacement policy. + * duplicate check and bounded oldest-entry replacement policy. A + * nonzero `insertion_sequence` is supplied by a durable snapshot loader + * when that format stores records newest-first; it preserves the original + * time ordering without expanding the on-disk record ABI. */ bool restore(const VoiceMessageMetadata& metadata, const uint8_t* encoded_media, - std::size_t encoded_media_len); + std::size_t encoded_media_len, + uint64_t insertion_sequence = 0U); bool erase(uint64_t local_id); void clear(); diff --git a/modules/core_chat/include/chat/infra/voice/vmp_wire.h b/modules/core_chat/include/chat/infra/voice/vmp_wire.h index b85325dd..b3cbec22 100644 --- a/modules/core_chat/include/chat/infra/voice/vmp_wire.h +++ b/modules/core_chat/include/chat/infra/voice/vmp_wire.h @@ -77,7 +77,12 @@ struct ControlFrame { ControlType type = ControlType::Offer; uint8_t flags = 0; - uint8_t key_or_profile_id = 0; + /** + * Logical local chat channel, independent of the 2.4 GHz RF channel. + * It is authenticated for private control and CRC-covered for public + * broadcast so a clip cannot be projected into a different chat thread. + */ + uint8_t conversation_channel = 0; uint32_t sender_id = 0; uint32_t target_id = 0; uint64_t session_id = 0; diff --git a/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp b/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp index 7a261f56..91fc26b9 100644 --- a/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp +++ b/modules/core_chat/src/infra/voice/vmp_voice_inbox.cpp @@ -28,17 +28,64 @@ bool validInboundControl(const ControlFrame& control, DeliveryMode* out_mode) deliveryModeFor(control, out_mode); } +bool validOutboundControl(const ControlFrame& control, DeliveryMode* out_mode) +{ + if (!out_mode || !isValidControlFrame(control) || + !deliveryModeFor(control, out_mode)) + { + return false; + } + return (*out_mode == DeliveryMode::Private && + control.type == ControlType::Offer) || + (*out_mode == DeliveryMode::Broadcast && + control.type == ControlType::Announce); +} + +bool validDeliveryState(VoiceDeliveryState delivery) +{ + return delivery == VoiceDeliveryState::Received || + delivery == VoiceDeliveryState::Sending || + delivery == VoiceDeliveryState::Sent || + delivery == VoiceDeliveryState::Failed; +} + bool validRestoredMetadata(const VoiceMessageMetadata& metadata, std::size_t encoded_media_len) { - if (metadata.local_id == 0U || !metadata.complete || + if (metadata.local_id == 0U || !voiceMessageComplete(metadata) || encoded_media_len == 0U || encoded_media_len > kMaxEncodedMediaSize || - encoded_media_len != metadata.encoded_media_len) + encoded_media_len != metadata.encoded_media_len || + (metadata.presentation_protocol != VoicePresentationProtocol::Unknown && + !isValidVoicePresentationBinding(metadata.presentation_protocol, + metadata.presentation_channel))) { return false; } - if (metadata.codec != Codec::Codec2_1300) + if (metadata.codec != Codec::Codec2_1300 || + !validDeliveryState(metadata.delivery)) + { + return false; + } + + if (voiceMessageOutgoing(metadata)) + { + if (voiceMessageSourceUnverified(metadata) || + metadata.delivery == VoiceDeliveryState::Received) + { + return false; + } + if (metadata.mode == DeliveryMode::Broadcast) + { + return metadata.sender_id != 0U && + metadata.target_id == kBroadcastTargetId; + } + return metadata.mode == DeliveryMode::Private && + metadata.sender_id != 0U && metadata.target_id != 0U && + metadata.target_id != kBroadcastTargetId; + } + + if (metadata.delivery != VoiceDeliveryState::Received) { return false; } @@ -46,13 +93,13 @@ bool validRestoredMetadata(const VoiceMessageMetadata& metadata, if (metadata.mode == DeliveryMode::Broadcast) { return metadata.target_id == kBroadcastTargetId && - metadata.source_unverified; + voiceMessageSourceUnverified(metadata); } if (metadata.mode == DeliveryMode::Private) { return metadata.sender_id != 0U && metadata.target_id != 0U && metadata.target_id != kBroadcastTargetId && - !metadata.source_unverified; + !voiceMessageSourceUnverified(metadata); } return false; } @@ -70,6 +117,8 @@ VoiceInboxStoreResult VoiceMessageInbox::store(const ControlFrame& control, std::size_t encoded_media_len, bool complete, uint32_t received_at_seconds, + VoicePresentationProtocol presentation_protocol, + uint8_t presentation_channel, uint64_t* out_local_id) { if (out_local_id) @@ -81,7 +130,9 @@ VoiceInboxStoreResult VoiceMessageInbox::store(const ControlFrame& control, if (!encoded_media || encoded_media_len == 0U || encoded_media_len > kMaxEncodedMediaSize || encoded_media_len != control.encoded_media_len || - !validInboundControl(control, &mode)) + !validInboundControl(control, &mode) || + !isValidVoicePresentationBinding(presentation_protocol, + presentation_channel)) { return VoiceInboxStoreResult::Invalid; } @@ -115,8 +166,14 @@ VoiceInboxStoreResult VoiceMessageInbox::store(const ControlFrame& control, static_cast(encoded_media_len); destination->metadata.codec = control.codec; destination->metadata.mode = mode; - destination->metadata.source_unverified = mode == DeliveryMode::Broadcast; - destination->metadata.complete = complete; + destination->metadata.flags = 0U; + setVoiceMessageFlag(&destination->metadata, + VoiceMessageFlagSourceUnverified, + mode == DeliveryMode::Broadcast); + setVoiceMessageFlag(&destination->metadata, VoiceMessageFlagComplete, complete); + destination->metadata.presentation_protocol = presentation_protocol; + destination->metadata.presentation_channel = presentation_channel; + destination->metadata.delivery = VoiceDeliveryState::Received; std::memcpy(destination->encoded_media, encoded_media, encoded_media_len); destination->insertion_sequence = next_insertion_sequence_++; if (next_insertion_sequence_ == 0U) @@ -135,6 +192,162 @@ VoiceInboxStoreResult VoiceMessageInbox::store(const ControlFrame& control, return VoiceInboxStoreResult::Stored; } +VoiceInboxStoreResult VoiceMessageInbox::storeOutgoing( + const ControlFrame& control, + const uint8_t* encoded_media, + std::size_t encoded_media_len, + uint32_t created_at_seconds, + VoicePresentationProtocol presentation_protocol, + uint8_t presentation_channel, + uint64_t* out_local_id) +{ + if (out_local_id) + { + *out_local_id = 0U; + } + + DeliveryMode mode = DeliveryMode::Private; + if (!encoded_media || encoded_media_len == 0U || + encoded_media_len > kMaxEncodedMediaSize || + encoded_media_len != control.encoded_media_len || + !validOutboundControl(control, &mode) || + !isValidVoicePresentationBinding(presentation_protocol, + presentation_channel)) + { + return VoiceInboxStoreResult::Invalid; + } + if (isDuplicate(control)) + { + return VoiceInboxStoreResult::Duplicate; + } + + Slot* const destination = selectDestination(); + if (!destination) + { + return VoiceInboxStoreResult::Invalid; + } + const bool replacing = destination->occupied; + if (replacing) + { + clearSlot(destination); + } + + destination->metadata.local_id = next_local_id_++; + if (next_local_id_ == 0U) + { + next_local_id_ = 1U; + } + destination->metadata.sender_id = control.sender_id; + destination->metadata.target_id = control.target_id; + destination->metadata.session_id = control.session_id; + destination->metadata.object_fingerprint = control.object_fingerprint; + destination->metadata.received_at_seconds = created_at_seconds; + destination->metadata.encoded_media_len = + static_cast(encoded_media_len); + destination->metadata.codec = control.codec; + destination->metadata.mode = mode; + destination->metadata.flags = 0U; + setVoiceMessageFlag(&destination->metadata, VoiceMessageFlagComplete, true); + setVoiceMessageFlag(&destination->metadata, VoiceMessageFlagOutgoing, true); + setVoiceMessageFlag(&destination->metadata, VoiceMessageFlagRead, true); + destination->metadata.presentation_protocol = presentation_protocol; + destination->metadata.presentation_channel = presentation_channel; + destination->metadata.delivery = VoiceDeliveryState::Sending; + std::memcpy(destination->encoded_media, encoded_media, encoded_media_len); + destination->insertion_sequence = next_insertion_sequence_++; + if (next_insertion_sequence_ == 0U) + { + next_insertion_sequence_ = 1U; + } + destination->occupied = true; + if (!replacing) + { + ++size_; + } + if (out_local_id) + { + *out_local_id = destination->metadata.local_id; + } + return VoiceInboxStoreResult::Stored; +} + +bool VoiceMessageInbox::updateDeliveryState(uint64_t local_id, + VoiceDeliveryState delivery) +{ + if (local_id == 0U || delivery == VoiceDeliveryState::Received || + !validDeliveryState(delivery)) + { + return false; + } + for (Slot& slot : slots_) + { + if (slot.occupied && slot.metadata.local_id == local_id && + voiceMessageOutgoing(slot.metadata)) + { + slot.metadata.delivery = delivery; + return true; + } + } + return false; +} + +bool VoiceMessageInbox::markConversationRead(VoicePresentationProtocol protocol, + uint8_t channel, + uint32_t peer_id, + bool broadcast) +{ + if (!isValidVoicePresentationBinding(protocol, channel)) + { + return false; + } + bool changed = false; + for (Slot& slot : slots_) + { + VoiceMessageMetadata& metadata = slot.metadata; + if (!slot.occupied || voiceMessageOutgoing(metadata) || + voiceMessageRead(metadata) || metadata.presentation_protocol != protocol || + metadata.presentation_channel != channel || + (broadcast ? metadata.mode != DeliveryMode::Broadcast + : (metadata.mode != DeliveryMode::Private || + metadata.sender_id != peer_id))) + { + continue; + } + setVoiceMessageFlag(&metadata, VoiceMessageFlagRead, true); + changed = true; + } + return changed; +} + +bool VoiceMessageInbox::bindUnassignedMessages( + VoicePresentationProtocol protocol, + uint8_t channel, + bool mark_incoming_read) +{ + if (!isValidVoicePresentationBinding(protocol, channel)) + { + return false; + } + bool changed = false; + for (Slot& slot : slots_) + { + VoiceMessageMetadata& metadata = slot.metadata; + if (!slot.occupied || + metadata.presentation_protocol != VoicePresentationProtocol::Unknown) + { + continue; + } + metadata.presentation_protocol = protocol; + metadata.presentation_channel = channel; + if (mark_incoming_read && !voiceMessageOutgoing(metadata)) + { + setVoiceMessageFlag(&metadata, VoiceMessageFlagRead, true); + } + changed = true; + } + return changed; +} + bool VoiceMessageInbox::get(uint64_t local_id, VoiceMessageView* out_view) const { if (!out_view || local_id == 0U) @@ -189,29 +402,40 @@ std::size_t VoiceMessageInbox::listMetadata(VoiceMessageMetadata* out_metadata, bool VoiceMessageInbox::restore(const VoiceMessageMetadata& metadata, const uint8_t* encoded_media, - std::size_t encoded_media_len) + std::size_t encoded_media_len, + uint64_t insertion_sequence) { if (!encoded_media || !validRestoredMetadata(metadata, encoded_media_len)) { return false; } + VoiceMessageMetadata restored_metadata = metadata; + // A VMP carrier attempt cannot survive reboot and VMP has no resume or + // retry queue. Never present a stale `Sending` bubble as live work after + // storage hydration; it is an interrupted local send. + if (voiceMessageOutgoing(restored_metadata) && + restored_metadata.delivery == VoiceDeliveryState::Sending) + { + restored_metadata.delivery = VoiceDeliveryState::Failed; + } + for (const Slot& slot : slots_) { if (!slot.occupied) { continue; } - if (slot.metadata.local_id == metadata.local_id) + if (slot.metadata.local_id == restored_metadata.local_id) { - return slot.metadata.sender_id == metadata.sender_id && - slot.metadata.session_id == metadata.session_id && + return slot.metadata.sender_id == restored_metadata.sender_id && + slot.metadata.session_id == restored_metadata.session_id && slot.metadata.object_fingerprint == - metadata.object_fingerprint && + restored_metadata.object_fingerprint && slot.metadata.encoded_media_len == encoded_media_len; } - if (slot.metadata.sender_id == metadata.sender_id && - slot.metadata.session_id == metadata.session_id) + if (slot.metadata.sender_id == restored_metadata.sender_id && + slot.metadata.session_id == restored_metadata.session_id) { return true; } @@ -228,18 +452,31 @@ bool VoiceMessageInbox::restore(const VoiceMessageMetadata& metadata, clearSlot(destination); } - destination->metadata = metadata; + destination->metadata = restored_metadata; std::memcpy(destination->encoded_media, encoded_media, encoded_media_len); - destination->insertion_sequence = next_insertion_sequence_; - next_insertion_sequence_ = nextNonZero(next_insertion_sequence_); + // V1 snapshots are written newest-first. The caller supplies the + // descending historical sequence for that representation so an SD + // hydrate preserves the same newest-first list and oldest-slot eviction + // behavior as the live inbox. Ad-hoc restore users retain the ordinary + // append-to-newest behavior with the default zero value. + if (insertion_sequence == 0U) + { + insertion_sequence = next_insertion_sequence_; + next_insertion_sequence_ = nextNonZero(next_insertion_sequence_); + } + else if (insertion_sequence >= next_insertion_sequence_) + { + next_insertion_sequence_ = nextNonZero(insertion_sequence); + } + destination->insertion_sequence = insertion_sequence; destination->occupied = true; if (!replacing) { ++size_; } - if (metadata.local_id >= next_local_id_) + if (restored_metadata.local_id >= next_local_id_) { - next_local_id_ = nextNonZero(metadata.local_id); + next_local_id_ = nextNonZero(restored_metadata.local_id); } return true; } diff --git a/modules/core_chat/src/infra/voice/vmp_wire.cpp b/modules/core_chat/src/infra/voice/vmp_wire.cpp index 7488c540..f569c4d8 100644 --- a/modules/core_chat/src/infra/voice/vmp_wire.cpp +++ b/modules/core_chat/src/infra/voice/vmp_wire.cpp @@ -199,6 +199,10 @@ bool isValidControlFrame(const ControlFrame& frame) { return false; } + if (frame.conversation_channel > 7U) + { + return false; + } if (mode == DeliveryMode::Private) { @@ -216,7 +220,6 @@ bool isValidControlFrame(const ControlFrame& frame) else { if (frame.target_id != kBroadcastTargetId || frame.type == ControlType::Accept || - frame.key_or_profile_id != 0 || (frame.flags & ControlFlagPublicBroadcast) == 0) { return false; @@ -270,7 +273,7 @@ bool encodeControlFrame(const ControlFrame& frame, out[2] = kVersion; out[3] = static_cast(frame.type); out[4] = frame.flags; - out[5] = frame.key_or_profile_id; + out[5] = frame.conversation_channel; writeU32(frame.sender_id, out + 6); writeU32(frame.target_id, out + 10); writeU64(frame.session_id, out + 14); @@ -307,7 +310,7 @@ bool decodeControlFrame(const uint8_t* data, ControlFrame frame{}; frame.type = static_cast(data[3]); frame.flags = data[4]; - frame.key_or_profile_id = data[5]; + frame.conversation_channel = data[5]; frame.sender_id = readU32(data + 6); frame.target_id = readU32(data + 10); frame.session_id = readU64(data + 14); diff --git a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp index a274ec53..b7cb2966 100644 --- a/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp +++ b/modules/core_chat/tests/test_vmp_attachment_persistence_contract.cpp @@ -23,6 +23,15 @@ std::size_t positionOf(const std::string& source, const std::string& needle) return position; } +std::size_t positionOfAfter(const std::string& source, + const std::string& needle, + std::size_t offset) +{ + const std::size_t position = source.find(needle, offset); + assert(position != std::string::npos); + return position; +} + } // namespace // This is deliberately a source-level boundary test. The ESP attachment @@ -50,12 +59,39 @@ int main(int argc, char** argv) "voice/vmp_pager_session.h"); const std::string pager_audio = readFile( root / "platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp"); + const std::string chat_controller = readFile( + root / "modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp"); + const std::string chat_compose = readFile( + root / "modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp"); const std::size_t store_completed = positionOf(session, "bool storeCompletedVoice("); - const std::size_t durable_commit = positionOf(session, "persistVoiceInbox("); - const std::size_t rollback = positionOf(session, "media_->inbox.erase(local_id)"); - assert(store_completed < durable_commit); + const std::size_t outbound_store = positionOf(session, "bool storeOutboundVoice()"); + const std::string inbound_store_body = + session.substr(store_completed, outbound_store - store_completed); + const std::size_t durable_commit = positionOf(inbound_store_body, "persistVoiceInbox("); + const std::size_t rollback = positionOf(inbound_store_body, "media_->inbox.erase(local_id)"); assert(durable_commit < rollback); + + // VMP is a local attachment-message extension, not a receive-only + // scratch inbox. The sender must durably create `Sending` before choosing + // a carrier, then persist a terminal local state after that one attempt. + const std::size_t outbound_run = positionOf(session, "void runOutbound()"); + const std::size_t outbound_store_call = positionOfAfter( + session, "storeOutboundVoice()", outbound_run); + const std::size_t carrier_select = positionOfAfter( + session, "if (!direct_rf_voice_supported_)", outbound_store_call); + const std::size_t terminal_commit = positionOfAfter( + session, "commitOutboundDelivery(", carrier_select); + assert(outbound_store_call < carrier_select); + assert(carrier_select < terminal_commit); + const std::size_t outbound_store_commit = positionOfAfter( + session, "persistVoiceInbox(", outbound_store); + const std::size_t outbound_local_id = positionOfAfter( + session, "outbound_local_id_ = local_id", outbound_store_commit); + assert(outbound_store_commit < outbound_local_id); + assert(session.find("VoiceDeliveryState::Sending") != std::string::npos); + assert(session.find("VoiceDeliveryState::Sent") != std::string::npos); + assert(session.find("VoiceDeliveryState::Failed") != std::string::npos); assert(session.find("requires_durable_attachment_store_") != std::string::npos); assert(session.find("inbox_ready_") != std::string::npos); @@ -65,6 +101,41 @@ int main(int argc, char** argv) std::string::npos); assert(app.find("vmp_session::servicePersistentInbox()") != std::string::npos); + // The VMP runtime projects a chat-message timeline in both directions. + // Do not regress to the original receive-only inbox API: the sender's + // durable Sending/Sent/Failed object must be visible to the controller. + assert(app.find("std::size_t listMessages(") != std::string::npos); + assert(chat_controller.find("::ui::chat_voice::listMessages(") != + std::string::npos); + assert(chat_controller.find("::ui::chat_voice::listReceivedMessages(") == + std::string::npos); + // Typed attachments have the same conversation boundary as text: a peer + // number alone is insufficient because channels and mesh backends can + // legitimately reuse it. The logical chat channel is part of VMP control + // and the local protocol/channel binding is persisted/projected. + assert(chat_controller.find("summary.presentation_protocol !=") != + std::string::npos); + assert(chat_controller.find("appendVoiceConversationsToControllerList") != + std::string::npos); + assert(chat_controller.find("::ui::chat_voice::markConversationRead(") != + std::string::npos); + assert(chat_controller.find("current_conv_.peer == 0U ? chat::voice::vmp::kBroadcastTargetId") != + std::string::npos); + assert(session.find("outgoing_control_.conversation_channel =") != + std::string::npos); + assert(session.find("control.conversation_channel") != std::string::npos); + assert(app.find("metadata.presentation_protocol") != std::string::npos); + assert(app.find("metadata.presentation_channel") != std::string::npos); + // Press-to-talk begins on PRESSED, but release must leave the compose + // event stack before it can destroy that view. The conversation then + // polls the local attachment commit quickly enough to expose Sending. + assert(chat_compose.find("LV_EVENT_PRESSED") != std::string::npos); + assert(chat_compose.find("schedule_action_async(ActionIntent::VoiceStop)") != + std::string::npos); + assert(chat_controller.find("kVoiceProjectionBusyPollMs = 150U") != + std::string::npos); + assert(chat_controller.find("voice release stop_requested=%u; return to timeline") != + std::string::npos); assert(app.find("MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT") != std::string::npos); assert(app.find("UI metadata scratch unavailable in PSRAM") != @@ -77,7 +148,24 @@ int main(int argc, char** argv) assert(attachment.find("inbox.v1.tmp") != std::string::npos); assert(attachment.find("inbox.v1.bak") != std::string::npos); assert(attachment.find("payload_crc32") != std::string::npos); + assert(attachment.find("kSnapshotSchemaVersion = 2U") != std::string::npos); + assert(attachment.find("record.reserved[1] = static_cast(metadata.presentation_protocol)") != + std::string::npos); + assert(attachment.find("record.reserved[2] = metadata.presentation_channel") != + std::string::npos); + assert(attachment.find("kVoiceReadFlag") != std::string::npos); assert(attachment.find("restoreVoiceInboxSnapshot") != std::string::npos); + assert(attachment.find("header.record_count - index") != std::string::npos); + // Snapshot records and restore sequencing are a matched on-disk contract: + // listMetadata() is newest-first, serialization must retain that order, + // and hydrate assigns the first record the highest sequence. Reversing + // only one side silently inverts the chat timeline after reboot. + const std::size_t persist = positionOf(attachment, "bool persistVoiceInbox("); + const std::string persist_body = attachment.substr(persist); + assert(persist_body.find("for (std::size_t index = 0U; wrote && index < count; ++index)") != + std::string::npos); + assert(persist_body.find("metadata_scratch[index]") != std::string::npos); + assert(persist_body.find("metadata_scratch[remaining - 1U]") == std::string::npos); assert(attachment.find("const VoiceInboxLoadResult backup_result") != std::string::npos); assert(attachment.find("return backup_result;") != std::string::npos); diff --git a/modules/core_chat/tests/test_vmp_voice_inbox.cpp b/modules/core_chat/tests/test_vmp_voice_inbox.cpp index 4896dde6..7f11e0a6 100644 --- a/modules/core_chat/tests/test_vmp_voice_inbox.cpp +++ b/modules/core_chat/tests/test_vmp_voice_inbox.cpp @@ -10,6 +10,10 @@ namespace using namespace chat::voice::vmp; +constexpr VoicePresentationProtocol kTestPresentation = + VoicePresentationProtocol::Meshtastic; +constexpr uint8_t kTestChannel = 0U; + ControlFrame privateOffer(uint32_t sender, uint64_t session_id, uint16_t media_len) { ControlFrame control{}; @@ -52,7 +56,8 @@ void test_store_local_only_voice_and_dedupe() const std::array media = {1U, 2U, 3U, 4U, 5U, 6U, 7U}; uint64_t local_id = 0U; - assert(inbox.store(control, media.data(), media.size(), true, 123U, &local_id) == + assert(inbox.store(control, media.data(), media.size(), true, 123U, + kTestPresentation, kTestChannel, &local_id) == VoiceInboxStoreResult::Stored); assert(local_id != 0U); assert(inbox.size() == 1U); @@ -60,14 +65,15 @@ void test_store_local_only_voice_and_dedupe() VoiceMessageView view{}; assert(inbox.get(local_id, &view)); assert(view.metadata.sender_id == 42U); - assert(view.metadata.complete); - assert(!view.metadata.source_unverified); + assert(voiceMessageComplete(view.metadata)); + assert(!voiceMessageSourceUnverified(view.metadata)); assert(view.metadata.encoded_media_len == media.size()); for (std::size_t index = 0U; index < media.size(); ++index) { assert(view.encoded_media[index] == media[index]); } - assert(inbox.store(control, media.data(), media.size(), true, 124U, nullptr) == + assert(inbox.store(control, media.data(), media.size(), true, 124U, + kTestPresentation, kTestChannel, nullptr) == VoiceInboxStoreResult::Duplicate); } @@ -77,13 +83,14 @@ void test_broadcast_is_explicitly_unverified() const auto control = broadcastAnnounce(9U, 0xAA55ULL, 7U); const std::array media = {7U, 6U, 5U, 4U, 3U, 2U, 1U}; uint64_t local_id = 0U; - assert(inbox.store(control, media.data(), media.size(), true, 0U, &local_id) == + assert(inbox.store(control, media.data(), media.size(), true, 0U, + kTestPresentation, kTestChannel, &local_id) == VoiceInboxStoreResult::Stored); VoiceMessageView view{}; assert(inbox.get(local_id, &view)); assert(view.metadata.mode == DeliveryMode::Broadcast); - assert(view.metadata.source_unverified); + assert(voiceMessageSourceUnverified(view.metadata)); } void test_bad_media_is_never_persisted() @@ -91,7 +98,8 @@ void test_bad_media_is_never_persisted() VoiceMessageInbox inbox; const auto control = privateOffer(42U, 100U, 7U); const std::array too_short = {}; - assert(inbox.store(control, too_short.data(), too_short.size(), true, 0U, nullptr) == + assert(inbox.store(control, too_short.data(), too_short.size(), true, 0U, + kTestPresentation, kTestChannel, nullptr) == VoiceInboxStoreResult::Invalid); assert(inbox.size() == 0U); } @@ -103,9 +111,11 @@ void test_metadata_lists_newest_first_without_media() const auto second = privateOffer(0x1002U, 0xA2U, 7U); const std::array first_media = {1U, 1U, 1U, 1U, 1U, 1U, 1U}; const std::array second_media = {2U, 2U, 2U, 2U, 2U, 2U, 2U}; - assert(inbox.store(first, first_media.data(), first_media.size(), true, 10U, nullptr) == + assert(inbox.store(first, first_media.data(), first_media.size(), true, 10U, + kTestPresentation, kTestChannel, nullptr) == VoiceInboxStoreResult::Stored); - assert(inbox.store(second, second_media.data(), second_media.size(), true, 20U, nullptr) == + assert(inbox.store(second, second_media.data(), second_media.size(), true, 20U, + kTestPresentation, kTestChannel, nullptr) == VoiceInboxStoreResult::Stored); VoiceMessageMetadata metadata[2] = {}; @@ -127,6 +137,8 @@ void test_restore_retains_playback_identity_and_deduplication() media.size(), true, 321U, + kTestPresentation, + kTestChannel, &local_id) == VoiceInboxStoreResult::Stored); VoiceMessageView original_view{}; @@ -145,14 +157,171 @@ void test_restore_retains_playback_identity_and_deduplication() { assert(restored_view.encoded_media[index] == media[index]); } - assert(restored.store(control, media.data(), media.size(), true, 322U, nullptr) == + assert(restored.store(control, media.data(), media.size(), true, 322U, + kTestPresentation, kTestChannel, nullptr) == VoiceInboxStoreResult::Duplicate); VoiceMessageMetadata invalid = original_view.metadata; - invalid.complete = false; + setVoiceMessageFlag(&invalid, VoiceMessageFlagComplete, false); assert(!VoiceMessageInbox{}.restore(invalid, media.data(), media.size())); } +void test_outgoing_voice_keeps_local_delivery_lifecycle() +{ + VoiceMessageInbox inbox; + const auto control = privateOffer(0x11223344U, 0x12345678U, 7U); + const std::array media = {1U, 3U, 3U, 7U, 0U, 1U, 9U}; + uint64_t local_id = 0U; + + assert(inbox.storeOutgoing(control, + media.data(), + media.size(), + 999U, + kTestPresentation, + kTestChannel, + &local_id) == VoiceInboxStoreResult::Stored); + VoiceMessageView sending{}; + assert(inbox.get(local_id, &sending)); + assert(voiceMessageOutgoing(sending.metadata)); + assert(!voiceMessageSourceUnverified(sending.metadata)); + assert(sending.metadata.delivery == VoiceDeliveryState::Sending); + assert(inbox.updateDeliveryState(local_id, VoiceDeliveryState::Sent)); + + VoiceMessageView sent{}; + assert(inbox.get(local_id, &sent)); + assert(sent.metadata.delivery == VoiceDeliveryState::Sent); + + VoiceMessageInbox restored; + assert(restored.restore(sent.metadata, + sent.encoded_media, + sent.metadata.encoded_media_len)); + VoiceMessageView restored_view{}; + assert(restored.get(local_id, &restored_view)); + assert(voiceMessageOutgoing(restored_view.metadata)); + assert(restored_view.metadata.delivery == VoiceDeliveryState::Sent); + assert(!restored.updateDeliveryState(local_id, VoiceDeliveryState::Received)); +} + +void test_interrupted_outgoing_send_restores_as_failed() +{ + VoiceMessageInbox original; + const auto control = broadcastAnnounce(0x11223344U, 0x55667788U, 7U); + const std::array media = {9U, 8U, 7U, 6U, 5U, 4U, 3U}; + uint64_t local_id = 0U; + assert(original.storeOutgoing(control, + media.data(), + media.size(), + 1234U, + kTestPresentation, + kTestChannel, + &local_id) == VoiceInboxStoreResult::Stored); + VoiceMessageView sending{}; + assert(original.get(local_id, &sending)); + + VoiceMessageInbox restored; + assert(restored.restore(sending.metadata, + sending.encoded_media, + sending.metadata.encoded_media_len)); + VoiceMessageView restored_view{}; + assert(restored.get(local_id, &restored_view)); + assert(restored_view.metadata.delivery == VoiceDeliveryState::Failed); +} + +void test_conversation_binding_and_read_scope_are_local_only() +{ + VoiceMessageInbox inbox; + const auto first = privateOffer(0x1111U, 0x111U, 7U); + const auto second = privateOffer(0x2222U, 0x222U, 7U); + const std::array media = {4U, 3U, 2U, 1U, 0U, 1U, 2U}; + uint64_t first_id = 0U; + uint64_t second_id = 0U; + assert(inbox.store(first, + media.data(), + media.size(), + true, + 100U, + VoicePresentationProtocol::Meshtastic, + 1U, + &first_id) == VoiceInboxStoreResult::Stored); + assert(inbox.store(second, + media.data(), + media.size(), + true, + 200U, + VoicePresentationProtocol::MeshCore, + 1U, + &second_id) == VoiceInboxStoreResult::Stored); + + VoiceMessageView first_view{}; + VoiceMessageView second_view{}; + assert(inbox.get(first_id, &first_view)); + assert(inbox.get(second_id, &second_view)); + assert(first_view.metadata.presentation_protocol == + VoicePresentationProtocol::Meshtastic); + assert(first_view.metadata.presentation_channel == 1U); + assert(!voiceMessageRead(first_view.metadata)); + assert(!voiceMessageRead(second_view.metadata)); + + assert(inbox.markConversationRead(VoicePresentationProtocol::Meshtastic, + 1U, + first.sender_id, + false)); + assert(inbox.get(first_id, &first_view)); + assert(inbox.get(second_id, &second_view)); + assert(voiceMessageRead(first_view.metadata)); + assert(!voiceMessageRead(second_view.metadata)); + assert(!inbox.markConversationRead(VoicePresentationProtocol::Meshtastic, + 1U, + second.sender_id, + false)); +} + +void test_snapshot_newest_first_restore_preserves_timeline_order() +{ + VoiceMessageInbox original; + const auto older = privateOffer(0x11223344U, 0x101U, 7U); + const auto newer = privateOffer(0x11223344U, 0x202U, 7U); + const std::array older_media = {1U, 1U, 1U, 1U, 1U, 1U, 1U}; + const std::array newer_media = {2U, 2U, 2U, 2U, 2U, 2U, 2U}; + assert(original.store(older, + older_media.data(), + older_media.size(), + true, + 100U, + kTestPresentation, + kTestChannel, + nullptr) == VoiceInboxStoreResult::Stored); + assert(original.store(newer, + newer_media.data(), + newer_media.size(), + true, + 200U, + kTestPresentation, + kTestChannel, + nullptr) == VoiceInboxStoreResult::Stored); + + VoiceMessageMetadata snapshot[2] = {}; + assert(original.listMetadata(snapshot, 2U) == 2U); + assert(snapshot[0].session_id == newer.session_id); + assert(snapshot[1].session_id == older.session_id); + + VoiceMessageInbox restored; + for (std::size_t index = 0U; index < 2U; ++index) + { + VoiceMessageView view{}; + assert(original.get(snapshot[index].local_id, &view)); + assert(restored.restore(snapshot[index], + view.encoded_media, + view.metadata.encoded_media_len, + static_cast(2U - index))); + } + + VoiceMessageMetadata listed[2] = {}; + assert(restored.listMetadata(listed, 2U) == 2U); + assert(listed[0].session_id == newer.session_id); + assert(listed[1].session_id == older.session_id); +} + } // namespace int main() @@ -162,5 +331,9 @@ int main() test_bad_media_is_never_persisted(); test_metadata_lists_newest_first_without_media(); test_restore_retains_playback_identity_and_deduplication(); + test_outgoing_voice_keeps_local_delivery_lifecycle(); + test_interrupted_outgoing_send_restores_as_failed(); + test_conversation_binding_and_read_scope_are_local_only(); + test_snapshot_newest_first_restore_preserves_timeline_order(); return 0; } diff --git a/modules/core_chat/tests/test_vmp_wire.cpp b/modules/core_chat/tests/test_vmp_wire.cpp index 4698b7c9..3f09cbb9 100644 --- a/modules/core_chat/tests/test_vmp_wire.cpp +++ b/modules/core_chat/tests/test_vmp_wire.cpp @@ -14,7 +14,7 @@ ControlFrame makePrivateOffer() ControlFrame frame{}; frame.type = ControlType::Offer; frame.flags = ControlFlagPrivate; - frame.key_or_profile_id = 7; + frame.conversation_channel = 7; frame.sender_id = 0x12345678U; frame.target_id = 0x90ABCDEFU; frame.session_id = 0x0123456789ABCDEFULL; @@ -52,6 +52,7 @@ void testPrivateControlRoundTrip() assert(decodeControlFrame(bytes.data(), bytes.size(), &actual)); assert(actual.type == expected.type); assert(actual.flags == expected.flags); + assert(actual.conversation_channel == expected.conversation_channel); assert(actual.sender_id == expected.sender_id); assert(actual.target_id == expected.target_id); assert(actual.session_id == expected.session_id); @@ -78,7 +79,7 @@ void testBroadcastAndInvalidControlConstraints() ControlFrame broadcast = makePrivateOffer(); broadcast.type = ControlType::Announce; broadcast.flags = ControlFlagBroadcast | ControlFlagPublicBroadcast; - broadcast.key_or_profile_id = 0; + broadcast.conversation_channel = 0; std::memset(broadcast.ephemeral_public_key, 0, sizeof(broadcast.ephemeral_public_key)); @@ -95,7 +96,7 @@ void testBroadcastAndInvalidControlConstraints() assert(!isValidControlFrame(invalid_broadcast_target)); ControlFrame invalid_broadcast_key = broadcast; - invalid_broadcast_key.key_or_profile_id = 1; + invalid_broadcast_key.conversation_channel = 8; assert(!isValidControlFrame(invalid_broadcast_key)); ControlFrame invalid_broadcast_ephemeral = broadcast; diff --git a/modules/ui_shared/include/ui/chat_voice_runtime.h b/modules/ui_shared/include/ui/chat_voice_runtime.h index e2ff0646..776bd517 100644 --- a/modules/ui_shared/include/ui/chat_voice_runtime.h +++ b/modules/ui_shared/include/ui/chat_voice_runtime.h @@ -23,15 +23,44 @@ enum class StartResult : uint8_t PrivateContactUnverified = 4, }; -/** @brief A local-only summary of a received VMP object for chat projection. */ +/** Local-only presentation state of one VMP voice attachment. */ +enum class DeliveryState : uint8_t +{ + Received = 0U, + Sending = 1U, + Sent = 2U, + Failed = 3U, +}; + +/** @brief A local-only summary of one VMP object for chat projection. */ struct MessageSummary { uint64_t local_id = 0U; uint32_t sender_id = 0U; uint32_t target_id = 0U; uint32_t received_at_seconds = 0U; + uint16_t duration_ms = 0U; bool private_message = false; bool source_unverified = false; + bool outgoing = false; + DeliveryState delivery = DeliveryState::Received; + /** Stable local conversation binding; never VMP media bytes. */ + uint8_t presentation_protocol = 0U; + uint8_t presentation_channel = 0U; + bool read = true; +}; + +// One conversation projection holds eight summaries. Keep this descriptor +// small and media-free: encoded audio belongs exclusively to the PSRAM VMP +// attachment store, never to the LVGL/controller working set. +static_assert(sizeof(MessageSummary) <= 32U, + "Voice message UI summaries must remain within their fixed memory budget"); + +struct SendRequest +{ + uint32_t target_id = 0U; + uint8_t presentation_protocol = 0U; + uint8_t presentation_channel = 0U; }; class IVoiceMessageRuntime @@ -41,9 +70,22 @@ class IVoiceMessageRuntime virtual bool isAvailable() const = 0; virtual bool canRecordAndSend() const = 0; - virtual StartResult requestRecordAndSend(uint32_t target_id) = 0; - virtual std::size_t listReceivedMessages(MessageSummary* out_messages, - std::size_t capacity) const = 0; + virtual StartResult requestRecordAndSend(const SendRequest& request) = 0; + /** Requests that the current press-to-talk capture stop after its current frame. */ + virtual bool requestStopRecording() = 0; + /** True from accepted press-to-talk until its carrier attempt finishes. */ + virtual bool isOutboundActive() const = 0; + /** + * Returns newest-first local VMP messages in both directions. The + * presentation layer uses this to merge incoming and outgoing voice + * attachment messages into one conversation timeline. + */ + virtual std::size_t listMessages(MessageSummary* out_messages, + std::size_t capacity) const = 0; + virtual bool markConversationRead(uint8_t presentation_protocol, + uint8_t presentation_channel, + uint32_t peer_id, + bool broadcast) = 0; virtual bool requestPlayback(uint64_t local_id) = 0; }; @@ -57,9 +99,34 @@ bool isAvailable(); bool canRecordAndSend(); /** @brief Requests an asynchronous record-and-send operation through VMP only. */ -StartResult requestRecordAndSend(uint32_t target_id); +StartResult requestRecordAndSend(const SendRequest& request); -/** @brief Retrieves newest-first local VMP summaries; never exposes audio bytes. */ +/** @brief Ends the current press-to-talk capture without aborting a valid send. */ +bool requestStopRecording(); + +/** @brief True while an accepted local voice capture/send is still active. */ +bool isOutboundActive(); + +/** + * @brief Retrieves newest-first local VMP summaries; never exposes audio bytes. + * + * The result contains both received and locally composed voice messages. It + * is intentionally named after a chat timeline, not an inbox, so future + * attachment presenters do not mistake outgoing VMP records for unavailable + * data. + */ +std::size_t listMessages(MessageSummary* out_messages, std::size_t capacity); + +/** Persists read state for accepted VMP objects in one displayed thread. */ +bool markConversationRead(uint8_t presentation_protocol, + uint8_t presentation_channel, + uint32_t peer_id, + bool broadcast); + +/** + * @deprecated Use listMessages(). Kept as a source-compatible wrapper for + * older UI integrations; it also returns outgoing messages. + */ std::size_t listReceivedMessages(MessageSummary* out_messages, std::size_t capacity); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_compose_components.h b/modules/ui_shared/include/ui/screens/chat/chat_compose_components.h index 4c486b85..16f81704 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_compose_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_compose_components.h @@ -27,7 +27,8 @@ class ChatComposeScreen { Send, Position, - Voice, + VoiceStart, + VoiceStop, Cancel }; @@ -86,6 +87,8 @@ class ChatComposeScreen static void async_back_cb(void* user_data); static void on_root_deleted(lv_event_t* e); static void on_action_click(lv_event_t* e); + static void on_voice_pressed(lv_event_t* e); + static void on_voice_released(lv_event_t* e); static void on_text_changed(lv_event_t* e); static void on_key(lv_event_t* e); static void on_back(void* user_data); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h index cf01d4b7..033fee23 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h @@ -98,7 +98,8 @@ class ChatConversationScreen enum class TimerDomain { ScreenGeneral, - Input + Input, + VoicePlayback }; struct TimerEntry @@ -155,6 +156,9 @@ class ChatConversationScreen struct VoicePlaybackContext { uint64_t local_id = 0U; + uint16_t duration_ms = 0U; + lv_obj_t* text_label = nullptr; + lv_timer_t* reset_timer = nullptr; }; lv_obj_t* container_ = nullptr; @@ -234,6 +238,7 @@ class ChatConversationScreen static void action_event_cb(lv_event_t* e); static void message_action_event_cb(lv_event_t* e); static void voice_message_event_cb(lv_event_t* e); + static void voice_playback_reset_cb(lv_timer_t* timer); static void scroll_event_cb(lv_event_t* e); static void async_action_cb(void* user_data); static void async_message_action_cb(void* user_data); 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 4f568eb8..1f449c8a 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 @@ -136,6 +136,8 @@ class UiController : public IChatUiRefreshSink void switchToCompose(chat::ConversationId conv); void appendVoiceMessagesToConversation(); uint64_t currentVoiceProjectionSignature(); + uint64_t currentVoiceListSignature(); + void updateVoiceComposeSession(); void handleChannelSelected(const chat::ConversationId& conv); void handlePingDestination(const chat::ConversationId& conv); void handleDeleteConversation(const chat::ConversationId& conv); @@ -187,7 +189,12 @@ class UiController : public IChatUiRefreshSink static constexpr std::size_t kVoiceProjectionCapacity = 8U; ::ui::chat_voice::MessageSummary voice_projection_buffer_[kVoiceProjectionCapacity] = {}; uint64_t rendered_voice_projection_signature_ = 0U; + uint64_t rendered_voice_list_signature_ = 0U; uint32_t voice_projection_last_poll_ms_ = 0U; + uint32_t voice_list_last_poll_ms_ = 0U; + uint32_t voice_hold_started_ms_ = 0U; + uint32_t voice_hold_last_render_ms_ = 0U; + bool voice_hold_active_ = false; bool conversation_list_dirty_ = true; bool conversation_list_loaded_ = false; bool conversation_view_loaded_ = false; diff --git a/modules/ui_shared/src/ui/chat_voice_runtime.cpp b/modules/ui_shared/src/ui/chat_voice_runtime.cpp index ad0409f3..84c4c0b0 100644 --- a/modules/ui_shared/src/ui/chat_voice_runtime.cpp +++ b/modules/ui_shared/src/ui/chat_voice_runtime.cpp @@ -29,16 +29,42 @@ bool canRecordAndSend() return s_runtime && s_runtime->canRecordAndSend(); } -StartResult requestRecordAndSend(uint32_t target_id) +StartResult requestRecordAndSend(const SendRequest& request) { - return s_runtime ? s_runtime->requestRecordAndSend(target_id) + return s_runtime ? s_runtime->requestRecordAndSend(request) : StartResult::Unsupported; } +bool requestStopRecording() +{ + return s_runtime && s_runtime->requestStopRecording(); +} + +bool isOutboundActive() +{ + return s_runtime && s_runtime->isOutboundActive(); +} + +std::size_t listMessages(MessageSummary* out_messages, std::size_t capacity) +{ + return s_runtime ? s_runtime->listMessages(out_messages, capacity) : 0U; +} + +bool markConversationRead(uint8_t presentation_protocol, + uint8_t presentation_channel, + uint32_t peer_id, + bool broadcast) +{ + return s_runtime && s_runtime->markConversationRead(presentation_protocol, + presentation_channel, + peer_id, + broadcast); +} + std::size_t listReceivedMessages(MessageSummary* out_messages, std::size_t capacity) { - return s_runtime ? s_runtime->listReceivedMessages(out_messages, capacity) : 0U; + return listMessages(out_messages, capacity); } bool requestPlayback(uint64_t local_id) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp index 0b77be44..a33c02a8 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp @@ -167,6 +167,21 @@ ChatComposeScreen::ChatComposeScreen(lv_obj_t* parent, chat::ConversationId conv impl_->cancel_ctx.intent = ActionIntent::Cancel; lv_obj_add_event_cb(impl_->w.send_btn, on_action_click, LV_EVENT_CLICKED, &impl_->send_ctx); lv_obj_add_event_cb(impl_->w.position_btn, on_action_click, LV_EVENT_CLICKED, &impl_->auxiliary_ctx); + // Voice mode deliberately uses press/release rather than CLICKED. CLICKED + // is emitted only after release, which would start a five-second capture + // after the user had already stopped holding the control. + lv_obj_add_event_cb(impl_->w.position_btn, + on_voice_pressed, + LV_EVENT_PRESSED, + &impl_->auxiliary_ctx); + lv_obj_add_event_cb(impl_->w.position_btn, + on_voice_released, + LV_EVENT_RELEASED, + &impl_->auxiliary_ctx); + lv_obj_add_event_cb(impl_->w.position_btn, + on_voice_released, + LV_EVENT_PRESS_LOST, + &impl_->auxiliary_ctx); lv_obj_add_event_cb(impl_->w.cancel_btn, on_action_click, LV_EVENT_CLICKED, &impl_->cancel_ctx); lv_obj_add_event_cb(impl_->w.send_btn, on_key, LV_EVENT_KEY, this); lv_obj_add_event_cb(impl_->w.position_btn, on_key, LV_EVENT_KEY, this); @@ -297,7 +312,7 @@ void ChatComposeScreen::setPositionButton(const char* label, bool visible) void ChatComposeScreen::setVoiceButton(const char* label, bool visible) { if (!impl_ || !impl_->w.position_btn) return; - impl_->auxiliary_ctx.intent = ActionIntent::Voice; + impl_->auxiliary_ctx.intent = ActionIntent::VoiceStart; if (label) { set_btn_label_text(impl_->w.position_btn, label); @@ -552,9 +567,54 @@ void ChatComposeScreen::on_action_click(lv_event_t* e) { return; } + // A voice hold has already begun on LV_EVENT_PRESSED. Ignore the synthetic + // click emitted after release so it cannot begin a second recording. + if (ctx->intent == ActionIntent::VoiceStart) + { + return; + } screen->schedule_action_async(ctx->intent); } +void ChatComposeScreen::on_voice_pressed(lv_event_t* e) +{ + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx || ctx->intent != ActionIntent::VoiceStart || !ctx->screen) + { + return; + } + auto* const screen = ctx->screen; + if (!screen->impl_ || !screen->impl_->guard || !screen->impl_->guard->alive || + !screen->action_cb_) + { + return; + } + CHAT_COMPOSE_LOG("[ChatCompose][VMP] voice press: begin capture\n"); + screen->action_cb_(ActionIntent::VoiceStart, screen->action_cb_user_data_); +} + +void ChatComposeScreen::on_voice_released(lv_event_t* e) +{ + auto* ctx = static_cast(lv_event_get_user_data(e)); + if (!ctx || ctx->intent != ActionIntent::VoiceStart || !ctx->screen) + { + return; + } + auto* const screen = ctx->screen; + if (!screen->impl_ || !screen->impl_->guard || !screen->impl_->guard->alive || + !screen->action_cb_) + { + return; + } + // Stopping capture may immediately switch back to the conversation and + // destroy this compose button. Defer that state transition until after + // LVGL has finished dispatching RELEASED/PRESS_LOST; recording itself was + // already started synchronously on PRESSED, so this does not reintroduce + // the old click-to-start behavior. + CHAT_COMPOSE_LOG("[ChatCompose][VMP] voice release: schedule capture stop\n"); + screen->schedule_action_async(ActionIntent::VoiceStop); +} + void ChatComposeScreen::on_text_changed(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); 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 7fe9e06d..68a45064 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 @@ -495,6 +495,58 @@ const char* message_ingress_label(::ui::chat::MessageIngressTransport transport) } return nullptr; } + +::ui::chat::MessageDeliveryState voice_delivery_state( + ::ui::chat_voice::DeliveryState delivery) +{ + switch (delivery) + { + case ::ui::chat_voice::DeliveryState::Sending: + return ::ui::chat::MessageDeliveryState::Sending; + case ::ui::chat_voice::DeliveryState::Sent: + return ::ui::chat::MessageDeliveryState::Sent; + case ::ui::chat_voice::DeliveryState::Failed: + return ::ui::chat::MessageDeliveryState::Failed; + case ::ui::chat_voice::DeliveryState::Received: + break; + } + return ::ui::chat::MessageDeliveryState::Received; +} + +const char* voice_source_label(const ::ui::chat_voice::MessageSummary& summary) +{ + if (summary.outgoing) + { + return summary.private_message ? "VMP private" : "VMP broadcast"; + } + return summary.source_unverified ? "VMP broadcast (unverified)" : "VMP private"; +} + +void format_voice_text(char* out, + std::size_t out_size, + uint16_t duration_ms, + bool playing) +{ + if (!out || out_size == 0U) + { + return; + } + const uint32_t tenths = (static_cast(duration_ms) + 50U) / 100U; + if (tenths == 0U) + { + std::snprintf(out, + out_size, + "%s", + playing ? "Playing voice..." : "Voice message - tap to play"); + return; + } + std::snprintf(out, + out_size, + "Voice %lu.%lus%s", + static_cast(tenths / 10U), + static_cast(tenths % 10U), + playing ? " - playing" : " - tap to play"); +} } // namespace static bool is_valid_epoch_ts(uint32_t ts) @@ -808,29 +860,37 @@ void ChatConversationScreen::addVoiceMessage( lv_obj_t* const bubble = chat::ui::layout::create_bubble(item.container); item.bubble = bubble; chat::ui::conversation::styles::apply_bubble( - bubble, false, summary.source_unverified); + bubble, summary.outgoing, summary.source_unverified); chat::ui::layout::set_bubble_max_width(bubble, kBubbleMaxWidth); lv_obj_add_flag(bubble, LV_OBJ_FLAG_CLICKABLE); char sender[16] = {}; - std::string sender_name = - app::messagingFacade().getContactService().getContactName(summary.sender_id); - if (sender_name.empty()) + std::string sender_name; + if (summary.outgoing) { - std::snprintf(sender, - sizeof(sender), - "%04lX", - static_cast(summary.sender_id & 0xFFFFU)); - sender_name = sender; + sender_name = "You"; + } + else + { + sender_name = app::messagingFacade().getContactService().getContactName( + summary.sender_id); + if (sender_name.empty()) + { + std::snprintf(sender, + sizeof(sender), + "%04lX", + static_cast(summary.sender_id & 0xFFFFU)); + sender_name = sender; + } } const lv_coord_t max_meta_w = std::max(kBubbleMaxWidth - 2 * bubble_pad_x(), 24); - item.meta_row = create_meta_row(bubble, max_meta_w, false); + item.meta_row = create_meta_row(bubble, max_meta_w, summary.outgoing); item.sender_label = create_meta_chip( item.meta_row, sender_name.c_str(), lv_color_hex(0xF1B75A), max_meta_w); item.source_label = create_meta_chip( item.meta_row, - summary.source_unverified ? "VMP broadcast (unverified)" : "VMP private", + voice_source_label(summary), summary.source_unverified ? lv_color_hex(0xFFB4A2) : lv_color_hex(0xCFE4FF), max_meta_w); char time_buf[24] = {}; @@ -839,20 +899,42 @@ void ChatConversationScreen::addVoiceMessage( summary.received_at_seconds); item.time_label = create_meta_chip( item.meta_row, time_buf, lv_color_hex(0xD4F0D2), max_meta_w); + if (summary.outgoing) + { + item.status_label = create_meta_chip( + item.meta_row, + "Sending...", + delivery_status_chip_color(voice_delivery_state(summary.delivery)), + max_meta_w); + update_delivery_status_chip(item.status_label, + voice_delivery_state(summary.delivery)); + } item.text_label = chat::ui::layout::create_bubble_text(bubble); chat::ui::conversation::styles::apply_bubble_text(item.text_label); - lv_label_set_text(item.text_label, "Voice message - tap to play"); + char voice_text[40] = {}; + format_voice_text(voice_text, sizeof(voice_text), summary.duration_ms, false); + lv_label_set_text(item.text_label, voice_text); ::ui::fonts::apply_chat_content_font( item.text_label, lv_label_get_text(item.text_label)); lv_obj_set_width(item.text_label, std::max(kBubbleMaxWidth - 2 * bubble_pad_x(), 24)); - item.voice_playback_ctx.reset(new VoicePlaybackContext{summary.local_id}); + item.voice_playback_ctx.reset(new VoicePlaybackContext{ + summary.local_id, summary.duration_ms, item.text_label, nullptr}); + item.voice_playback_ctx->reset_timer = add_timer( + voice_playback_reset_cb, + 1000U, + item.voice_playback_ctx.get(), + TimerDomain::VoicePlayback); + if (item.voice_playback_ctx->reset_timer) + { + lv_timer_pause(item.voice_playback_ctx->reset_timer); + } lv_obj_add_event_cb(bubble, voice_message_event_cb, LV_EVENT_CLICKED, item.voice_playback_ctx.get()); - chat::ui::layout::align_message_row(item.container, false); + chat::ui::layout::align_message_row(item.container, summary.outgoing); messages_.push_back(std::move(item)); } @@ -868,6 +950,7 @@ void ChatConversationScreen::clearMessages() CHAT_CONVERSATION_LOG("[ChatUiTrace] stage=conversation_clear reject\n"); return; } + clear_timers(TimerDomain::VoicePlayback); size_t index = 0; for (auto& item : messages_) { @@ -891,15 +974,61 @@ void ChatConversationScreen::voice_message_event_cb(lv_event_t* e) { return; } - const auto* context = - static_cast(lv_event_get_user_data(e)); + auto* context = + static_cast(lv_event_get_user_data(e)); if (!context || context->local_id == 0U) { return; } const bool started = ::ui::chat_voice::requestPlayback(context->local_id); - ::ui::feedback::show_notice(started ? "Playing voice" : "Voice playback unavailable", - started ? 1400 : 1800); + if (!started) + { + ::ui::feedback::show_notice("Voice audio is busy", 1600); + return; + } + + if (context->text_label && lv_obj_is_valid(context->text_label)) + { + char voice_text[40] = {}; + format_voice_text(voice_text, + sizeof(voice_text), + context->duration_ms, + true); + lv_label_set_text(context->text_label, voice_text); + ::ui::fonts::apply_chat_content_font(context->text_label, voice_text); + } + if (context->reset_timer) + { + const uint32_t reset_after_ms = + std::max(1000U, static_cast(context->duration_ms) + 500U); + lv_timer_set_period(context->reset_timer, reset_after_ms); + lv_timer_reset(context->reset_timer); + lv_timer_resume(context->reset_timer); + } + CHAT_CONVERSATION_LOG("[ChatUiTrace][VMP] playback queued local_id=%llu duration_ms=%u\n", + static_cast(context->local_id), + static_cast(context->duration_ms)); +} + +void ChatConversationScreen::voice_playback_reset_cb(lv_timer_t* timer) +{ + auto* context = timer ? static_cast( + lv_timer_get_user_data(timer)) + : nullptr; + if (context && context->text_label && lv_obj_is_valid(context->text_label)) + { + char voice_text[40] = {}; + format_voice_text(voice_text, + sizeof(voice_text), + context->duration_ms, + false); + lv_label_set_text(context->text_label, voice_text); + ::ui::fonts::apply_chat_content_font(context->text_label, voice_text); + } + if (timer) + { + lv_timer_pause(timer); + } } void ChatConversationScreen::scrollToTop() 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 38a5c959..1bf7c8cd 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 @@ -8,6 +8,7 @@ #include "app/app_facade_access.h" #include "chat/infra/mesh_protocol_utils.h" #include "chat/infra/meshtastic/mt_radio_config.h" +#include "chat/infra/voice/vmp_wire.h" #include "chat/usecase/contact_service.h" #include "chat_presentation_adapters/chat_conversation_mapper.h" #include "platform/ui/reticulum_directory_runtime.h" @@ -35,6 +36,7 @@ #include #include #include +#include #ifndef CHAT_UI_LOG_ENABLE #define CHAT_UI_LOG_ENABLE 0 @@ -65,6 +67,11 @@ enum class ConversationScrollAnchor constexpr uint8_t kTeamChatChannelRaw = static_cast(chat::ChannelId::TEAM); constexpr chat::ChannelId kTeamChatChannel = static_cast(kTeamChatChannelRaw); +// While a local voice worker is encoding or sending, refresh the chat +// projection promptly so the durable outgoing `Sending` bubble is visible as +// soon as it exists. Idle conversations retain the lower-frequency poll. +constexpr uint32_t kVoiceProjectionBusyPollMs = 150U; +constexpr uint32_t kVoiceProjectionIdlePollMs = 1000U; const char* protocol_short_label(chat::MeshProtocol protocol) { @@ -410,6 +417,101 @@ void appendSnapshotConversationsToControllerList( } } +bool conversationIdForVoiceMessage(const ::ui::chat_voice::MessageSummary& summary, + chat::ConversationId* out) +{ + if (!out || summary.presentation_protocol == 0U || + summary.presentation_channel > 7U || + (summary.presentation_protocol != + static_cast(chat::MeshProtocol::Meshtastic) && + summary.presentation_protocol != + static_cast(chat::MeshProtocol::MeshCore) && + summary.presentation_protocol != + static_cast(chat::MeshProtocol::Reticulum))) + { + return false; + } + const uint32_t peer = summary.private_message + ? (summary.outgoing ? summary.target_id + : summary.sender_id) + : 0U; + if (summary.private_message && peer == 0U) + { + return false; + } + *out = chat::ConversationId( + static_cast(summary.presentation_channel), + peer, + static_cast(summary.presentation_protocol)); + return true; +} + +const char* voiceConversationPreview(const ::ui::chat_voice::MessageSummary& summary) +{ + if (!summary.outgoing) + { + return "Voice message"; + } + switch (summary.delivery) + { + case ::ui::chat_voice::DeliveryState::Sending: + return "Voice message (Sending...)"; + case ::ui::chat_voice::DeliveryState::Failed: + return "Voice message (Failed)"; + case ::ui::chat_voice::DeliveryState::Sent: + return "Voice message"; + case ::ui::chat_voice::DeliveryState::Received: + default: + return "Voice message"; + } +} + +void appendVoiceConversationsToControllerList( + const ::ui::chat_voice::MessageSummary* voice_messages, + std::size_t voice_count, + std::vector& out) +{ + for (std::size_t index = 0U; voice_messages && index < voice_count; ++index) + { + const auto& summary = voice_messages[index]; + chat::ConversationId voice_id; + if (!conversationIdForVoiceMessage(summary, &voice_id)) + { + continue; + } + + auto existing = out.end(); + for (auto it = out.begin(); it != out.end(); ++it) + { + if (it->id == voice_id) + { + existing = it; + break; + } + } + if (existing == out.end()) + { + chat::ConversationMeta created; + created.id = voice_id; + created.preview = voiceConversationPreview(summary); + created.last_timestamp = summary.received_at_seconds; + created.unread = (!summary.outgoing && !summary.read) ? 1 : 0; + out.push_back(std::move(created)); + continue; + } + + if (!summary.outgoing && !summary.read) + { + ++existing->unread; + } + if (summary.received_at_seconds >= existing->last_timestamp) + { + existing->preview = voiceConversationPreview(summary); + existing->last_timestamp = summary.received_at_seconds; + } + } +} + bool teamConversationMetaFromSnapshot( const ::ui::chat::ChatWorkspaceSnapshot& snapshot, chat::ConversationMeta& out) @@ -480,8 +582,48 @@ bool teamConversationMetaFromSnapshot( return overlay; } -void applySnapshotMessagesToConversation( +bool voiceMessageMatchesConversation( + const ::ui::chat_voice::MessageSummary& summary, + const chat::ConversationId& conversation) +{ + const chat::MeshProtocol protocol = + chat::infra::normalizeMeshProtocol(conversation.protocol); + if (summary.presentation_protocol != static_cast(protocol) || + summary.presentation_channel != static_cast(conversation.channel)) + { + return false; + } + if (!summary.private_message) + { + return conversation.peer == 0U; + } + if (conversation.peer == 0U) + { + return false; + } + return summary.outgoing ? summary.target_id == conversation.peer + : summary.sender_id == conversation.peer; +} + +uint32_t presentationRowTimestamp(const ::ui::chat::MessageRow& row) +{ + const char* const timestamp = row.time_label.c_str(); + if (!timestamp || timestamp[0] == '\0') + { + return 0U; + } + char* end = nullptr; + const unsigned long parsed = std::strtoul(timestamp, &end, 10); + return end != timestamp && end && *end == '\0' + ? static_cast(parsed) + : 0U; +} + +void applyConversationProjection( const ::ui::chat::ChatWorkspaceSnapshot& snapshot, + const ::ui::chat_voice::MessageSummary* voice_messages, + std::size_t voice_count, + const chat::ConversationId& voice_conversation, ChatConversationScreen& conversation, ConversationScrollAnchor scroll_anchor = ConversationScrollAnchor::Bottom) { @@ -496,22 +638,56 @@ void applySnapshotMessagesToConversation( conversation.clearMessages(); CHAT_UI_LOG("[ChatUiTrace] stage=apply_snapshot clear_done elapsed_ms=%lu\n", static_cast(lv_tick_elaps(started_ms))); - for (size_t i = 0; i < snapshot.message_count; ++i) + std::size_t text_index = 0U; + std::size_t voice_cursor = voice_count; + const auto next_voice = [&]() -> const ::ui::chat_voice::MessageSummary* { + while (voice_messages && voice_cursor > 0U) + { + const auto& candidate = voice_messages[--voice_cursor]; + if (voiceMessageMatchesConversation(candidate, voice_conversation)) + { + return &candidate; + } + } + return nullptr; + }; + const ::ui::chat_voice::MessageSummary* voice = next_voice(); + while (text_index < snapshot.message_count || voice) + { + const bool emit_voice = voice && + (text_index == snapshot.message_count || + (voice->received_at_seconds != 0U && + presentationRowTimestamp(snapshot.messages[text_index]) != 0U && + voice->received_at_seconds < + presentationRowTimestamp(snapshot.messages[text_index]))); + if (emit_voice) + { + CHAT_UI_LOG("[ChatUiTrace][VMP] stage=apply_voice local_id=%llu outgoing=%u delivery=%u duration_ms=%u\n", + static_cast(voice->local_id), + voice->outgoing ? 1U : 0U, + static_cast(voice->delivery), + static_cast(voice->duration_ms)); + conversation.addVoiceMessage(*voice); + voice = next_voice(); + continue; + } + const uint32_t item_started_ms = lv_tick_get(); - const auto& row = snapshot.messages[i]; + const auto& row = snapshot.messages[text_index]; CHAT_UI_LOG("[ChatUiTrace] stage=apply_message begin index=%u local_id=%llu protocol_id=%lu outgoing=%u delivery=%u text_len=%u\n", - static_cast(i), + static_cast(text_index), static_cast(row.ref.local_id), static_cast(row.ref.protocol_id), row.outgoing ? 1U : 0U, static_cast(row.delivery), static_cast(std::strlen(row.text.c_str()))); - conversation.addMessage(snapshot.messages[i]); + conversation.addMessage(row); CHAT_UI_LOG("[ChatUiTrace] stage=apply_message done index=%u elapsed_ms=%lu total_elapsed_ms=%lu\n", static_cast(i), static_cast(lv_tick_elaps(item_started_ms)), static_cast(lv_tick_elaps(started_ms))); + ++text_index; } CHAT_UI_LOG("[ChatUiTrace] stage=apply_snapshot messages_done elapsed_ms=%lu\n", static_cast(lv_tick_elaps(started_ms))); @@ -544,6 +720,35 @@ void applySnapshotMessagesToConversation( static_cast(lv_tick_elaps(started_ms))); } +void applySnapshotMessagesToConversation( + const ::ui::chat::ChatWorkspaceSnapshot& snapshot, + ChatConversationScreen& conversation, + ConversationScrollAnchor scroll_anchor = ConversationScrollAnchor::Bottom) +{ + applyConversationProjection(snapshot, + nullptr, + 0U, + chat::ConversationId{}, + conversation, + scroll_anchor); +} + +void applySnapshotAndVoiceMessagesToConversation( + const ::ui::chat::ChatWorkspaceSnapshot& snapshot, + const ::ui::chat_voice::MessageSummary* voice_messages, + std::size_t voice_count, + const chat::ConversationId& voice_conversation, + ChatConversationScreen& conversation, + ConversationScrollAnchor scroll_anchor = ConversationScrollAnchor::Bottom) +{ + applyConversationProjection(snapshot, + voice_messages, + voice_count, + voice_conversation, + conversation, + scroll_anchor); +} + const char* key_verification_action_failure_message(::ui::UiActionResult result) { if (result.failure == ::ui::UiActionFailure::NotReady) @@ -791,8 +996,27 @@ void UiController::init() void UiController::update() { - // Refresh UI only when an event marks the conversation list dirty. + // Voice attachments do not pass through the text-event bus. Poll their + // fixed metadata projection while the list is visible so received clips + // create/update an IM thread preview and unread count even before that + // thread is opened. + if (channel_list_ && ::ui::chat_voice::isAvailable() && + lv_tick_elaps(voice_list_last_poll_ms_) >= kVoiceProjectionIdlePollMs) + { + voice_list_last_poll_ms_ = lv_tick_get(); + const uint64_t signature = currentVoiceListSignature(); + if (signature != rendered_voice_list_signature_) + { + rendered_voice_list_signature_ = signature; + conversation_list_dirty_ = true; + CHAT_UI_LOG("[ChatUiTrace][VMP] conversation_list_refresh signature=%llu\n", + static_cast(signature)); + } + } + // Refresh UI only when an event or the typed attachment projection marks + // the conversation list dirty. refreshUnreadCounts(false); + updateVoiceComposeSession(); if (state_ == State::Conversation && conversation_ && !team_conv_active_ && !conversation_view_loaded_) { @@ -802,13 +1026,18 @@ void UiController::update() reloadConversationView(); } if (state_ == State::Conversation && conversation_ && !team_conv_active_ && - ::ui::chat_voice::isAvailable() && - lv_tick_elaps(voice_projection_last_poll_ms_) >= 1000U) + ::ui::chat_voice::isAvailable()) { - voice_projection_last_poll_ms_ = lv_tick_get(); - if (currentVoiceProjectionSignature() != rendered_voice_projection_signature_) + const uint32_t projection_poll_ms = ::ui::chat_voice::isOutboundActive() + ? kVoiceProjectionBusyPollMs + : kVoiceProjectionIdlePollMs; + if (lv_tick_elaps(voice_projection_last_poll_ms_) >= projection_poll_ms) { - reloadConversationView(); + voice_projection_last_poll_ms_ = lv_tick_get(); + if (currentVoiceProjectionSignature() != rendered_voice_projection_signature_) + { + reloadConversationView(); + } } } const auto receive = ::platform::ui::reticulum_receive::snapshot(); @@ -1023,6 +1252,13 @@ void UiController::switchToChannelList() } service_.setModelEnabled(true); + // The attachment projection has no text-event-bus notification. Force a + // fixed-summary merge on return so clips received while a thread was open + // are visible in the list immediately, not after its one-second poll. + if (::ui::chat_voice::isAvailable()) + { + conversation_list_dirty_ = true; + } refreshUnreadCounts(false); } @@ -1189,7 +1425,6 @@ void UiController::switchToConversation(chat::ConversationId conv) static_cast(lv_tick_elaps(started_ms))); if (snapshot_loaded) { - applySnapshotMessagesToConversation(chat_snapshot_buffer_, *conversation_); appendVoiceMessagesToConversation(); } conversation_view_loaded_ = snapshot_loaded; @@ -1204,6 +1439,23 @@ void UiController::switchToConversation(chat::ConversationId conv) { conversation_list_dirty_ = true; } + const chat::MeshProtocol voice_protocol = + chat::infra::normalizeMeshProtocol(conv.protocol); + const bool voice_read = ::ui::chat_voice::isAvailable() && + ::ui::chat_voice::markConversationRead( + static_cast(voice_protocol), + static_cast(conv.channel), + conv.peer, + conv.peer == 0U); + if (voice_read) + { + conversation_list_dirty_ = true; + } + CHAT_UI_LOG("[ChatUiTrace][VMP] conversation_mark_read protocol=%u channel=%u peer=%08lX changed=%u\n", + static_cast(voice_protocol), + static_cast(conv.channel), + static_cast(conv.peer), + voice_read ? 1U : 0U); CHAT_UI_LOG("[ChatUiTrace] stage=switch_conversation end elapsed_ms=%lu\n", static_cast(lv_tick_elaps(started_ms))); } @@ -1333,7 +1585,7 @@ void UiController::switchToCompose(chat::ConversationId conv) std::string header = "[" + std::string(protocol_short_label(conv.protocol)) + "] " + title; compose_->setHeaderText(header.c_str(), nullptr); #if !defined(ARDUINO_T_WATCH_S3) - compose_->setVoiceButton("Voice", ::ui::chat_voice::canRecordAndSend()); + compose_->setVoiceButton("Hold to talk", ::ui::chat_voice::canRecordAndSend()); #else compose_->setPositionButton(nullptr, false); #endif @@ -1671,6 +1923,14 @@ void UiController::syncConversationListFromStore() appendSnapshotConversationsToControllerList(chat_snapshot_buffer_, next_conversations); } + const std::size_t voice_count = ::ui::chat_voice::isAvailable() + ? ::ui::chat_voice::listMessages( + voice_projection_buffer_, + kVoiceProjectionCapacity) + : 0U; + appendVoiceConversationsToControllerList(voice_projection_buffer_, + voice_count, + next_conversations); normalizeConversationNames(next_conversations); chat::ConversationMeta team_conv; @@ -1833,32 +2093,31 @@ void UiController::reloadConversationView() { return; } - applySnapshotMessagesToConversation(chat_snapshot_buffer_, *conversation_); appendVoiceMessagesToConversation(); } void UiController::appendVoiceMessagesToConversation() { - if (!conversation_ || team_conv_active_ || !::ui::chat_voice::isAvailable()) + if (!conversation_ || team_conv_active_) { return; } - const std::size_t count = ::ui::chat_voice::listReceivedMessages( - voice_projection_buffer_, kVoiceProjectionCapacity); - for (std::size_t index = count; index > 0U; --index) - { - const auto& summary = voice_projection_buffer_[index - 1U]; - const bool matches_current = summary.private_message - ? current_conv_.peer != 0U && - summary.sender_id == current_conv_.peer - : current_conv_.peer == 0U; - if (matches_current) - { - conversation_->addVoiceMessage(summary); - } - } + + const std::size_t count = ::ui::chat_voice::isAvailable() + ? ::ui::chat_voice::listMessages( + voice_projection_buffer_, + kVoiceProjectionCapacity) + : 0U; + // Text and typed attachment messages share the one conversation timeline. + // The merge reads the fixed text snapshot and the fixed eight-entry VMP + // metadata projection directly; no mixed vector is allocated on the UI + // task stack or heap. + applySnapshotAndVoiceMessagesToConversation(chat_snapshot_buffer_, + voice_projection_buffer_, + count, + current_conv_, + *conversation_); rendered_voice_projection_signature_ = currentVoiceProjectionSignature(); - conversation_->scrollToBottom(); } uint64_t UiController::currentVoiceProjectionSignature() @@ -1867,25 +2126,94 @@ uint64_t UiController::currentVoiceProjectionSignature() { return 0U; } - const std::size_t count = ::ui::chat_voice::listReceivedMessages( + const std::size_t count = ::ui::chat_voice::listMessages( voice_projection_buffer_, kVoiceProjectionCapacity); uint64_t signature = static_cast(count); for (std::size_t index = 0U; index < count; ++index) { const auto& summary = voice_projection_buffer_[index]; - const bool matches_current = summary.private_message - ? current_conv_.peer != 0U && - summary.sender_id == current_conv_.peer - : current_conv_.peer == 0U; - if (matches_current) + if (voiceMessageMatchesConversation(summary, current_conv_)) { - signature ^= summary.local_id + 0x9E3779B97F4A7C15ULL + + const uint64_t state = (static_cast(summary.delivery) << 56U) | + (summary.outgoing ? (uint64_t{1} << 55U) : 0U) | + summary.duration_ms; + signature ^= summary.local_id + state + 0x9E3779B97F4A7C15ULL + (signature << 6U) + (signature >> 2U); } } return signature; } +uint64_t UiController::currentVoiceListSignature() +{ + if (!::ui::chat_voice::isAvailable()) + { + return 0U; + } + const std::size_t count = ::ui::chat_voice::listMessages( + voice_projection_buffer_, kVoiceProjectionCapacity); + uint64_t signature = static_cast(count); + for (std::size_t index = 0U; index < count; ++index) + { + const auto& summary = voice_projection_buffer_[index]; + const uint64_t state = + (static_cast(summary.delivery) << 56U) | + (summary.outgoing ? (uint64_t{1} << 55U) : 0U) | + (summary.read ? (uint64_t{1} << 54U) : 0U) | + (static_cast(summary.presentation_protocol) << 46U) | + (static_cast(summary.presentation_channel) << 38U) | + summary.received_at_seconds; + signature ^= summary.local_id + state + 0x9E3779B97F4A7C15ULL + + (signature << 6U) + (signature >> 2U); + } + return signature; +} + +void UiController::updateVoiceComposeSession() +{ +#if !defined(ARDUINO_T_WATCH_S3) + if (!voice_hold_active_ || state_ != State::Compose || !compose_) + { + return; + } + + const uint32_t elapsed_ms = lv_tick_elaps(voice_hold_started_ms_); + if (elapsed_ms >= 5000U) + { + (void)::ui::chat_voice::requestStopRecording(); + voice_hold_active_ = false; + // The worker creates the durable outgoing record asynchronously. Go + // back to the timeline now, rather than making the user wait for RF, + // MQTT, or LXMF completion; the busy projection poll will show its + // Sending bubble as soon as that local commit finishes. + voice_projection_last_poll_ms_ = lv_tick_get() - kVoiceProjectionBusyPollMs; + CHAT_UI_LOG("[ChatUiTrace][VMP] voice capture cap reached; return to timeline\n"); + switchToConversation(current_conv_); + return; + } + else if (lv_tick_elaps(voice_hold_last_render_ms_) >= 250U) + { + voice_hold_last_render_ms_ = lv_tick_get(); + const uint32_t tenths = (elapsed_ms + 99U) / 100U; + char label[16] = {}; + char status[16] = {}; + std::snprintf(label, + sizeof(label), + "Release %lu.%lus", + static_cast(tenths / 10U), + static_cast(tenths % 10U)); + std::snprintf(status, + sizeof(status), + "REC %lu.%lus/5", + static_cast(tenths / 10U), + static_cast(tenths % 10U)); + compose_->setVoiceButton(label, true); + compose_->setHeaderText(nullptr, status); + } + +#endif +} + bool UiController::isTeamConversation(const chat::ConversationId& conv) const { return isTeamConversationId(conv); @@ -2238,7 +2566,6 @@ void UiController::handleConversationAction(ChatConversationScreen::ActionIntent ::ui::feedback::show_notice("No more messages", 1400); return; } - applySnapshotMessagesToConversation(chat_snapshot_buffer_, *conversation_); appendVoiceMessagesToConversation(); return; } @@ -2304,10 +2631,19 @@ void UiController::handleConversationAction(ChatConversationScreen::ActionIntent ::ui::feedback::show_notice("Latest messages", 1400); return; } - applySnapshotMessagesToConversation(chat_snapshot_buffer_, - *conversation_, - ConversationScrollAnchor::Top); - appendVoiceMessagesToConversation(); + const std::size_t voice_count = ::ui::chat_voice::isAvailable() + ? ::ui::chat_voice::listMessages( + voice_projection_buffer_, + kVoiceProjectionCapacity) + : 0U; + applySnapshotAndVoiceMessagesToConversation( + chat_snapshot_buffer_, + voice_projection_buffer_, + voice_count, + current_conv_, + *conversation_, + ConversationScrollAnchor::Top); + rendered_voice_projection_signature_ = currentVoiceProjectionSignature(); return; } #endif @@ -2389,6 +2725,9 @@ void UiController::handleComposeAction(ChatComposeScreen::ActionIntent intent) } if (intent == ChatComposeScreen::ActionIntent::Cancel) { + (void)::ui::chat_voice::requestStopRecording(); + voice_hold_active_ = false; + voice_projection_last_poll_ms_ = lv_tick_get() - kVoiceProjectionBusyPollMs; switchToConversation(current_conv_); return; } @@ -2417,18 +2756,48 @@ void UiController::handleComposeAction(ChatComposeScreen::ActionIntent intent) } #if !defined(ARDUINO_T_WATCH_S3) - if (intent == ChatComposeScreen::ActionIntent::Voice) + if (intent == ChatComposeScreen::ActionIntent::VoiceStop) { - switch (::ui::chat_voice::requestRecordAndSend(current_conv_.peer)) + if (!voice_hold_active_) + { + CHAT_UI_LOG("[ChatUiTrace][VMP] voice release ignored reason=no_active_hold\n"); + return; + } + const bool stop_requested = ::ui::chat_voice::requestStopRecording(); + voice_hold_active_ = false; + voice_projection_last_poll_ms_ = lv_tick_get() - kVoiceProjectionBusyPollMs; + CHAT_UI_LOG("[ChatUiTrace][VMP] voice release stop_requested=%u; return to timeline\n", + stop_requested ? 1U : 0U); + switchToConversation(current_conv_); + return; + } + + if (intent == ChatComposeScreen::ActionIntent::VoiceStart) + { + const ::ui::chat_voice::SendRequest request{ + current_conv_.peer == 0U ? chat::voice::vmp::kBroadcastTargetId + : current_conv_.peer, + static_cast(chat::infra::normalizeMeshProtocol( + current_conv_.protocol)), + static_cast(current_conv_.channel)}; + switch (::ui::chat_voice::requestRecordAndSend(request)) { case ::ui::chat_voice::StartResult::Queued: - ::ui::feedback::show_notice("Recording voice (max 5s)", 2200); + // Press-to-talk is visible in-place. A notification is neither a + // timer nor a reliable release affordance, especially on Pager. + voice_hold_active_ = true; + voice_hold_started_ms_ = lv_tick_get(); + voice_hold_last_render_ms_ = voice_hold_started_ms_; + compose_->setVoiceButton("Release 0.0s", true); + compose_->setHeaderText(nullptr, "REC 0.0s/5"); + CHAT_UI_LOG("[ChatUiTrace][VMP] voice press queued target=%08lX\n", + static_cast(current_conv_.peer)); return; case ::ui::chat_voice::StartResult::PrivateContactUnverified: - ::ui::feedback::show_notice("Verify contact before private voice", 2400); + ::ui::feedback::show_notice("Verify contact for private voice", 2400); return; case ::ui::chat_voice::StartResult::Busy: - ::ui::feedback::show_notice("Voice session already active", 1800); + ::ui::feedback::show_notice("Voice busy; wait", 1800); return; case ::ui::chat_voice::StartResult::Unsupported: default: diff --git a/modules/ui_shared/tests/test_chat_voice_runtime.cpp b/modules/ui_shared/tests/test_chat_voice_runtime.cpp index 1bbc3f82..dbc7885c 100644 --- a/modules/ui_shared/tests/test_chat_voice_runtime.cpp +++ b/modules/ui_shared/tests/test_chat_voice_runtime.cpp @@ -20,15 +20,29 @@ class FakeVoiceRuntime final : public ui::chat_voice::IVoiceMessageRuntime return send_available; } - ui::chat_voice::StartResult requestRecordAndSend(uint32_t target_id) override + ui::chat_voice::StartResult requestRecordAndSend( + const ui::chat_voice::SendRequest& request) override { - last_target = target_id; + last_target = request.target_id; + last_protocol = request.presentation_protocol; + last_channel = request.presentation_channel; ++request_count; return result; } - std::size_t listReceivedMessages(ui::chat_voice::MessageSummary* out_messages, - std::size_t capacity) const override + bool requestStopRecording() override + { + ++stop_request_count; + return stop_result; + } + + bool isOutboundActive() const override + { + return outbound_active; + } + + std::size_t listMessages(ui::chat_voice::MessageSummary* out_messages, + std::size_t capacity) const override { if (!out_messages || capacity == 0U) { @@ -38,6 +52,19 @@ class FakeVoiceRuntime final : public ui::chat_voice::IVoiceMessageRuntime return 1U; } + bool markConversationRead(uint8_t protocol, + uint8_t channel, + uint32_t peer_id, + bool broadcast) override + { + read_protocol = protocol; + read_channel = channel; + read_peer = peer_id; + read_broadcast = broadcast; + ++read_request_count; + return read_result; + } + bool requestPlayback(uint64_t local_id) override { played_id = local_id; @@ -48,9 +75,32 @@ class FakeVoiceRuntime final : public ui::chat_voice::IVoiceMessageRuntime bool send_available = true; uint32_t last_target = 0U; uint32_t request_count = 0U; - mutable ui::chat_voice::MessageSummary summary{0xF00DU, 9U, 0U, 123U, false, true}; + uint8_t last_protocol = 0U; + uint8_t last_channel = 0U; + uint8_t read_protocol = 0U; + uint8_t read_channel = 0U; + uint32_t read_peer = 0U; + bool read_broadcast = false; + uint32_t read_request_count = 0U; + bool read_result = true; + mutable ui::chat_voice::MessageSummary summary{ + 0xF00DU, + 9U, + 0U, + 123U, + 2000U, + false, + true, + true, + ui::chat_voice::DeliveryState::Sending, + 1U, + 0U, + false}; uint64_t played_id = 0U; bool playback_result = true; + uint32_t stop_request_count = 0U; + bool stop_result = true; + bool outbound_active = false; ui::chat_voice::StartResult result = ui::chat_voice::StartResult::Queued; }; @@ -59,7 +109,7 @@ void test_unbound_runtime_is_safe() ui::chat_voice::setRuntime(nullptr); assert(!ui::chat_voice::isAvailable()); assert(!ui::chat_voice::canRecordAndSend()); - assert(ui::chat_voice::requestRecordAndSend(1U) == + assert(ui::chat_voice::requestRecordAndSend({1U, 1U, 0U}) == ui::chat_voice::StartResult::Unsupported); } @@ -70,25 +120,46 @@ void test_runtime_forwards_without_transport_coupling() assert(ui::chat_voice::isAvailable()); assert(ui::chat_voice::canRecordAndSend()); - assert(ui::chat_voice::requestRecordAndSend(0x11223344U) == + assert(ui::chat_voice::requestRecordAndSend({0x11223344U, 1U, 1U}) == ui::chat_voice::StartResult::Queued); assert(runtime.request_count == 1U); assert(runtime.last_target == 0x11223344U); + assert(runtime.last_protocol == 1U); + assert(runtime.last_channel == 1U); + assert(ui::chat_voice::requestStopRecording()); + assert(runtime.stop_request_count == 1U); + runtime.outbound_active = true; + assert(ui::chat_voice::isOutboundActive()); runtime.available = false; runtime.send_available = false; runtime.result = ui::chat_voice::StartResult::PrivateContactUnverified; assert(!ui::chat_voice::isAvailable()); assert(!ui::chat_voice::canRecordAndSend()); - assert(ui::chat_voice::requestRecordAndSend(0x55667788U) == + assert(ui::chat_voice::requestRecordAndSend({0x55667788U, 4U, 0U}) == ui::chat_voice::StartResult::PrivateContactUnverified); assert(runtime.request_count == 2U); assert(runtime.last_target == 0x55667788U); + assert(runtime.last_protocol == 4U); ui::chat_voice::MessageSummary summaries[1] = {}; - assert(ui::chat_voice::listReceivedMessages(summaries, 1U) == 1U); + assert(ui::chat_voice::listMessages(summaries, 1U) == 1U); assert(summaries[0].local_id == runtime.summary.local_id); assert(summaries[0].source_unverified); + assert(summaries[0].outgoing); + assert(summaries[0].delivery == ui::chat_voice::DeliveryState::Sending); + assert(!summaries[0].read); + assert(ui::chat_voice::markConversationRead(1U, 0U, 9U, false)); + assert(runtime.read_request_count == 1U); + assert(runtime.read_protocol == 1U); + assert(runtime.read_channel == 0U); + assert(runtime.read_peer == 9U); + assert(!runtime.read_broadcast); + // The legacy name is intentionally only a source-compatible alias. It + // must expose the exact same mixed-direction timeline rather than + // restoring the old receive-only meaning. + assert(ui::chat_voice::listReceivedMessages(summaries, 1U) == 1U); + assert(summaries[0].outgoing); assert(ui::chat_voice::requestPlayback(summaries[0].local_id)); assert(runtime.played_id == summaries[0].local_id); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h index a83a2f8c..7088fde4 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/voice/vmp_pager_session.h @@ -143,6 +143,12 @@ void setLxmfEnvelopeSender(LxmfEnvelopeSender sender, void* context); */ void setLxmfCarrierEnabled(bool enabled); +/** + * Binds direct-RF ingress and legacy attachment hydration to the active local + * chat presentation protocol. This never changes VMP's wire/bearer rules. + */ +void setPresentationProtocol(uint8_t protocol); + /** * @brief Accepts one subscribed VMP MQTT envelope into local VMP storage. * @@ -171,6 +177,25 @@ void discardMqttPublication(); * verified-contact secret. Recording itself is performed by a VMP worker, so * this call never blocks a UI task for five seconds. */ -StartSendResult requestRecordAndSend(uint32_t target_id); +StartSendResult requestRecordAndSend(uint32_t target_id, + uint8_t presentation_protocol, + uint8_t presentation_channel); + +/** Marks incoming local VMP attachments in one displayed thread as read. */ +bool markConversationRead(uint8_t presentation_protocol, + uint8_t presentation_channel, + uint32_t peer_id, + bool broadcast); + +/** + * @brief Stops an in-progress press-to-talk capture after its current frame. + * + * This is deliberately capture-only: once a valid clip has been encoded, a + * later key release must not cancel the selected RF, MQTT, or LXMF delivery. + */ +bool requestStopRecording(); + +/** @brief True from press-to-talk acceptance until its carrier attempt ends. */ +bool isOutboundActive(); } // namespace platform::esp::arduino_common::voice::vmp_session diff --git a/platform/esp/arduino_common/src/app_context.cpp b/platform/esp/arduino_common/src/app_context.cpp index b8f940a4..68c33bd8 100644 --- a/platform/esp/arduino_common/src/app_context.cpp +++ b/platform/esp/arduino_common/src/app_context.cpp @@ -67,10 +67,13 @@ class PagerVoiceMessageRuntime final : public ::ui::chat_voice::IVoiceMessageRun return ::platform::esp::arduino_common::voice::vmp_session::canRecordAndSend(); } - ::ui::chat_voice::StartResult requestRecordAndSend(uint32_t target_id) override + ::ui::chat_voice::StartResult requestRecordAndSend( + const ::ui::chat_voice::SendRequest& request) override { switch (::platform::esp::arduino_common::voice::vmp_session::requestRecordAndSend( - target_id)) + request.target_id, + request.presentation_protocol, + request.presentation_channel)) { case ::platform::esp::arduino_common::voice::vmp_session::StartSendResult::Queued: return ::ui::chat_voice::StartResult::Queued; @@ -84,7 +87,19 @@ class PagerVoiceMessageRuntime final : public ::ui::chat_voice::IVoiceMessageRun } } - std::size_t listReceivedMessages( + bool requestStopRecording() override + { + return ::platform::esp::arduino_common::voice::vmp_session:: + requestStopRecording(); + } + + bool isOutboundActive() const override + { + return ::platform::esp::arduino_common::voice::vmp_session:: + isOutboundActive(); + } + + std::size_t listMessages( ::ui::chat_voice::MessageSummary* out_messages, std::size_t capacity) const override { @@ -106,13 +121,38 @@ class PagerVoiceMessageRuntime final : public ::ui::chat_voice::IVoiceMessageRun out_messages[index].sender_id = metadata.sender_id; out_messages[index].target_id = metadata.target_id; out_messages[index].received_at_seconds = metadata.received_at_seconds; + // Codec2-1300 frames are fixed at 40 ms and seven encoded bytes. + // The UI receives duration only, never a pointer to media bytes. + out_messages[index].duration_ms = static_cast( + (metadata.encoded_media_len / 7U) * 40U); out_messages[index].private_message = metadata.mode == chat::voice::vmp::DeliveryMode::Private; - out_messages[index].source_unverified = metadata.source_unverified; + out_messages[index].source_unverified = + chat::voice::vmp::voiceMessageSourceUnverified(metadata); + out_messages[index].outgoing = + chat::voice::vmp::voiceMessageOutgoing(metadata); + out_messages[index].delivery = + static_cast<::ui::chat_voice::DeliveryState>(metadata.delivery); + out_messages[index].presentation_protocol = + static_cast(metadata.presentation_protocol); + out_messages[index].presentation_channel = metadata.presentation_channel; + out_messages[index].read = chat::voice::vmp::voiceMessageRead(metadata); } return count; } + bool markConversationRead(uint8_t presentation_protocol, + uint8_t presentation_channel, + uint32_t peer_id, + bool broadcast) override + { + return ::platform::esp::arduino_common::voice::vmp_session:: + markConversationRead(presentation_protocol, + presentation_channel, + peer_id, + broadcast); + } + bool requestPlayback(uint64_t local_id) override { return ::platform::esp::arduino_common::voice::vmp_session::requestPlayback( @@ -341,6 +381,9 @@ void AppContext::initChatRuntime(bool use_mock_adapter) } else { + ::platform::esp::arduino_common::voice::vmp_session::setPresentationProtocol( + static_cast(chat::infra::normalizeMeshProtocol( + config_.mesh_protocol))); ::platform::esp::arduino_common::voice::vmp_session::setVerifiedContactSecretDeriver( &deriveVmpVerifiedContactSecret, this); #if defined(ARDUINO_LILYGO_LORA_LR1121) @@ -669,6 +712,10 @@ void AppContext::applyMeshConfig() chat::infra::isReticulumMeshProtocol( chat::infra::normalizeMeshProtocol(config_.mesh_protocol))); #endif +#if defined(ARDUINO_T_LORA_PAGER) + ::platform::esp::arduino_common::voice::vmp_session::setPresentationProtocol( + static_cast(chat::infra::normalizeMeshProtocol(config_.mesh_protocol))); +#endif } void AppContext::applyUserInfo() @@ -871,6 +918,8 @@ bool AppContext::switchMeshProtocol(chat::MeshProtocol protocol, bool persist) } #if defined(ARDUINO_T_LORA_PAGER) ::platform::esp::arduino_common::voice::vmp_session::invalidateContactSecretCache(); + ::platform::esp::arduino_common::voice::vmp_session::setPresentationProtocol( + static_cast(normalized)); #if defined(ARDUINO_LILYGO_LORA_LR1121) ::platform::esp::arduino_common::voice::vmp_session::setLxmfCarrierEnabled( chat::infra::isReticulumMeshProtocol(normalized)); diff --git a/platform/esp/arduino_common/src/chat/infra/store/message_attachment_store.cpp b/platform/esp/arduino_common/src/chat/infra/store/message_attachment_store.cpp index 6eee0b89..29412409 100644 --- a/platform/esp/arduino_common/src/chat/infra/store/message_attachment_store.cpp +++ b/platform/esp/arduino_common/src/chat/infra/store/message_attachment_store.cpp @@ -27,9 +27,11 @@ constexpr const char* kVoiceSnapshotBackupPath = "/data/v2/attachments/voice/inbox.v1.bak"; constexpr uint32_t kSnapshotMagic = 0x54414D56UL; // "VMAT", little-endian. constexpr uint32_t kSnapshotFooterMagic = 0x454E4456UL; // "VDNE". -constexpr uint16_t kSnapshotSchemaVersion = 1U; +constexpr uint16_t kSnapshotSchemaVersion = 2U; constexpr uint8_t kVoiceCompleteFlag = 0x01U; constexpr uint8_t kVoiceUnverifiedFlag = 0x02U; +constexpr uint8_t kVoiceOutgoingFlag = 0x04U; +constexpr uint8_t kVoiceReadFlag = 0x08U; struct SnapshotHeader { @@ -121,22 +123,36 @@ VoiceRecordHeader makeRecordHeader(const vmp::VoiceMessageMetadata& metadata, record.encoded_media_len = metadata.encoded_media_len; record.codec = static_cast(metadata.codec); record.mode = static_cast(metadata.mode); - record.flags = (metadata.complete ? kVoiceCompleteFlag : 0U) | - (metadata.source_unverified ? kVoiceUnverifiedFlag : 0U); + record.flags = (vmp::voiceMessageComplete(metadata) ? kVoiceCompleteFlag : 0U) | + (vmp::voiceMessageSourceUnverified(metadata) + ? kVoiceUnverifiedFlag + : 0U) | + (vmp::voiceMessageOutgoing(metadata) ? kVoiceOutgoingFlag : 0U) | + (vmp::voiceMessageRead(metadata) ? kVoiceReadFlag : 0U); + // The retained V1 record ABI had three reserved bytes. V2 uses them for + // local-only delivery and conversation binding without changing the + // fixed 48-byte record size or placing a mesh protocol inside VMP media. + record.reserved[0] = static_cast(metadata.delivery); + record.reserved[1] = static_cast(metadata.presentation_protocol); + record.reserved[2] = metadata.presentation_channel; record.media_crc32 = crc32(media, metadata.encoded_media_len); return record; } bool decodeRecordMetadata(const VoiceRecordHeader& record, + uint16_t schema, vmp::VoiceMessageMetadata* metadata) { if (!metadata || record.local_id == 0U || record.encoded_media_len == 0U || record.encoded_media_len > vmp::kMaxEncodedMediaSize || (record.flags & kVoiceCompleteFlag) == 0U || + (record.flags & ~(kVoiceCompleteFlag | kVoiceUnverifiedFlag | + kVoiceOutgoingFlag | kVoiceReadFlag)) != 0U || record.codec != static_cast(vmp::Codec::Codec2_1300) || (record.mode != static_cast(vmp::DeliveryMode::Private) && - record.mode != static_cast(vmp::DeliveryMode::Broadcast))) + record.mode != static_cast(vmp::DeliveryMode::Broadcast)) || + record.reserved[0] > static_cast(vmp::VoiceDeliveryState::Failed)) { return false; } @@ -150,8 +166,31 @@ bool decodeRecordMetadata(const VoiceRecordHeader& record, metadata->encoded_media_len = record.encoded_media_len; metadata->codec = static_cast(record.codec); metadata->mode = static_cast(record.mode); - metadata->source_unverified = (record.flags & kVoiceUnverifiedFlag) != 0U; - metadata->complete = true; + metadata->flags = 0U; + vmp::setVoiceMessageFlag(metadata, + vmp::VoiceMessageFlagSourceUnverified, + (record.flags & kVoiceUnverifiedFlag) != 0U); + vmp::setVoiceMessageFlag(metadata, vmp::VoiceMessageFlagComplete, true); + vmp::setVoiceMessageFlag(metadata, + vmp::VoiceMessageFlagOutgoing, + (record.flags & kVoiceOutgoingFlag) != 0U); + // A V1 snapshot had no read bit. Existing received clips predate the + // unread model, so migration deliberately treats them as already seen. + vmp::setVoiceMessageFlag(metadata, + vmp::VoiceMessageFlagRead, + schema == 1U || (record.flags & kVoiceReadFlag) != 0U); + metadata->delivery = static_cast(record.reserved[0]); + metadata->presentation_protocol = + schema == 1U ? vmp::VoicePresentationProtocol::Unknown + : static_cast(record.reserved[1]); + metadata->presentation_channel = + schema == 1U ? vmp::kVoicePresentationPrimaryChannel : record.reserved[2]; + if (schema != 1U && + !vmp::isValidVoicePresentationBinding(metadata->presentation_protocol, + metadata->presentation_channel)) + { + return false; + } return true; } @@ -211,7 +250,7 @@ VoiceInboxLoadResult restoreVoiceInboxSnapshot( SnapshotHeader header{}; if (!readExact(&file, &header, sizeof(header)) || header.magic != kSnapshotMagic || - header.schema != kSnapshotSchemaVersion || + (header.schema != 1U && header.schema != kSnapshotSchemaVersion) || header.kind != static_cast(AttachmentKind::Voice) || header.record_count > vmp::kVoiceInboxCapacity) { @@ -226,10 +265,17 @@ VoiceInboxLoadResult restoreVoiceInboxSnapshot( VoiceRecordHeader record{}; vmp::VoiceMessageMetadata metadata{}; if (!readExact(&file, &record, sizeof(record)) || - !decodeRecordMetadata(record, &metadata) || + !decodeRecordMetadata(record, header.schema, &metadata) || !readExact(&file, media_scratch, metadata.encoded_media_len) || crc32(media_scratch, metadata.encoded_media_len) != record.media_crc32 || - !inbox->restore(metadata, media_scratch, metadata.encoded_media_len)) + // Snapshot records are written newest-first by listMetadata(). + // Restore their descending insertion order explicitly so a + // power cycle keeps chat history and replacement policy ordered + // exactly as before persistence, including legacy V1 snapshots. + !inbox->restore(metadata, + media_scratch, + metadata.encoded_media_len, + static_cast(header.record_count - index))) { file.close(); inbox->clear(); @@ -293,13 +339,14 @@ bool persistVoiceInbox(const vmp::VoiceMessageInbox& inbox, header.record_count = static_cast(count); bool wrote = writeExact(&file, &header, sizeof(header)); uint32_t payload_crc = 0xFFFFFFFFUL; - // `listMetadata` is newest-first; serializing oldest-first lets inbox - // restore rebuild the original presentation order without a second RAM - // array or storing its private insertion sequence on disk. - for (std::size_t remaining = count; wrote && remaining > 0U; --remaining) + // `listMetadata` is newest-first and the V1 loader restores records with + // a descending insertion sequence. Keep the on-disk ordering newest to + // oldest so the durable timeline and bounded oldest-entry replacement + // policy survive a reboot without a second metadata array. + for (std::size_t index = 0U; wrote && index < count; ++index) { const vmp::VoiceMessageMetadata& metadata = - metadata_scratch[remaining - 1U]; + metadata_scratch[index]; vmp::VoiceMessageView view{}; if (!inbox.get(metadata.local_id, &view) || !view.encoded_media || diff --git a/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp index 86db516f..eee6a291 100644 --- a/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp +++ b/platform/esp/arduino_common/src/voice/vmp_pager_audio.cpp @@ -10,6 +10,7 @@ #if defined(ARDUINO_T_LORA_PAGER) +#include #include #include @@ -101,14 +102,19 @@ bool PagerCodec2Audio::isSupported() const CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) { clearEncodedMedia(); + const uint32_t started_ms = millis(); + Serial.printf("[VMP][AUDIO] capture begin max_ms=5000 stop_hook=%u\n", + stop_requested ? 1U : 0U); TLoRaPagerBoard* const board = pagerBoard(); if (!board || !acquireFrameScratch()) { + Serial.printf("[VMP][AUDIO] capture rejected reason=unsupported\n"); return CaptureResult::Unsupported; } if (!beginAudio(board, false)) { releaseFrameScratch(); + Serial.printf("[VMP][AUDIO] capture rejected reason=audio_busy\n"); return CaptureResult::AudioBusy; } @@ -124,16 +130,22 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) } board->closeAudioSession(kOwner); releaseFrameScratch(); + Serial.printf("[VMP][AUDIO] capture rejected reason=codec_failure\n"); return CaptureResult::CodecFailure; } CaptureResult result = CaptureResult::Complete; + bool stopped_by_release = false; for (std::size_t frame = 0; frame < kCodec2FramesPerMessage; ++frame) { if (stop_requested && *stop_requested) { + stopped_by_release = true; result = encoded_media_size_ == 0U ? CaptureResult::Cancelled : CaptureResult::Complete; + Serial.printf("[VMP][AUDIO] capture stop_requested frames=%u bytes=%u\n", + static_cast(frame), + static_cast(encoded_media_size_)); break; } if (!readCaptureFrame()) @@ -146,15 +158,29 @@ CaptureResult PagerCodec2Audio::capture(const volatile bool* stop_requested) encoded_media_ + encoded_media_size_, frame_scratch_->mono); encoded_media_size_ += kCodec2BytesPerFrame; + if ((frame + 1U) % 25U == 0U) + { + Serial.printf("[VMP][AUDIO] capture progress elapsed_ms=%lu frames=%u bytes=%u\n", + static_cast(millis() - started_ms), + static_cast(frame + 1U), + static_cast(encoded_media_size_)); + } } codec2_destroy(encoder); board->closeAudioSession(kOwner); releaseFrameScratch(); + const std::size_t encoded_size = encoded_media_size_; if (result != CaptureResult::Complete) { clearEncodedMedia(); } + Serial.printf("[VMP][AUDIO] capture end result=%u reason=%s elapsed_ms=%lu frames=%u bytes=%u\n", + static_cast(result), + stopped_by_release ? "release" : "limit_or_error", + static_cast(millis() - started_ms), + static_cast(encoded_size / kCodec2BytesPerFrame), + static_cast(encoded_size)); return result; } diff --git a/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp index 56d227d9..8c66d9fb 100644 --- a/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp +++ b/platform/esp/arduino_common/src/voice/vmp_pager_session.cpp @@ -16,6 +16,7 @@ #include "chat/infra/voice/vmp_mqtt_transport.h" #include "chat/infra/voice/vmp_receive_block.h" #include "platform/esp/arduino_common/chat/infra/store/message_attachment_store.h" +#include "sys/clock.h" #include @@ -56,6 +57,14 @@ bool deadlineExpired(uint32_t deadline) return static_cast(millis() - deadline) >= 0; } +uint32_t voiceTimestampSeconds() +{ + // Use the same real-time source as text-message persistence. A missing + // clock deliberately remains zero instead of fabricating an uptime value + // that would sort a voice attachment ahead of dated text history. + return ::sys::epoch_seconds_now(); +} + bool profileFor(const vmp::ControlFrame& control, radio::PhyProfile* out_profile) { if (!out_profile || control.phy_profile_id != kDefaultPhyProfile || @@ -142,6 +151,9 @@ class PagerReceiveSession final control::setEnvelopeHandler(&PagerReceiveSession::controlEnvelopeReceived, this); } initialized_ = true; + Serial.printf("[VMP] init carrier=%s durable_inbox=%u\n", + direct_rf_voice_supported_ ? "lr1121_rf" : "sx1262_mqtt_only", + requires_durable_attachment_store_ ? 1U : 0U); return true; } @@ -204,11 +216,30 @@ class PagerReceiveSession final VoiceInboxLoadResult::Empty) { inbox_ready_ = true; - Serial.printf("[VMP] attachment inbox restore=%s\n", + const bool rebound_legacy = + presentation_protocol_ != vmp::VoicePresentationProtocol::Unknown && + media_->inbox.bindUnassignedMessages( + presentation_protocol_, + vmp::kVoicePresentationPrimaryChannel, + true); + if (result == ::platform::esp::arduino_common::chat_attachment:: + VoiceInboxLoadResult::Restored && + !::platform::esp::arduino_common::chat_attachment::persistVoiceInbox( + media_->inbox, + media_->persistence_metadata, + vmp::kVoiceInboxCapacity)) + { + // Restore converts an interrupted outgoing `Sending` state to + // local `Failed`; retain the safe RAM state even if the first + // healing snapshot cannot be committed yet. + Serial.printf("[VMP] attachment inbox status-heal deferred\n"); + } + Serial.printf("[VMP] attachment inbox restore=%s legacy_rebound=%u\n", result == ::platform::esp::arduino_common::chat_attachment:: VoiceInboxLoadResult::Restored ? "restored" - : "empty"); + : "empty", + rebound_legacy ? 1U : 0U); } else { @@ -290,6 +321,8 @@ class PagerReceiveSession final unlockState(); if (unavailable) { + Serial.printf("[VMP][PLAY] rejected local_id=%llu reason=voice_busy\n", + static_cast(local_id)); return false; } @@ -300,6 +333,8 @@ class PagerReceiveSession final if (active_ || playback_task_) { unlockState(); + Serial.printf("[VMP][PLAY] rejected local_id=%llu reason=became_busy\n", + static_cast(local_id)); return false; } vmp::VoiceMessageView view{}; @@ -307,6 +342,8 @@ class PagerReceiveSession final view.metadata.encoded_media_len > sizeof(media_->playback_media)) { unlockState(); + Serial.printf("[VMP][PLAY] rejected local_id=%llu reason=not_found\n", + static_cast(local_id)); return false; } std::memcpy(media_->playback_media, @@ -326,9 +363,14 @@ class PagerReceiveSession final playback_local_id_ = 0U; playback_task_ = nullptr; unlockState(); + Serial.printf("[VMP][PLAY] rejected local_id=%llu reason=worker_create\n", + static_cast(local_id)); return false; } unlockState(); + Serial.printf("[VMP][PLAY] queued local_id=%llu bytes=%u\n", + static_cast(local_id), + static_cast(playback_media_len_)); return true; } @@ -407,6 +449,20 @@ class PagerReceiveSession final } } + void setPresentationProtocol(uint8_t protocol) + { + const auto presentation = + static_cast(protocol); + if (!vmp::isValidVoicePresentationBinding( + presentation, vmp::kVoicePresentationPrimaryChannel) || + !lockState()) + { + return; + } + presentation_protocol_ = presentation; + unlockState(); + } + bool acceptMqttEnvelope(const uint8_t* envelope, std::size_t envelope_len) { return acceptStoreForwardEnvelope(0U, envelope, envelope_len); @@ -477,7 +533,7 @@ class PagerReceiveSession final recovered && storeCompletedVoice(media_->mqtt_receive.control(), media_->mqtt_received_media, media_len, - millis() / 1000U); + voiceTimestampSeconds()); secureClear(media_->mqtt_received_media, sizeof(media_->mqtt_received_media)); media_->mqtt_receive.clear(); stored = stored_voice; @@ -495,27 +551,39 @@ class PagerReceiveSession final } } - StartSendResult requestRecordAndSend(uint32_t target_id) + StartSendResult requestRecordAndSend(uint32_t target_id, + uint8_t presentation_protocol, + uint8_t presentation_channel) { const bool broadcast = target_id == vmp::kBroadcastTargetId; + const auto presentation = + static_cast(presentation_protocol); if (!initialized_ || !media_ || !inbox_ready_ || !media_->audio.isSupported()) { + Serial.printf("[VMP][TX] hold begin rejected reason=unavailable\n"); return StartSendResult::Unsupported; } - if ((!broadcast && target_id == 0U) || !lockState()) + if ((!broadcast && target_id == 0U) || + !vmp::isValidVoicePresentationBinding(presentation, + presentation_channel) || + !lockState()) { + Serial.printf("[VMP][TX] hold begin rejected reason=invalid_target_or_lock\n"); return StartSendResult::Busy; } - const bool unavailable = active_ || outbound_task_ || playback_task_ || + const bool unavailable = presentation != presentation_protocol_ || active_ || + outbound_task_ || playback_task_ || (!direct_rf_voice_supported_ && !mqtt_uplink_enabled_); unlockState(); if (unavailable) { + Serial.printf("[VMP][TX] hold begin rejected reason=busy_or_no_carrier\n"); return StartSendResult::Busy; } if (!broadcast && !ensureVerifiedContactSecret(target_id)) { + Serial.printf("[VMP][TX] hold begin rejected reason=private_contact_unverified\n"); return StartSendResult::PrivateContactUnverified; } @@ -526,12 +594,21 @@ class PagerReceiveSession final if (active_ || outbound_task_ || playback_task_) { unlockState(); + Serial.printf("[VMP][TX] hold begin rejected reason=became_busy\n"); return StartSendResult::Busy; } outbound_target_id_ = target_id; outbound_is_broadcast_ = broadcast; + outbound_presentation_protocol_ = presentation; + outbound_presentation_channel_ = presentation_channel; + record_stop_requested_ = false; active_ = true; + outbound_active_ = true; unlockState(); + Serial.printf("[VMP][TX] hold begin accepted mode=%s target=%08lX carrier=%s\n", + broadcast ? "broadcast" : "private", + static_cast(target_id), + direct_rf_voice_supported_ ? "lr1121_rf" : "sx1262_mqtt_only"); if (xTaskCreatePinnedToCore(&PagerReceiveSession::outboundTaskEntry, "vmp_tx", kOutboundTaskStackWords, @@ -540,12 +617,87 @@ class PagerReceiveSession final nullptr, tskNO_AFFINITY) != pdPASS) { - setActive(false); + if (lockState()) + { + active_ = false; + outbound_active_ = false; + unlockState(); + } + Serial.printf("[VMP][TX] hold begin failed reason=worker_create\n"); return StartSendResult::Unsupported; } + Serial.printf("[VMP][TX] capture worker queued\n"); return StartSendResult::Queued; } + bool isOutboundActive() const + { + if (!initialized_ || !lockState()) + { + return false; + } + const bool active = outbound_active_; + unlockState(); + return active; + } + + bool requestStopRecording() + { + if (!initialized_ || !lockState()) + { + Serial.printf("[VMP][TX] hold release ignored reason=unavailable\n"); + return false; + } + const bool active = active_; + if (active) + { + // The capture loop observes this without taking the state mutex, + // so a release cannot block the LVGL task behind an I2S read. + record_stop_requested_ = true; + } + unlockState(); + Serial.printf("[VMP][TX] hold release stop_requested=%u\n", + active ? 1U : 0U); + return active; + } + + bool markConversationRead(uint8_t presentation_protocol, + uint8_t presentation_channel, + uint32_t peer_id, + bool broadcast) + { + const auto protocol = + static_cast(presentation_protocol); + if (!initialized_ || !media_ || !inbox_ready_ || + !vmp::isValidVoicePresentationBinding(protocol, + presentation_channel) || + !lockState()) + { + return false; + } + const bool changed = media_->inbox.markConversationRead(protocol, + presentation_channel, + peer_id, + broadcast); + const bool persisted = + !changed || !requires_durable_attachment_store_ || + ::platform::esp::arduino_common::chat_attachment::persistVoiceInbox( + media_->inbox, + media_->persistence_metadata, + vmp::kVoiceInboxCapacity); + unlockState(); + if (changed) + { + Serial.printf("[VMP][UI] conversation_read protocol=%u channel=%u peer=%08lX broadcast=%u durable=%u\n", + static_cast(presentation_protocol), + static_cast(presentation_channel), + static_cast(peer_id), + broadcast ? 1U : 0U, + persisted ? 1U : 0U); + } + return persisted; + } + private: static void controlEnvelopeReceived(const control::Envelope& envelope, void* context) @@ -607,6 +759,7 @@ class PagerReceiveSession final if (self) { self->setOutboundTask(xTaskGetCurrentTaskHandle()); + Serial.printf("[VMP][TX] capture worker start\n"); self->runOutbound(); } vTaskDelete(nullptr); @@ -625,23 +778,53 @@ class PagerReceiveSession final void runOutbound() { bool sent = false; + bool local_object_stored = false; bool used_lxmf = false; - if (media_->audio.capture(nullptr) == audio::CaptureResult::Complete && - media_->audio.hasEncodedMedia() && - media_->transmit_block.prepare(media_->audio.encodedMedia(), media_->audio.encodedMediaSize()) && - prepareOutboundControl()) + const uint32_t started_ms = millis(); + const audio::CaptureResult capture_result = + media_->audio.capture(&record_stop_requested_); + if (capture_result != audio::CaptureResult::Complete || + !media_->audio.hasEncodedMedia()) { + Serial.printf("[VMP][TX] capture discarded result=%u bytes=%u\n", + static_cast(capture_result), + static_cast(media_->audio.encodedMediaSize())); + } + else if (!media_->transmit_block.prepare(media_->audio.encodedMedia(), + media_->audio.encodedMediaSize())) + { + Serial.printf("[VMP][TX] encode rejected reason=fec_prepare bytes=%u\n", + static_cast(media_->audio.encodedMediaSize())); + } + else if (!prepareOutboundControl()) + { + Serial.printf("[VMP][TX] encode rejected reason=control_prepare\n"); + } + else if (!storeOutboundVoice()) + { + Serial.printf("[VMP][TX] local message commit failed; carrier skipped\n"); + } + else + { + local_object_stored = true; + Serial.printf("[VMP][TX] encoded bytes=%u shards=%u mode=%s\n", + static_cast(media_->audio.encodedMediaSize()), + static_cast(vmp::kTotalShardsPerBlock), + outbound_is_broadcast_ ? "broadcast" : "private"); if (!direct_rf_voice_supported_) { // SX1262 can encode and publish the VMP object through an // explicitly enabled MT MQTT uplink, but has no legal RF or // LXMF voice carrier. No READY/control/2.4 GHz operation is // reachable from this branch. + Serial.printf("[VMP][TX] carrier=mqtt plan_begin\n"); sent = queueMqttPublication(); } else { used_lxmf = shouldUseLxmfCarrier(); + Serial.printf("[VMP][TX] carrier=%s begin\n", + used_lxmf ? "lxmf" : (outbound_is_broadcast_ ? "lr1121_rf_broadcast" : "lr1121_rf_private")); sent = used_lxmf ? sendLxmfVoice() : (outbound_is_broadcast_ ? sendBroadcastVoice() : sendPrivateVoice()); @@ -651,7 +834,14 @@ class PagerReceiveSession final } } } - (void)sent; + if (local_object_stored) + { + commitOutboundDelivery(sent ? vmp::VoiceDeliveryState::Sent + : vmp::VoiceDeliveryState::Failed); + } + Serial.printf("[VMP][TX] outbound end sent=%u elapsed_ms=%lu\n", + sent ? 1U : 0U, + static_cast(millis() - started_ms)); clearOutboundAcceptWait(); media_->audio.clearEncodedMedia(); media_->transmit_block.clear(); @@ -662,13 +852,20 @@ class PagerReceiveSession final void runPlayback() { + audio::PlaybackResult result = audio::PlaybackResult::InvalidMedia; if (playback_local_id_ != 0U && playback_media_len_ != 0U) { - (void)media_->audio.play(media_->playback_media, - playback_media_len_, - playback_codec_, - 70U); + Serial.printf("[VMP][PLAY] begin local_id=%llu bytes=%u\n", + static_cast(playback_local_id_), + static_cast(playback_media_len_)); + result = media_->audio.play(media_->playback_media, + playback_media_len_, + playback_codec_, + 70U); } + Serial.printf("[VMP][PLAY] end local_id=%llu result=%u\n", + static_cast(playback_local_id_), + static_cast(result)); clearPlaybackTask(); } @@ -681,6 +878,7 @@ class PagerReceiveSession final if (!mqtt_uplink_enabled_) { unlockState(); + Serial.printf("[VMP][MQTT] plan rejected reason=uplink_disabled\n"); return false; } const bool prepared = outbound_is_broadcast_ @@ -696,6 +894,9 @@ class PagerReceiveSession final media_->mqtt_transmit.clear(); } unlockState(); + Serial.printf("[VMP][MQTT] plan %s mode=%s\n", + prepared ? "ready" : "rejected", + outbound_is_broadcast_ ? "broadcast" : "private"); return prepared; } @@ -828,6 +1029,7 @@ class PagerReceiveSession final : static_cast(vmp::ControlFlagPrivate); outgoing_control_.sender_id = self_node_id_; outgoing_control_.target_id = outbound_target_id_; + outgoing_control_.conversation_channel = outbound_presentation_channel_; esp_fill_random(&outgoing_control_.session_id, sizeof(outgoing_control_.session_id)); if (outgoing_control_.session_id == 0U) @@ -884,10 +1086,13 @@ class PagerReceiveSession final &control_len) || !radio::tryAcquire(&radio_lease_)) { + Serial.printf("[VMP][RF] private offer prepare_or_lease_failed\n"); return false; } beginOutboundAcceptWait(); + Serial.printf("[VMP][RF] private offer tx; wait_accept_ms=%lu\n", + static_cast(kPrivateAcceptWindowMs)); if (!radio::transmit(&radio_lease_, control_wire_, control_len)) { clearOutboundAcceptWait(); @@ -906,8 +1111,10 @@ class PagerReceiveSession final &session_keys_)) { clearOutboundAcceptWait(); + Serial.printf("[VMP][RF] private accept failed_or_timed_out\n"); return false; } + Serial.printf("[VMP][RF] private accept authenticated; enter_2g_ready\n"); return transmitPrivateDataTrain(); } @@ -923,9 +1130,12 @@ class PagerReceiveSession final !radio::transmit(&radio_lease_, control_wire_, control_len)) { releaseRadio(); + Serial.printf("[VMP][RF] broadcast announce failed\n"); return false; } releaseRadio(); + Serial.printf("[VMP][RF] broadcast announce sent; enter_2g_after_ms=%lu\n", + static_cast(outgoing_control_.data_start_delay_ms)); vTaskDelay(pdMS_TO_TICKS(outgoing_control_.data_start_delay_ms)); radio::PhyProfile profile{}; @@ -946,8 +1156,12 @@ class PagerReceiveSession final !radio::transmit(&radio_lease_, data_wire_, probe_len)) { releaseRadio(); + Serial.printf("[VMP][RF] broadcast ready_probe failed index=%u\n", + static_cast(probe)); return false; } + Serial.printf("[VMP][RF] broadcast ready_probe sent index=%u\n", + static_cast(probe + 1U)); vTaskDelay(pdMS_TO_TICKS(kReadyProbeSpacingMs)); } return transmitDataShards(false); @@ -966,6 +1180,8 @@ class PagerReceiveSession final vTaskDelay(pdMS_TO_TICKS(outgoing_control_.data_start_delay_ms)); for (uint8_t probe = 0U; probe < kReadyProbeCount; ++probe) { + Serial.printf("[VMP][RF] private ready_probe attempt=%u\n", + static_cast(probe + 1U)); if (!sendPrivateReadyProbe() || !waitForPrivateReady()) { if (probe + 1U == kReadyProbeCount) @@ -976,6 +1192,7 @@ class PagerReceiveSession final vTaskDelay(pdMS_TO_TICKS(kReadyProbeSpacingMs)); continue; } + Serial.printf("[VMP][RF] private ready authenticated\n"); return transmitDataShards(true); } releaseRadio(); @@ -1030,6 +1247,9 @@ class PagerReceiveSession final bool transmitDataShards(bool private_mode) { + Serial.printf("[VMP][RF] shard_train begin mode=%s count=%u\n", + private_mode ? "private" : "broadcast", + static_cast(vmp::kTotalShardsPerBlock)); for (uint8_t shard_index = 0U; shard_index < vmp::kTotalShardsPerBlock; ++shard_index) { @@ -1050,10 +1270,13 @@ class PagerReceiveSession final if (!built || !radio::transmit(&radio_lease_, data_wire_, frame_len)) { releaseRadio(); + Serial.printf("[VMP][RF] shard_train failed index=%u\n", + static_cast(shard_index)); return false; } } releaseRadio(); + Serial.printf("[VMP][RF] shard_train complete\n"); return true; } @@ -1328,7 +1551,7 @@ class PagerReceiveSession final const bool stored = storeCompletedVoice(incoming_control_, media_->received_media, media_len, - millis() / 1000U); + voiceTimestampSeconds()); secureClear(media_->received_media, sizeof(media_->received_media)); unlockState(); return stored; @@ -1463,6 +1686,8 @@ class PagerReceiveSession final outbound_waiting_accept_ = false; outbound_accept_received_ = false; outbound_task_ = nullptr; + outbound_active_ = false; + outbound_local_id_ = 0U; active_ = false; unlockState(); } @@ -1496,18 +1721,27 @@ class PagerReceiveSession final encoded_media_len, true, received_at_seconds, + presentation_protocol_, + control.conversation_channel, &local_id); if (result == vmp::VoiceInboxStoreResult::Duplicate) { + Serial.printf("[VMP][RX] inbox duplicate bytes=%u\n", + static_cast(encoded_media_len)); return true; } if (result != vmp::VoiceInboxStoreResult::Stored) { + Serial.printf("[VMP][RX] inbox rejected result=%u bytes=%u\n", + static_cast(result), + static_cast(encoded_media_len)); return false; } if (!requires_durable_attachment_store_) { + Serial.printf("[VMP][RX] inbox stored volatile bytes=%u\n", + static_cast(encoded_media_len)); return true; } const bool persisted = @@ -1521,14 +1755,93 @@ class PagerReceiveSession final // object is not exposed locally until both its payload and index // have been committed. There is no VMP ACK or retransmit here. (void)media_->inbox.erase(local_id); + Serial.printf("[VMP][RX] inbox persistence failed rollback=1\n"); + } + else + { + Serial.printf("[VMP][RX] inbox durable_commit local_id=%llu bytes=%u\n", + static_cast(local_id), + static_cast(encoded_media_len)); } return persisted; } + bool storeOutboundVoice() + { + if (!media_ || !inbox_ready_ || !media_->audio.hasEncodedMedia()) + { + return false; + } + + uint64_t local_id = 0U; + const vmp::VoiceInboxStoreResult result = media_->inbox.storeOutgoing( + outgoing_control_, + media_->audio.encodedMedia(), + media_->audio.encodedMediaSize(), + voiceTimestampSeconds(), + outbound_presentation_protocol_, + outbound_presentation_channel_, + &local_id); + if (result != vmp::VoiceInboxStoreResult::Stored) + { + Serial.printf("[VMP][TX] local message store rejected result=%u\n", + static_cast(result)); + return false; + } + + if (requires_durable_attachment_store_ && + !::platform::esp::arduino_common::chat_attachment::persistVoiceInbox( + media_->inbox, + media_->persistence_metadata, + vmp::kVoiceInboxCapacity)) + { + (void)media_->inbox.erase(local_id); + Serial.printf("[VMP][TX] local message persistence failed rollback=1\n"); + return false; + } + + outbound_local_id_ = local_id; + Serial.printf("[VMP][TX] local message committed local_id=%llu delivery=sending\n", + static_cast(local_id)); + return true; + } + + void commitOutboundDelivery(vmp::VoiceDeliveryState delivery) + { + if (!media_ || outbound_local_id_ == 0U || + !media_->inbox.updateDeliveryState(outbound_local_id_, delivery)) + { + Serial.printf("[VMP][TX] local delivery update skipped state=%u\n", + static_cast(delivery)); + return; + } + + if (requires_durable_attachment_store_ && + !::platform::esp::arduino_common::chat_attachment::persistVoiceInbox( + media_->inbox, + media_->persistence_metadata, + vmp::kVoiceInboxCapacity)) + { + // Do not report a durable terminal status that was not committed. + // A later UI refresh keeps the safe `Sending` state rather than + // inventing a successful or failed history entry. + (void)media_->inbox.updateDeliveryState( + outbound_local_id_, vmp::VoiceDeliveryState::Sending); + Serial.printf("[VMP][TX] local delivery persistence deferred state=%u\n", + static_cast(delivery)); + return; + } + Serial.printf("[VMP][TX] local delivery committed local_id=%llu state=%u\n", + static_cast(outbound_local_id_), + static_cast(delivery)); + } + uint32_t self_node_id_ = 0U; bool initialized_ = false; bool direct_rf_voice_supported_ = false; bool active_ = false; + bool outbound_active_ = false; + volatile bool record_stop_requested_ = false; bool requires_durable_attachment_store_ = false; bool attachment_store_ready_ = false; bool inbox_ready_ = false; @@ -1544,6 +1857,12 @@ class PagerReceiveSession final uint16_t playback_media_len_ = 0U; vmp::Codec playback_codec_ = vmp::Codec::Codec2_1300; uint32_t outbound_target_id_ = 0U; + vmp::VoicePresentationProtocol presentation_protocol_ = + vmp::VoicePresentationProtocol::Unknown; + vmp::VoicePresentationProtocol outbound_presentation_protocol_ = + vmp::VoicePresentationProtocol::Unknown; + uint8_t outbound_presentation_channel_ = vmp::kVoicePresentationPrimaryChannel; + uint64_t outbound_local_id_ = 0U; bool outbound_is_broadcast_ = false; bool mqtt_uplink_enabled_ = false; bool lxmf_carrier_enabled_ = false; @@ -1657,6 +1976,11 @@ void setLxmfCarrierEnabled(bool enabled) s_session.setLxmfCarrierEnabled(enabled); } +void setPresentationProtocol(uint8_t protocol) +{ + s_session.setPresentationProtocol(protocol); +} + bool acceptMqttEnvelope(const uint8_t* envelope, std::size_t envelope_len) { return s_session.acceptMqttEnvelope(envelope, envelope_len); @@ -1674,9 +1998,34 @@ void discardMqttPublication() s_session.discardMqttPublication(); } -StartSendResult requestRecordAndSend(uint32_t target_id) +StartSendResult requestRecordAndSend(uint32_t target_id, + uint8_t presentation_protocol, + uint8_t presentation_channel) { - return s_session.requestRecordAndSend(target_id); + return s_session.requestRecordAndSend(target_id, + presentation_protocol, + presentation_channel); +} + +bool markConversationRead(uint8_t presentation_protocol, + uint8_t presentation_channel, + uint32_t peer_id, + bool broadcast) +{ + return s_session.markConversationRead(presentation_protocol, + presentation_channel, + peer_id, + broadcast); +} + +bool requestStopRecording() +{ + return s_session.requestStopRecording(); +} + +bool isOutboundActive() +{ + return s_session.isOutboundActive(); } } // namespace platform::esp::arduino_common::voice::vmp_session @@ -1761,6 +2110,10 @@ void setLxmfCarrierEnabled(bool) { } +void setPresentationProtocol(uint8_t) +{ +} + bool acceptMqttEnvelope(const uint8_t*, std::size_t) { return false; @@ -1775,11 +2128,26 @@ void discardMqttPublication() { } -StartSendResult requestRecordAndSend(uint32_t) +StartSendResult requestRecordAndSend(uint32_t, uint8_t, uint8_t) { return StartSendResult::Unsupported; } +bool markConversationRead(uint8_t, uint8_t, uint32_t, bool) +{ + return false; +} + +bool requestStopRecording() +{ + return false; +} + +bool isOutboundActive() +{ + return false; +} + } // namespace platform::esp::arduino_common::voice::vmp_session #endif