diff --git a/android/app/build.gradle b/android/app/build.gradle index ee00c1de..354c37ea 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -11,6 +11,7 @@ def pythonTempDir = new File(buildDir, "python-tmp") def vendorWheelDir = new File(rootProject.projectDir, "vendor") def lxstPatchedWheel = new File(rootProject.projectDir, "vendor/lxst-0.4.8-py3-none-any.whl") def bleakPatchedWheel = new File(rootProject.projectDir, "vendor/bleak-3.0.2-py3-none-any.whl") +def rnsPatchedWheel = new File(rootProject.projectDir, "vendor/rns-1.3.7-py3-none-any.whl") def allAndroidAbis = ["arm64-v8a", "x86_64", "armeabi-v7a"] def selectedAndroidAbis = ( project.findProperty("meshchatxAbis") @@ -227,7 +228,14 @@ chaquopy { options "--find-links", vendorWheelDir.absolutePath install "packaging>=23" install "aiohttp==3.14.1" - install "rns>=1.3.7" + if (!rnsPatchedWheel.exists()) { + throw new org.gradle.api.GradleException("Missing patched RNS wheel at ${rnsPatchedWheel}") + } + // Patched so RNS's Android RNodeInterface never calls RNS.panic() (os._exit) + // when usbserial4a/jnius are missing. RNode over TCP needs neither and always + // works; serial/BLE/classic-Bluetooth now fail gracefully instead of crashing + // the app. See scripts/build-android-wheels-local.sh. + install rnsPatchedWheel.absolutePath install "lxmf>=1.0.1" install "numpy==1.26.2" install "chaquopy-libcodec2==1.2.0" diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 9fa317f8..4997d70a 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -853,7 +853,7 @@ class ReticulumMeshChat: disable_rnode_interfaces_in_config, ) - return disable_rnode_interfaces_in_config(config_path) + return disable_rnode_interfaces_in_config(config_path, is_android=True) def _ensure_reticulum_config(self, materialize: bool = True): """Normalize ``reticulum_config_dir`` and optionally ensure a ``config`` file exists. @@ -899,8 +899,10 @@ class ReticulumMeshChat: from meshchatx.src.backend.rnode_support import ( guard_invalid_rnode_txpower_in_config, guard_rnode_interfaces_on_android, + normalize_rnode_tcp_host_in_config, ) + normalize_rnode_tcp_host_in_config(config_path) guard_rnode_interfaces_on_android(config_path) guard_invalid_rnode_txpower_in_config(config_path) @@ -5145,20 +5147,41 @@ 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(): + if interface_type == "RNodeMultiInterface": + # RNS has no Android-specific implementation of RNodeMultiInterface, + # so it always crashes on Android regardless of transport. + if _is_chaquopy_android(): return web.json_response( { "message": ( - "RNode serial and Bluetooth are not available on this device. " - "On Android, the app must include usbserial4a and jnius " - "(see MeshChatX issue #6)." + "RNodeMultiInterface is not supported on Android " + "(Reticulum has no Android-specific implementation of it)." + ), + }, + status=422, + ) + + elif interface_type == "RNodeInterface": + # RNodeIPInterface always maps to an RNodeInterface with a tcp:// + # port, which needs no native Android modules and always works. + from meshchatx.src.backend.rnode_support import ( + rnode_transport_supported, + ) + + probe_interface = { + "port": data.get("port"), + "allow_bluetooth": data.get("allow_bluetooth"), + } + if not rnode_transport_supported( + probe_interface, + is_android=_is_chaquopy_android(), + ): + return web.json_response( + { + "message": ( + "This RNode connection type is not available on this device. " + "On Android, USB serial and Bluetooth need usbserial4a and jnius " + "(see MeshChatX issue #6); RNode over IP (TCP) is unaffected." ), }, status=422, @@ -5548,6 +5571,7 @@ class ReticulumMeshChat: status=422, ) + interface_tcp_host = None if str(interface_port).strip().lower().startswith("tcp://"): interface_port = InterfaceEditor.normalize_rnode_tcp_port( str(interface_port), @@ -5560,6 +5584,7 @@ class ReticulumMeshChat: }, status=422, ) + interface_tcp_host = host_part # ensure frequency provided interface_frequency = data.get("frequency") @@ -5616,6 +5641,14 @@ class ReticulumMeshChat: # set required RNodeInterface options interface_details["port"] = interface_port + if interface_tcp_host is not None: + # RNS's Android-specific RNodeInterface reads tcp_host as its + # own config key instead of parsing it out of port like the + # desktop implementation does, so both must be set for RNode + # over IP to work on Android. + interface_details["tcp_host"] = interface_tcp_host + else: + interface_details.pop("tcp_host", None) interface_details["frequency"] = ( InterfaceEditor.coerce_rnode_frequency_hz( interface_frequency, diff --git a/meshchatx/src/backend/rnode_support.py b/meshchatx/src/backend/rnode_support.py index e047e468..35a9345d 100644 --- a/meshchatx/src/backend/rnode_support.py +++ b/meshchatx/src/backend/rnode_support.py @@ -1,6 +1,15 @@ # SPDX-License-Identifier: 0BSD -"""RNode USB serial / BLE UART support checks for desktop and Android.""" +"""RNode USB serial / Bluetooth / BLE support checks for desktop and Android. + +RNode over TCP ("RNode over IP") needs no native Android modules and works +unconditionally, since RNS's Android RNodeInterface is patched (see +scripts/build-android-wheels-local.sh) to stop hard-crashing the process when +usbserial4a/jnius are missing. Serial and classic-Bluetooth ports need +usbserial4a + jnius. BLE (ble://) ports need able. This module lets the rest +of the app tell which RNode config entries can actually be brought up on the +current build, so only the genuinely unsupported ones get disabled. +""" from __future__ import annotations @@ -8,6 +17,8 @@ import logging logger = logging.getLogger(__name__) +_TRUE_STRINGS = ("true", "yes", "1", "on") + def _is_chaquopy_android() -> bool: try: @@ -19,9 +30,7 @@ def _is_chaquopy_android() -> bool: def android_usbserial4a_available() -> bool: - """True when Chaquopy can import usbserial4a (RNS RNode on Android).""" - if not _is_chaquopy_android(): - return False + """True when usbserial4a can be imported (RNode serial/Bluetooth-classic on Android).""" try: import usbserial4a # noqa: F401 except ImportError: @@ -30,9 +39,13 @@ def android_usbserial4a_available() -> bool: def android_jnius_available() -> bool: - """True when Chaquopy can import jnius (pyjnius).""" - if not _is_chaquopy_android(): - return False + """True when jnius (pyjnius) can be imported. + + RNS's Android-specific RNodeInterface needs jnius for USB serial and + classic Bluetooth (RFCOMM) access. Chaquopy does not ship pyjnius under + the importable name "jnius" unless bundled explicitly (e.g. via a + compatibility shim), so this is normally unavailable. + """ try: import jnius # noqa: F401 except ImportError: @@ -40,6 +53,15 @@ def android_jnius_available() -> bool: return True +def android_able_available() -> bool: + """True when able can be imported (BLE GATT support for RNode ble:// on Android).""" + try: + import able # noqa: F401 + except ImportError: + return False + return True + + def desktop_serial_stack_available() -> bool: try: from serial.tools import list_ports # noqa: F401 @@ -49,16 +71,82 @@ def desktop_serial_stack_available() -> bool: def rnode_serial_supported() -> bool: - """Whether RNode serial and ble:// UART ports can be opened on this platform.""" + """Whether RNode USB serial / classic-Bluetooth ports can be opened here. + + This does not cover RNode over TCP (always supported) or ble:// (covered + by android_able_available() on Android). + """ if _is_chaquopy_android(): return android_usbserial4a_available() and android_jnius_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. +def _is_enabled(iface: dict) -> bool: + for key in ("interface_enabled", "enabled"): + if key in iface and str(iface.get(key, "")).lower() in _TRUE_STRINGS: + return True + return False - Returns True if any interfaces were disabled. + +def rnode_port_is_tcp(port: object) -> bool: + return str(port or "").strip().lower().startswith("tcp://") + + +def rnode_port_is_ble(port: object) -> bool: + return str(port or "").strip().lower().startswith("ble://") + + +def _rnode_iface_transport(iface: dict) -> str: + """Classify an RNodeInterface config entry's transport. + + Returns one of "tcp", "ble", "bluetooth_classic", or "serial". + """ + port = iface.get("port") + if rnode_port_is_tcp(port): + return "tcp" + if rnode_port_is_ble(port): + return "ble" + allow_bluetooth = str(iface.get("allow_bluetooth", "")).lower() in _TRUE_STRINGS + if not port and allow_bluetooth: + return "bluetooth_classic" + return "serial" + + +def rnode_transport_supported(iface: dict, *, is_android: bool | None = None) -> bool: + """Whether a specific RNodeInterface config entry can be brought up here. + + RNode over TCP always works. Serial and classic-Bluetooth need + usbserial4a + jnius on Android. BLE needs able on Android. On desktop, + every transport relies on the regular pyserial/bleak stack. + + ``is_android`` lets a caller that already determined the platform pass + that result through explicitly, instead of re-detecting it here. + """ + if is_android is None: + is_android = _is_chaquopy_android() + if not is_android: + return desktop_serial_stack_available() + + transport = _rnode_iface_transport(iface) + if transport == "tcp": + return True + if transport == "ble": + return android_able_available() + return android_usbserial4a_available() and android_jnius_available() + + +def normalize_rnode_tcp_host_in_config(config_path: str) -> bool: + """Backfill tcp_host for RNodeInterface entries configured with a tcp:// port. + + RNS's desktop RNodeInterface derives tcp_host from a tcp:// port itself, + but the Android-specific implementation reads tcp_host as its own, + separate config key and never looks at port for that. Configs written or + hand-edited with only ``port = tcp://host:port`` therefore silently try + (and fail) to open the RNode as a serial device on Android. This keeps + both keys in sync regardless of how the entry was created, so RNode over + TCP works the same way on both platforms. + + Returns True if any interfaces were modified. """ import os @@ -71,6 +159,60 @@ def disable_rnode_interfaces_in_config(config_path: str) -> bool: 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 + if iface.get("type") != "RNodeInterface": + continue + port = iface.get("port") + if not rnode_port_is_tcp(port): + continue + host_part = str(port).strip()[len("tcp://") :].strip().strip(":") + if not host_part: + continue + if str(iface.get("tcp_host", "")).strip() != host_part: + iface["tcp_host"] = host_part + modified = True + if modified: + try: + cfg.write() + except Exception: + pass + return modified + + +def disable_rnode_interfaces_in_config( + config_path: str, + *, + is_android: bool | None = None, +) -> bool: + """Disable RNode* interfaces in a Reticulum config file that can't run here. + + RNode over TCP is left enabled since it needs no native Android modules. + RNodeMultiInterface has no Android-specific implementation upstream and is + always disabled on Android. Serial, classic-Bluetooth, and BLE entries are + disabled only when their required native module isn't available. + + 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 + + if is_android is None: + is_android = _is_chaquopy_android() + modified = False interfaces = cfg.get("interfaces") if not isinstance(interfaces, dict): @@ -79,15 +221,19 @@ def disable_rnode_interfaces_in_config(config_path: str) -> bool: 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 not isinstance(iface_type, str) or not iface_type.startswith("RNode"): + continue + if not _is_enabled(iface): + continue + + if iface_type == "RNodeMultiInterface": + should_disable = is_android + else: + should_disable = not rnode_transport_supported(iface, is_android=is_android) + + if should_disable: + iface["interface_enabled"] = "false" + modified = True if modified: try: cfg.write() @@ -136,12 +282,7 @@ def guard_invalid_rnode_txpower_in_config(config_path: str) -> bool: continue if not _rnode_interface_has_invalid_txpower(iface): continue - if str(iface.get("interface_enabled", "")).lower() not in ( - "true", - "yes", - "1", - "on", - ): + if str(iface.get("interface_enabled", "")).lower() not in _TRUE_STRINGS: continue iface["interface_enabled"] = "false" modified = True @@ -166,21 +307,19 @@ def guard_invalid_rnode_txpower_in_config(config_path: str) -> bool: def guard_rnode_interfaces_on_android(config_path: str) -> bool: - """On Android without usbserial4a/jnius, disable RNode interfaces to avoid startup crashes.""" + """On Android, disable RNode interfaces that can't be brought up on this build. + + RNode over TCP is unaffected. Serial, classic-Bluetooth, and BLE entries + are disabled only when their required native module isn't bundled, and + RNodeMultiInterface is always disabled since RNS has no Android-specific + implementation of it. + """ if not _is_chaquopy_android(): return False - if rnode_serial_supported(): - return False - missing = [] - if not android_usbserial4a_available(): - missing.append("usbserial4a") - if not android_jnius_available(): - missing.append("jnius") - disabled = disable_rnode_interfaces_in_config(config_path) + disabled = disable_rnode_interfaces_in_config(config_path, is_android=True) if disabled: logger.warning( - "RNode interfaces were disabled because %s is not installed on this build. " - "Rebuild the Android app with the missing module(s) or remove RNode entries from config.", - " and ".join(missing) if missing else "a required module", + "One or more RNode interfaces were disabled because their transport " + "is not supported on this Android build. RNode over TCP is unaffected.", ) return disabled diff --git a/scripts/build-android-wheels-local.sh b/scripts/build-android-wheels-local.sh index 54e70811..b22c34d3 100755 --- a/scripts/build-android-wheels-local.sh +++ b/scripts/build-android-wheels-local.sh @@ -11,9 +11,11 @@ This script: 3) Builds pycodec2 Android wheels with Chaquopy's build-wheel tool 4) Optionally patches LXST wheel metadata for local Android constraints 5) Vendors the bleak pure-python wheel from PyPI -6) Builds every recipe under android/chaquopy-recipes/ for each requested +6) Patches the rns wheel so its Android RNodeInterface never calls + RNS.panic() (os._exit) when usbserial4a/jnius are missing +7) Builds every recipe under android/chaquopy-recipes/ for each requested ABI (currently: cryptography, miniaudio) -7) Copies outputs to android/vendor +8) Copies outputs to android/vendor Usage: scripts/build-android-wheels-local.sh [options] @@ -28,7 +30,9 @@ Options: --numpy-version V NumPy version used during pycodec2 build (default: 1.26.2) --lxst-version V LXST wheel version for metadata patch (default: 0.4.8) --bleak-version V bleak pure-python wheel version to vendor (default: 3.0.2) + --rns-version V rns wheel version to patch (default: 1.3.7) --no-lxst-patch Skip LXST metadata patch + --no-rns-patch Skip RNS Android RNodeInterface patch --only-recipes LIST Comma-separated recipe directory names under android/chaquopy-recipes to build. When set, the NumPy, pycodec2/chaquopy-libcodec2 and LXST steps @@ -56,7 +60,9 @@ LIBCODEC2_VERSION="1.2.0" NUMPY_VERSION="1.26.2" LXST_VERSION="0.4.8" BLEAK_VERSION="3.0.2" +RNS_VERSION="1.3.7" PATCH_LXST="1" +PATCH_RNS="1" ONLY_RECIPES="" WORK_DIR="${ROOT_DIR}/.local/chaquopy-build-wheel" OUT_DIR="${ROOT_DIR}/android/vendor" @@ -99,10 +105,18 @@ while [[ $# -gt 0 ]]; do BLEAK_VERSION="${2:?missing value for --bleak-version}" shift 2 ;; + --rns-version) + RNS_VERSION="${2:?missing value for --rns-version}" + shift 2 + ;; --no-lxst-patch) PATCH_LXST="0" shift ;; + --no-rns-patch) + PATCH_RNS="0" + shift + ;; --only-recipes) ONLY_RECIPES="${2:?missing value for --only-recipes}" shift 2 @@ -793,6 +807,105 @@ PY fi fi +if [[ "${PATCH_RNS}" == "1" && -z "${ONLY_RECIPES}" ]]; then + echo "Fetching and patching rns ${RNS_VERSION} for Android" + RNS_TMP_DIR="$(mktemp -d)" + + "${VENV_DIR}/bin/pip" download \ + --only-binary=:all: \ + --no-deps \ + "rns==${RNS_VERSION}" \ + --dest "${RNS_TMP_DIR}" \ + --index-url https://pypi.org/simple + + RNS_WHEEL="$(ls "${RNS_TMP_DIR}"/rns-"${RNS_VERSION}"-py3-none-any.whl)" + PATCHED_RNS_WHEEL="${OUT_DIR}/rns-${RNS_VERSION}-py3-none-any.whl" + + # RNS's Android-specific RNodeInterface calls RNS.panic() (os._exit) when + # usbserial4a/jnius aren't importable, even for RNode over TCP, which + # needs neither. Since Reticulum.py also panics on any exception raised + # from an interface's __init__, the fix is to never raise/panic there: + # leave serial/bt_manager as None and let the transport-specific code + # (open_port/configure_device) fail into RNodeInterface's own existing + # reconnect-loop instead of crashing the whole app. + "${VENV_DIR}/bin/python" - </dev/null 2>&1; then + echo "Expected rns-${RNS_VERSION}-py3-none-any.whl in ${OUT_DIR}" >&2 + exit 1 + fi +fi + fix_wheel_libpython_needed() { local wheel="$1" local python_soname="$2" diff --git a/tests/backend/test_interface_options.py b/tests/backend/test_interface_options.py index 4c52acac..0a3c9a74 100644 --- a/tests/backend/test_interface_options.py +++ b/tests/backend/test_interface_options.py @@ -489,6 +489,85 @@ async def test_rnode_tcp_over_ip_normalizes_to_host_only(temp_dir): body = json.loads(response.body) assert response.status == 200, body assert config["interfaces"]["RNodeWiFi"]["port"] == "tcp://192.168.4.1" + # RNS's Android-specific RNodeInterface reads tcp_host as its own + # config key instead of parsing it out of port like desktop does. + assert config["interfaces"]["RNodeWiFi"]["tcp_host"] == "192.168.4.1" + + +@pytest.mark.asyncio +async def test_rnode_over_ip_allowed_on_android_without_usbserial4a_or_jnius(temp_dir): + """RNode over TCP needs no native Android modules and must not be blocked.""" + config = ConfigDict({"reticulum": {}, "interfaces": {}}) + + async with make_app(temp_dir, config) as handler: + with patch("meshchatx.meshchat._is_chaquopy_android", return_value=True): + payload = { + "name": "RNodeWiFi", + "type": "RNodeIPInterface", + "port": "tcp://192.168.4.1:7633", + "frequency": 868000000, + "bandwidth": 125000, + "txpower": 7, + "spreadingfactor": 8, + "codingrate": 5, + } + response = await handler(make_request(payload)) + body = json.loads(response.body) + assert response.status == 200, body + assert config["interfaces"]["RNodeWiFi"]["tcp_host"] == "192.168.4.1" + + +@pytest.mark.asyncio +async def test_rnode_serial_blocked_on_android_without_usbserial4a_or_jnius(temp_dir): + config = ConfigDict({"reticulum": {}, "interfaces": {}}) + + async with make_app(temp_dir, config) as handler: + with patch("meshchatx.meshchat._is_chaquopy_android", return_value=True): + payload = { + "name": "Radio", + "type": "RNodeInterface", + "port": "/dev/ttyUSB0", + "frequency": 868000000, + "bandwidth": 125000, + "txpower": 7, + "spreadingfactor": 8, + "codingrate": 5, + } + response = await handler(make_request(payload)) + body = json.loads(response.body) + assert response.status == 422, body + assert "RNode over IP" in body["message"] + assert "Radio" not in config["interfaces"] + + +@pytest.mark.asyncio +async def test_rnode_multi_interface_blocked_on_android(temp_dir): + """RNS has no Android-specific implementation of RNodeMultiInterface.""" + config = ConfigDict({"reticulum": {}, "interfaces": {}}) + + async with make_app(temp_dir, config) as handler: + with patch("meshchatx.meshchat._is_chaquopy_android", return_value=True): + payload = { + "name": "MultiRadio", + "type": "RNodeMultiInterface", + "port": "/dev/ttyUSB0", + "sub_interfaces": [ + { + "name": "vport0", + "frequency": 868000000, + "bandwidth": 125000, + "txpower": 7, + "spreadingfactor": 8, + "codingrate": 5, + "vport": 0, + }, + ], + } + response = await handler(make_request(payload)) + body = json.loads(response.body) + assert response.status == 422, body + assert "not supported on Android" in body["message"] + assert "MultiRadio" not in config["interfaces"] @pytest.mark.asyncio diff --git a/tests/backend/test_rnode_support.py b/tests/backend/test_rnode_support.py index 0fd66e28..01fcea38 100644 --- a/tests/backend/test_rnode_support.py +++ b/tests/backend/test_rnode_support.py @@ -1,9 +1,66 @@ # SPDX-License-Identifier: 0BSD +import pytest + from meshchatx.src.backend import rnode_support +def test_normalize_rnode_tcp_host_backfills_from_port(tmp_path): + """RNS's Android RNodeInterface reads tcp_host as its own config key. + + Configs written with only ``port = tcp://host:port`` (which is all the + desktop RNodeInterface needs) silently try to open the RNode as a serial + device on Android unless tcp_host is also present. + """ + config_path = tmp_path / "config" + config_path.write_text( + """[interfaces] + [[RNode TCP]] + type = RNodeInterface + interface_enabled = True + port = tcp://192.0.2.1:4242 +""", + encoding="utf-8", + ) + + assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is True + text = config_path.read_text(encoding="utf-8") + assert "tcp_host = 192.0.2.1:4242" in text + + +def test_normalize_rnode_tcp_host_leaves_non_tcp_entries_alone(tmp_path): + config_path = tmp_path / "config" + config_path.write_text( + """[interfaces] + [[RNode Serial]] + type = RNodeInterface + interface_enabled = True + port = /dev/ttyUSB0 +""", + encoding="utf-8", + ) + + assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is False + assert "tcp_host" not in config_path.read_text(encoding="utf-8") + + +def test_normalize_rnode_tcp_host_is_idempotent(tmp_path): + config_path = tmp_path / "config" + config_path.write_text( + """[interfaces] + [[RNode TCP]] + type = RNodeInterface + interface_enabled = True + port = tcp://192.0.2.1:4242 + tcp_host = 192.0.2.1:4242 +""", + encoding="utf-8", + ) + + assert rnode_support.normalize_rnode_tcp_host_in_config(str(config_path)) is False + + def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch): config_path = tmp_path / "config" config_path.write_text( @@ -11,7 +68,7 @@ def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch): [[RNode Serial]] type = RNodeInterface interface_enabled = True - port = ble://aa:bb:cc:dd:ee:ff + port = /dev/ttyUSB0 """, encoding="utf-8", ) @@ -24,19 +81,17 @@ def test_guard_disables_rnode_when_usbserial4a_missing(tmp_path, monkeypatch): def test_guard_disables_rnode_when_jnius_missing(tmp_path, monkeypatch): - """usbserial4a alone is not enough: RNS's Android RNodeInterface also needs jnius. + """usbserial4a alone is not enough for serial/classic-Bluetooth ports. - Chaquopy builds that bundle usbserial4a but not jnius still hit - RNS.panic() (os._exit) for any RNode port type (serial, tcp://, ble://), - since the jnius check runs before the transport is selected. + RNS's Android RNodeInterface also needs jnius for those transports. """ config_path = tmp_path / "config" config_path.write_text( """[interfaces] - [[RNode TCP]] + [[RNode Serial]] type = RNodeInterface interface_enabled = True - port = tcp://192.0.2.1:4242 + port = /dev/ttyUSB0 """, encoding="utf-8", ) @@ -55,6 +110,7 @@ def test_guard_keeps_rnode_when_usbserial4a_and_jnius_available(tmp_path, monkey [[RNode Serial]] type = RNodeInterface interface_enabled = True + port = /dev/ttyUSB0 """, encoding="utf-8", ) @@ -66,6 +122,101 @@ def test_guard_keeps_rnode_when_usbserial4a_and_jnius_available(tmp_path, monkey assert "interface_enabled = True" in config_path.read_text(encoding="utf-8") +def test_guard_keeps_tcp_rnode_enabled_even_without_usbserial4a_or_jnius( + tmp_path, + monkeypatch, +): + """RNode over TCP needs no native Android modules and must stay enabled.""" + config_path = tmp_path / "config" + config_path.write_text( + """[interfaces] + [[RNode TCP]] + type = RNodeInterface + interface_enabled = True + port = tcp://192.0.2.1:4242 +""", + encoding="utf-8", + ) + monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True) + monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False) + monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: False) + + assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False + assert "interface_enabled = True" in config_path.read_text(encoding="utf-8") + + +def test_guard_disables_ble_rnode_when_able_missing(tmp_path, monkeypatch): + config_path = tmp_path / "config" + config_path.write_text( + """[interfaces] + [[RNode BLE]] + type = RNodeInterface + interface_enabled = True + port = ble://aa:bb:cc:dd:ee:ff +""", + encoding="utf-8", + ) + monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True) + monkeypatch.setattr(rnode_support, "android_able_available", lambda: False) + + assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True + assert "interface_enabled = false" in config_path.read_text(encoding="utf-8") + + +def test_guard_keeps_ble_rnode_when_able_available(tmp_path, monkeypatch): + config_path = tmp_path / "config" + config_path.write_text( + """[interfaces] + [[RNode BLE]] + type = RNodeInterface + interface_enabled = True + port = ble://aa:bb:cc:dd:ee:ff +""", + encoding="utf-8", + ) + monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True) + monkeypatch.setattr(rnode_support, "android_able_available", lambda: True) + + assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False + assert "interface_enabled = True" in config_path.read_text(encoding="utf-8") + + +def test_guard_always_disables_rnode_multi_interface_on_android(tmp_path, monkeypatch): + """RNS has no Android-specific RNodeMultiInterface; it always crashes there.""" + config_path = tmp_path / "config" + config_path.write_text( + """[interfaces] + [[RNode Multi]] + type = RNodeMultiInterface + interface_enabled = True +""", + encoding="utf-8", + ) + monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True) + monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True) + monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: True) + monkeypatch.setattr(rnode_support, "android_able_available", lambda: True) + + assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is True + assert "interface_enabled = false" in config_path.read_text(encoding="utf-8") + + +def test_guard_skips_rnode_multi_interface_off_android(tmp_path, monkeypatch): + config_path = tmp_path / "config" + config_path.write_text( + """[interfaces] + [[RNode Multi]] + type = RNodeMultiInterface + interface_enabled = True +""", + encoding="utf-8", + ) + monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: False) + + assert rnode_support.guard_rnode_interfaces_on_android(str(config_path)) is False + assert "interface_enabled = True" in config_path.read_text(encoding="utf-8") + + def test_rnode_serial_supported_on_desktop(monkeypatch): monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: False) assert rnode_support.rnode_serial_supported() is True @@ -86,6 +237,32 @@ def test_rnode_serial_supported_on_android_requires_both_modules(monkeypatch): assert rnode_support.rnode_serial_supported() is True +def test_rnode_transport_supported_tcp_always_true_on_android(monkeypatch): + monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True) + monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: False) + monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: False) + monkeypatch.setattr(rnode_support, "android_able_available", lambda: False) + + assert ( + rnode_support.rnode_transport_supported({"port": "tcp://example.org:4242"}) + is True + ) + + +def test_rnode_transport_supported_bluetooth_classic_needs_usbserial_and_jnius( + monkeypatch, +): + monkeypatch.setattr(rnode_support, "_is_chaquopy_android", lambda: True) + monkeypatch.setattr(rnode_support, "android_usbserial4a_available", lambda: True) + monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: True) + + iface = {"port": "", "allow_bluetooth": "true"} + assert rnode_support.rnode_transport_supported(iface) is True + + monkeypatch.setattr(rnode_support, "android_jnius_available", lambda: False) + assert rnode_support.rnode_transport_supported(iface) is False + + def test_guard_disables_rnode_with_invalid_txpower(tmp_path): config_path = tmp_path / "config" config_path.write_text(