From 01f5164828bc59d3b1e8ab10c4c302cd7803c7fc Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 30 Apr 2026 15:33:25 -0500 Subject: [PATCH] feat(telephony): update call metadata tracking and user interface updates for active calls, including path hops and interface details; implement ringtone playback handling for browser autoplay restrictions; add configuration for telephone announcement enabling. --- meshchatx/meshchat.py | 66 ++- meshchatx/src/backend/config_manager.py | 5 + meshchatx/src/backend/telephone_manager.py | 84 ++-- meshchatx/src/frontend/components/App.vue | 33 +- .../src/frontend/components/call/CallPage.vue | 453 +++++++++++++++--- .../js/telephone-pcm-capture.worklet.js | 23 + tests/backend/test_telephone_initiation.py | 50 ++ tests/frontend/AppModals.test.js | 87 ++++ tests/frontend/CallPage.test.js | 146 ++++++ 9 files changed, 827 insertions(+), 120 deletions(-) create mode 100644 meshchatx/src/frontend/public/assets/js/telephone-pcm-capture.worklet.js diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 741cab8..25a17ef 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -6831,6 +6831,8 @@ class ReticulumMeshChat: "rx_bytes": 0, "tx_packets": 0, "rx_packets": 0, + "path_hops": None, + "path_interface": None, } link = getattr(self.telephone_manager, "call_stats", {}).get("link") if link: @@ -6838,6 +6840,53 @@ class ReticulumMeshChat: active_call["rx_bytes"] = getattr(link, "rxbytes", 0) active_call["tx_packets"] = getattr(link, "tx", 0) active_call["rx_packets"] = getattr(link, "rx", 0) + # Best-effort direct link metadata fallback. + if active_call["path_hops"] is None: + for hop_attr in ["hops", "hop_count", "path_hops"]: + hops_val = getattr(link, hop_attr, None) + if isinstance(hops_val, int): + active_call["path_hops"] = hops_val + break + if not active_call["path_interface"]: + for iface_attr in ["attached_interface", "interface", "ifac"]: + iface_val = getattr(link, iface_attr, None) + if isinstance(iface_val, str) and iface_val.strip(): + active_call["path_interface"] = iface_val.strip() + break + iface_name = ( + getattr(iface_val, "name", None) if iface_val else None + ) + if isinstance(iface_name, str) and iface_name.strip(): + active_call["path_interface"] = iface_name.strip() + break + + # Try multiple destination hashes; depending on LXST state, the + # active call hash is not always the route-resolvable destination. + for candidate_hex in [ + remote_telephony_hash, + remote_hash, + active_call["hash"], + remote_destination_hash, + ]: + if not candidate_hex: + continue + try: + candidate_hash = bytes.fromhex(candidate_hex) + except Exception: + continue + try: + if not RNS.Transport.has_path(candidate_hash): + continue + active_call["path_hops"] = RNS.Transport.hops_to(candidate_hash) + if hasattr(self, "reticulum") and self.reticulum: + active_call["path_interface"] = ( + self.reticulum.get_next_hop_if_name( + candidate_hash, + ) + ) + break + except Exception: + continue initiation_target_hash = self.telephone_manager.initiation_target_hash initiation_target_name = None @@ -6887,6 +6936,9 @@ class ReticulumMeshChat: "target_frame_time_ms", None, ), + "diagnostics": self.web_audio_bridge.get_diagnostics() + if hasattr(self.web_audio_bridge, "get_diagnostics") + else None, }, }, ) @@ -6921,7 +6973,7 @@ class ReticulumMeshChat: # hangup active telephone call @routes.get("/api/v1/telephone/hangup") async def telephone_hangup(request): - await asyncio.to_thread(self.telephone_manager.hangup) + self.telephone_manager.request_hangup() return web.json_response( { @@ -12441,8 +12493,10 @@ class ReticulumMeshChat: if ctx.config.lxmf_local_propagation_node_enabled.get(): ctx.message_router.announce_propagation_node() - # send announce for telephone - ctx.telephone_manager.announce(display_name=ctx.config.display_name.get()) + # send announce for telephone (can be disabled to reduce unsolicited + # incoming telephony link attempts from public lxst.telephony announces) + if ctx.config.telephone_announce_enabled.get(): + ctx.telephone_manager.announce(display_name=ctx.config.display_name.get()) # tell websocket clients we just announced await self.send_announced_to_websocket_clients(context=ctx) @@ -13042,6 +13096,11 @@ class ReticulumMeshChat: self._parse_bool(data["telephone_allow_calls_from_contacts_only"]), ) + if "telephone_announce_enabled" in data: + self.config.telephone_announce_enabled.set( + self._parse_bool(data["telephone_announce_enabled"]), + ) + if "call_recording_enabled" in data: value = self._parse_bool(data["call_recording_enabled"]) self.config.call_recording_enabled.set(value) @@ -14209,6 +14268,7 @@ class ReticulumMeshChat: "map_nominatim_api_url": ctx.config.map_nominatim_api_url.get(), "do_not_disturb_enabled": ctx.config.do_not_disturb_enabled.get(), "telephone_allow_calls_from_contacts_only": ctx.config.telephone_allow_calls_from_contacts_only.get(), + "telephone_announce_enabled": ctx.config.telephone_announce_enabled.get(), "telephone_audio_profile_id": ctx.config.telephone_audio_profile_id.get(), "telephone_web_audio_enabled": ctx.config.telephone_web_audio_enabled.get(), "telephone_web_audio_allow_fallback": ctx.config.telephone_web_audio_allow_fallback.get(), diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index 2b5b97f..13fa947 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -198,6 +198,11 @@ class ConfigManager: "telephone_allow_calls_from_contacts_only", False, ) + self.telephone_announce_enabled = self.BoolConfig( + self, + "telephone_announce_enabled", + True, + ) self.telephone_audio_profile_id = self.IntConfig( self, "telephone_audio_profile_id", diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py index 379ff69..eebd301 100644 --- a/meshchatx/src/backend/telephone_manager.py +++ b/meshchatx/src/backend/telephone_manager.py @@ -4,11 +4,13 @@ import asyncio import base64 import contextlib import os +import threading import time import RNS from LXST import Telephone +from meshchatx.src.backend import reticulum_pathfinding from meshchatx.src.backend.meshchat_utils import ( hex_identifier_to_bytes, normalize_hex_identifier, @@ -116,14 +118,19 @@ class TelephoneManager: self.telephone = None def hangup(self): + self._update_initiation_status(None, None) if self.telephone: try: self.telephone.hangup() except Exception as e: RNS.log(f"TelephoneManager: Error during hangup: {e}", RNS.LOG_ERROR) - # Always clear initiation status on hangup to prevent "Dialing..." hang + def request_hangup(self): + # FIXME: Remove async hangup shim when LXST call() cancellation is non-blocking. self._update_initiation_status(None, None) + if not self.telephone: + return + threading.Thread(target=self.hangup, daemon=True).start() def register_ringing_callback(self, callback): self.on_ringing_callback = callback @@ -221,6 +228,11 @@ class TelephoneManager: return not bool(self.initiation_status) async def _await_path(self, destination_hash: bytes, timeout_seconds: float): + # Reuse shared pathfinding behavior so stale/unresponsive routes are + # refreshed before we wait, mirroring the faster outbound LXMF path prep. + with contextlib.suppress(Exception): + reticulum_pathfinding.prepare_fresh_path_request(None, destination_hash) + timeout_after = time.monotonic() + max(0.0, timeout_seconds) next_request_at = 0.0 @@ -234,7 +246,7 @@ class TelephoneManager: now = time.monotonic() if now >= next_request_at: with contextlib.suppress(Exception): - RNS.Transport.request_path(destination_hash) + reticulum_pathfinding.nudge_path_request(destination_hash) next_request_at = now + self._path_retry_interval_s await asyncio.sleep(self._path_poll_interval_s) @@ -311,6 +323,10 @@ class TelephoneManager: if destination_identity is None: self._update_initiation_status("Discovering path/identity...") + with contextlib.suppress(Exception): + reticulum_pathfinding.prepare_fresh_path_request( + None, destination_hash + ) timeout_after = time.monotonic() + timeout_seconds next_request_at = 0.0 @@ -322,7 +338,7 @@ class TelephoneManager: now = time.monotonic() if now >= next_request_at: with contextlib.suppress(Exception): - RNS.Transport.request_path(destination_hash) + reticulum_pathfinding.nudge_path_request(destination_hash) next_request_at = now + self._path_retry_interval_s destination_identity = resolve_identity(destination_hash_hex) @@ -335,10 +351,22 @@ class TelephoneManager: msg = "Destination identity not found" raise RuntimeError(msg) - if not RNS.Transport.has_path(destination_hash): + # FIXME: Remove telephony-destination pre-path lookup once LXST aligns + # identity-hash and telephony-destination path handling. + call_destination_hash = destination_hash + with contextlib.suppress(Exception): + call_destination_hash = RNS.Destination( + destination_identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + "lxst", + "telephony", + ).hash + + if not RNS.Transport.has_path(call_destination_hash): self._update_initiation_status("Requesting path...") has_path = await self._await_path( - destination_hash, + call_destination_hash, timeout_seconds=min(timeout_seconds, 10), ) if self._is_initiation_cancelled(): @@ -390,8 +418,10 @@ class TelephoneManager: await asyncio.sleep(self._status_poll_interval_s) if cancel_requested: + self._update_initiation_status(None, None) with contextlib.suppress(Exception): - self.telephone.hangup() + # FIXME: Remove async hangup dispatch when LXST exposes cooperative cancellation. + asyncio.create_task(asyncio.to_thread(self.telephone.hangup)) return None # If the task finished but we're still ringing or connecting, @@ -462,74 +492,32 @@ class TelephoneManager: def mute_transmit(self): if self.telephone: - # Manual override as LXST internal muting can be buggy - if hasattr(self.telephone, "audio_input") and self.telephone.audio_input: - try: - self.telephone.audio_input.stop() - except Exception as e: - RNS.log(f"Failed to stop audio input for mute: {e}", RNS.LOG_ERROR) - - # Still call the internal method just in case it does something useful try: self.telephone.mute_transmit() except Exception: pass - self.transmit_muted = True def unmute_transmit(self): if self.telephone: - # Manual override as LXST internal muting can be buggy - if hasattr(self.telephone, "audio_input") and self.telephone.audio_input: - try: - self.telephone.audio_input.start() - except Exception as e: - RNS.log( - f"Failed to start audio input for unmute: {e}", - RNS.LOG_ERROR, - ) - - # Still call the internal method just in case try: self.telephone.unmute_transmit() except Exception: pass - self.transmit_muted = False def mute_receive(self): if self.telephone: - # Manual override as LXST internal muting can be buggy - if hasattr(self.telephone, "audio_output") and self.telephone.audio_output: - try: - self.telephone.audio_output.stop() - except Exception as e: - RNS.log(f"Failed to stop audio output for mute: {e}", RNS.LOG_ERROR) - - # Still call the internal method just in case try: self.telephone.mute_receive() except Exception: pass - self.receive_muted = True def unmute_receive(self): if self.telephone: - # Manual override as LXST internal muting can be buggy - if hasattr(self.telephone, "audio_output") and self.telephone.audio_output: - try: - self.telephone.audio_output.start() - except Exception as e: - RNS.log( - f"Failed to start audio output for unmute: {e}", - RNS.LOG_ERROR, - ) - - # Still call the internal method just in case try: self.telephone.unmute_receive() except Exception: pass - self.receive_muted = False diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index 5a1654f..d96eb83 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -703,6 +703,7 @@ export default { isSpeakerMuting: false, endedTimeout: null, ringtonePlayer: null, + ringtoneAutoplayBlocked: false, toneGenerator: new ToneGenerator(), isFetchingRingtone: false, initiationStatus: null, @@ -806,6 +807,8 @@ export default { this.stopRingtone(); this.toneGenerator.stop(); window.removeEventListener("meshchatx-intent-uri", this.onAndroidIntentUri); + window.removeEventListener("pointerdown", this.onRingtoneUnlockGesture, true); + window.removeEventListener("keydown", this.onRingtoneUnlockGesture, true); }, mounted() { try { @@ -828,8 +831,19 @@ export default { }); } window.addEventListener("meshchatx-intent-uri", this.onAndroidIntentUri); + window.addEventListener("pointerdown", this.onRingtoneUnlockGesture, true); + window.addEventListener("keydown", this.onRingtoneUnlockGesture, true); }, methods: { + onRingtoneUnlockGesture() { + if (!this.ringtoneAutoplayBlocked) { + return; + } + this.ringtoneAutoplayBlocked = false; + if (this.activeCall?.status === 4 && this.activeCall?.is_incoming) { + this.playRingtone(); + } + }, startShellAuthWatch() { if (typeof this._shellAuthWatchStop === "function") { this._shellAuthWatchStop(); @@ -1574,12 +1588,19 @@ export default { } }, playRingtone() { - if (this.ringtonePlayer) { - if (this.ringtonePlayer.paused) { - this.ringtonePlayer.play().catch((e) => { - console.log("Failed to play custom ringtone:", e); - }); - } + if (!this.ringtonePlayer || this.ringtoneAutoplayBlocked) { + return; + } + if (this.ringtonePlayer.paused) { + this.ringtonePlayer.play().catch((e) => { + if (e?.name === "NotAllowedError") { + // Browser autoplay policy blocked playback until user gesture. + // Stop retry spam; we retry once user interacts again. + this.ringtoneAutoplayBlocked = true; + return; + } + console.warn("Failed to play custom ringtone:", e); + }); } }, stopRingtone() { diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue index 42d6491..812da29 100644 --- a/meshchatx/src/frontend/components/call/CallPage.vue +++ b/meshchatx/src/frontend/components/call/CallPage.vue @@ -80,7 +80,7 @@ -
+
- -
-
+
+ + + {{ activeCall.path_hops }} hops + + + + {{ activeCall.path_interface }} + +
{{ elapsedTime }}
+
+
+
+ TX Pkts +
+
+ {{ formatNumber(activeCall.tx_packets) }} +
+
+
+
+ RX Pkts +
+
+ {{ formatNumber(activeCall.rx_packets) }} +
+
+
+
+ TX Data Out +
+
+ {{ formatBytes(activeCall.tx_bytes) }} +
+
+
+
+ RX Data In +
+
+ {{ formatBytes(activeCall.rx_bytes) }} +
+
+