From 7eea421940d2baf28e6476c2736f889f5f3657a4 Mon Sep 17 00:00:00 2001 From: Ivan Date: Fri, 5 Jun 2026 18:30:56 -0500 Subject: [PATCH] feat(android): refactor RNode interface handling and add support for Android storage management --- meshchatx/meshchat.py | 120 ++- meshchatx/src/backend/rnode_support.py | 100 +++ meshchatx/src/backend/rrc/server.py | 7 + .../components/AndroidStorageChoicePrompt.vue | 199 +++++ meshchatx/src/frontend/components/App.vue | 34 + .../messages/ConversationMessageEntry.vue | 39 +- .../messages/ConversationViewer.vue | 26 +- .../components/relay/RelayChatPage.vue | 287 +++--- .../relay/RelayHostMembersModal.vue | 410 --------- .../relay/RelayHostModerationPage.vue | 825 ++++++++++++++++++ .../components/relay/RelayHostRoomsModal.vue | 293 ------- .../components/tools/RNSHManagerPage.vue | 283 +++--- .../components/tools/RNSHSessionTerminal.vue | 166 ++++ .../src/frontend/js/relayHostModalClasses.js | 9 + .../frontend/js/relayHostModerationClasses.js | 37 + meshchatx/src/frontend/locales/de.json | 49 +- meshchatx/src/frontend/locales/en.json | 47 +- meshchatx/src/frontend/locales/es.json | 49 +- meshchatx/src/frontend/locales/fr.json | 49 +- meshchatx/src/frontend/locales/it.json | 49 +- meshchatx/src/frontend/locales/nl.json | 49 +- meshchatx/src/frontend/locales/ru.json | 49 +- meshchatx/src/frontend/locales/zh.json | 49 +- tests/backend/test_android_rnode.py | 1 + tests/backend/test_lxmf_propagation_full.py | 4 + .../test_lxmf_propagation_sync_integration.py | 3 + tests/backend/test_lxmf_sync.py | 5 + tests/backend/test_propagation_sync_cancel.py | 92 ++ tests/backend/test_rnode_support.py | 44 + tests/backend/test_rrc_server.py | 9 + ...onViewer.outboundPropagationStatus.test.js | 11 + tests/frontend/RNSHManagerPage.test.js | 24 + tests/frontend/RelayChatPage.test.js | 14 +- ...est.js => RelayHostModerationPage.test.js} | 27 +- 34 files changed, 2385 insertions(+), 1074 deletions(-) create mode 100644 meshchatx/src/backend/rnode_support.py create mode 100644 meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue delete mode 100644 meshchatx/src/frontend/components/relay/RelayHostMembersModal.vue create mode 100644 meshchatx/src/frontend/components/relay/RelayHostModerationPage.vue delete mode 100644 meshchatx/src/frontend/components/relay/RelayHostRoomsModal.vue create mode 100644 meshchatx/src/frontend/components/tools/RNSHSessionTerminal.vue create mode 100644 meshchatx/src/frontend/js/relayHostModalClasses.js create mode 100644 meshchatx/src/frontend/js/relayHostModerationClasses.js create mode 100644 tests/backend/test_propagation_sync_cancel.py create mode 100644 tests/backend/test_rnode_support.py rename tests/frontend/{RelayHostMembersModal.test.js => RelayHostModerationPage.test.js} (77%) diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index b4834f13..e5cb2321 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -783,44 +783,14 @@ class ReticulumMeshChat: @staticmethod def _disable_rnode_interfaces_on_android(config_path: str) -> bool: - """If running on Android, disable RNode* interfaces in Reticulum config. - - Returns True if any interfaces were disabled. - """ + """Disable enabled RNode* interfaces in Reticulum config (Android recovery helper).""" if not _is_chaquopy_android(): return False - if not os.path.isfile(config_path): - return False - try: - from RNS.vendor.configobj import ConfigObj + from meshchatx.src.backend.rnode_support import ( + disable_rnode_interfaces_in_config, + ) - cfg = ConfigObj(config_path) - except Exception: - return False - - modified = False - interfaces = cfg.get("interfaces") - if not isinstance(interfaces, dict): - return False - for _iface_name, iface in interfaces.items(): - if not isinstance(iface, dict): - continue - iface_type = iface.get("type", "") - if isinstance(iface_type, str) and iface_type.startswith("RNode"): - if str(iface.get("interface_enabled", "")).lower() in ( - "true", - "yes", - "1", - "on", - ): - iface["interface_enabled"] = "false" - modified = True - if modified: - try: - cfg.write() - except Exception: - pass - return modified + return disable_rnode_interfaces_in_config(config_path) def _ensure_reticulum_config(self, materialize: bool = True): """Normalize ``reticulum_config_dir`` and optionally ensure a ``config`` file exists. @@ -863,13 +833,11 @@ class ReticulumMeshChat: cfg.write() except Exception: pass - # Android: RNodeInterface crashes because serial port access isn't available - if _is_chaquopy_android(): - disabled = self._disable_rnode_interfaces_on_android(config_path) - if disabled: - logging.getLogger(__name__).warning( - "RNodeInterface is not supported on Android; disabled in config.", - ) + from meshchatx.src.backend.rnode_support import ( + guard_rnode_interfaces_on_android, + ) + + guard_rnode_interfaces_on_android(config_path) def setup_identity(self, identity: RNS.Identity): identity_hash = identity.hash.hex() @@ -2655,7 +2623,45 @@ class ReticulumMeshChat: ctx = context or self.current_context if not ctx or not ctx.message_router: return - ctx.message_router.cancel_propagation_node_requests() + router = ctx.message_router + with contextlib.suppress(Exception): + router.cancel_propagation_node_requests() + # cancel_propagation_node_requests resets via acknowledge_sync_completion, + # but a blocked RNS.Identity.recall can leave the router in an active state. + with contextlib.suppress(Exception): + active_states = { + router.PR_PATH_REQUESTED, + router.PR_LINK_ESTABLISHING, + router.PR_LINK_ESTABLISHED, + router.PR_REQUEST_SENT, + router.PR_RECEIVING, + router.PR_RESPONSE_RECEIVED, + } + if router.propagation_transfer_state in active_states: + router.propagation_transfer_state = router.PR_IDLE + router.propagation_transfer_progress = 0.0 + + async def _request_propagation_node_messages(self, context=None): + ctx = context or self.current_context + if not ctx or not ctx.message_router: + return + + router = ctx.message_router + + def _request(): + try: + router.request_messages_from_propagation_node(ctx.identity) + except (EOFError, BrokenPipeError, ConnectionResetError, OSError): + with contextlib.suppress(Exception): + router.propagation_transfer_state = router.PR_IDLE + router.propagation_transfer_progress = 0.0 + except Exception: + logging.getLogger("meshchatx").exception( + "Propagation node message request failed", + ) + + await asyncio.to_thread(_request) + await self.send_config_to_websocket_clients(context=ctx) def _get_propagation_sync_metrics(self, context=None): ctx = context or self.current_context @@ -4848,6 +4854,24 @@ class ReticulumMeshChat: # update interface details interface_details["type"] = interface_type + if interface_type in ( + "RNodeInterface", + "RNodeIPInterface", + "RNodeMultiInterface", + ): + from meshchatx.src.backend.rnode_support import rnode_serial_supported + + if not rnode_serial_supported(): + return web.json_response( + { + "message": ( + "RNode serial and Bluetooth are not available on this device. " + "On Android, the app must include usbserial4a (see MeshChatX issue #6)." + ), + }, + status=422, + ) + # if interface doesn't have enabled or interface_enabled setting already, enable it by default if ( "enabled" not in interface_details @@ -14180,8 +14204,10 @@ class ReticulumMeshChat: await self.send_config_to_websocket_clients(context=ctx) return - # request messages from propagation node - router.request_messages_from_propagation_node(ctx.identity) + # Kick off the LXMF request on a worker thread. Identity.recall and link + # setup can block on multiprocessing pipes; running inline would stall the + # HTTP handler and race with cancel_propagation_node_requests (EOFError). + asyncio.create_task(self._request_propagation_node_messages(context=ctx)) # send config to websocket clients (used to tell ui last synced at) await self.send_config_to_websocket_clients(context=ctx) @@ -18018,8 +18044,6 @@ class ReticulumMeshChat: should_update_message = True while should_update_message: - await asyncio.sleep(1) - progress_pct = round(lxmf_message.progress * 100, 2) ctx.database.messages.update_lxmf_message_state( message_hash=lxmf_message.hash.hex(), @@ -18069,6 +18093,8 @@ class ReticulumMeshChat: # check if we should stop updating if has_delivered or has_propagated or has_failed or is_cancelled: should_update_message = False + else: + await asyncio.sleep(1) def on_telephone_announce_received( self, diff --git a/meshchatx/src/backend/rnode_support.py b/meshchatx/src/backend/rnode_support.py new file mode 100644 index 00000000..dfffb8fa --- /dev/null +++ b/meshchatx/src/backend/rnode_support.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: 0BSD + +"""RNode USB serial / BLE UART support checks for desktop and Android.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def _is_chaquopy_android() -> bool: + try: + from meshchatx.android_push_bridge import _is_chaquopy_android as _check + + return _check() + except ImportError: + return False + + +def android_usbserial4a_available() -> bool: + """True when Chaquopy can import usbserial4a (RNS RNode on Android).""" + if not _is_chaquopy_android(): + return False + try: + import usbserial4a # noqa: F401 + except ImportError: + return False + return True + + +def desktop_serial_stack_available() -> bool: + try: + from serial.tools import list_ports # noqa: F401 + except ImportError: + return False + return True + + +def rnode_serial_supported() -> bool: + """Whether RNode serial and ble:// UART ports can be opened on this platform.""" + if _is_chaquopy_android(): + return android_usbserial4a_available() + return desktop_serial_stack_available() + + +def disable_rnode_interfaces_in_config(config_path: str) -> bool: + """Disable enabled RNode* interfaces in a Reticulum config file. + + Returns True if any interfaces were disabled. + """ + import os + + if not os.path.isfile(config_path): + return False + try: + from RNS.vendor.configobj import ConfigObj + + cfg = ConfigObj(config_path) + except Exception: + return False + + modified = False + interfaces = cfg.get("interfaces") + if not isinstance(interfaces, dict): + return False + for _iface_name, iface in interfaces.items(): + if not isinstance(iface, dict): + continue + iface_type = iface.get("type", "") + if isinstance(iface_type, str) and iface_type.startswith("RNode"): + if str(iface.get("interface_enabled", "")).lower() in ( + "true", + "yes", + "1", + "on", + ): + iface["interface_enabled"] = "false" + modified = True + if modified: + try: + cfg.write() + except Exception: + pass + return modified + + +def guard_rnode_interfaces_on_android(config_path: str) -> bool: + """On Android without usbserial4a, disable RNode interfaces to avoid startup crashes.""" + if not _is_chaquopy_android(): + return False + if rnode_serial_supported(): + return False + disabled = disable_rnode_interfaces_in_config(config_path) + if disabled: + logger.warning( + "RNode interfaces were disabled because usbserial4a is not installed. " + "Rebuild the Android app with usbserial4a or remove RNode entries from config.", + ) + return disabled diff --git a/meshchatx/src/backend/rrc/server.py b/meshchatx/src/backend/rrc/server.py index d21e4a05..c41db8e2 100644 --- a/meshchatx/src/backend/rrc/server.py +++ b/meshchatx/src/backend/rrc/server.py @@ -108,6 +108,7 @@ class RRCHubServer: self.destination = None self.running = False + self._started_at = None self._lock = threading.RLock() self._sessions = {} @@ -145,6 +146,7 @@ class RRCHubServer: ) self.destination.set_link_established_callback(self._on_link) self.running = True + self._started_at = time.time() if self.announce: self.announce_now() self._log("hub started at " + self.dest_hash.hex()) @@ -165,6 +167,7 @@ class RRCHubServer: dest = self.destination self.destination = None self.running = False + self._started_at = None for link in links: with contextlib.suppress(Exception): link.teardown() @@ -905,12 +908,16 @@ class RRCHubServer: cfg["members"] = len(self._room_members.get(name, set())) rooms.append(cfg) policy = self.policy.to_dict() + uptime_seconds = 0 + if self.running and self._started_at is not None: + uptime_seconds = max(0, int(time.time() - self._started_at)) return { "id": self.hub_id, "name": self.name, "dest_hash": self.dest_hash.hex() if self.dest_hash else None, "enabled": self.enabled, "running": self.running, + "uptime_seconds": uptime_seconds, "announce": self.announce, "greeting": self.greeting, "clients": sum(1 for s in self._sessions.values() if s.welcomed), diff --git a/meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue b/meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue new file mode 100644 index 00000000..b2b152dd --- /dev/null +++ b/meshchatx/src/frontend/components/AndroidStorageChoicePrompt.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index dc6769ff..5103736d 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -643,6 +643,11 @@ +
{ if (this._isPropagationSyncPolling) return; @@ -1756,6 +1777,19 @@ export default { try { await this.updatePropagationNodeStatus(); if (this.isSyncingPropagationNode) { + if (Date.now() - pollStartedAt > propagationSyncPollTimeoutMs) { + if (this._propagationSyncPollTimer != null) { + clearInterval(this._propagationSyncPollTimer); + this._propagationSyncPollTimer = null; + } + await this.stopSyncingPropagationNode(); + ToastUtils.error( + this.$t("app.sync_error", { + status: this.propagationSyncStatusLabel("path_timeout"), + }) + ); + return; + } ToastUtils.loading(this.propagationSyncLiveToastMessage(), 0, propagationSyncToastKey); return; } diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue index a7a58423..3ab0a09d 100644 --- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue +++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue @@ -224,9 +224,7 @@ :title="cv.outboundSentStatusTitle(entry.items[0].lxmf_message)" />
+
+
+
+
+ + {{ cv.outboundSendingProgressLabel(entry.items[0].lxmf_message) }} + +
+
+
+
+
+ + {{ cv.outboundSendingProgressLabel(chatItem.lxmf_message) }} + +
+
0 && ["outbound", "generating"].includes(state)) { + return Math.min(100, Math.max(0, Math.round(progress))); + } + return null; + }, + outboundSendingProgressLabel(lxmfMessage) { + const pct = this.outboundTransferProgressPercent(lxmfMessage); + return pct === null ? null : `${pct}%`; + }, outboundSendingStatusTooltip(lxmfMessage) { if (!lxmfMessage) { return ""; diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue index a5e3cb4b..bded8550 100644 --- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue +++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue @@ -703,146 +703,127 @@
-
-
-
-
-

{{ $t("relay_chat.host_title") }}

-

{{ $t("relay_chat.host_subtitle") }}

-
- -
- -
- - {{ $t("relay_chat.no_hosted_hubs") }} -
- -
+
+ +
+
-
-
- - {{ hub.name }} -
- +
+

{{ $t("relay_chat.host_title") }}

+

{{ $t("relay_chat.host_subtitle") }}

-
- - - - -
-
- -
- -
- + + {{ $t("relay_chat.no_hosted_hubs") }} +
+ +
+
+
+
+ + {{ hub.name }} +
+ +
+
+ + + + +
+
+ +
+ + + {{ hub.clients }} {{ $t("relay_chat.host_clients") }} + + + + {{ hub.rooms.length }} {{ $t("relay_chat.host_rooms") }} + +
+ + +
- - - -
-
-

{{ $t("relay_chat.create_hub_title") }}

+
+
+

{{ $t("relay_chat.create_hub_title") }}