feat: update LXST version to 0.5.0 for android, fix telephone initialization error handling, and add compatibility layer for LXST profiles across versions

This commit is contained in:
Ivan 2026-07-23 08:47:54 -05:00
parent 9c739fa940
commit 026b8c5bc7
No known key found for this signature in database
25 changed files with 334 additions and 98 deletions

View file

@ -11,7 +11,7 @@ def repositoryBundledWheelsDir = new File(meshchatSourceDir, "public/repository-
def chaquopyBuildPython = System.getenv("CHAQUOPY_BUILD_PYTHON")
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 lxstPatchedWheel = new File(rootProject.projectDir, "vendor/lxst-0.5.0-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.4.0-py3-none-any.whl")
def allAndroidAbis = ["arm64-v8a", "x86_64", "armeabi-v7a"]

Binary file not shown.

View file

@ -249,12 +249,12 @@ def register_telephone_routes(routes, app):
"path_hops": None,
"path_interface": None,
}
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend import lxst_profiles_compat as lxst_modes
mode_id = active_call["call_mode_id"]
with contextlib.suppress(Exception):
active_call["call_mode_name"] = Profiles.mode_name(mode_id)
active_call["call_mode_abbrev"] = Profiles.mode_abbrevation(mode_id)
active_call["call_mode_name"] = lxst_modes.mode_name(mode_id)
active_call["call_mode_abbrev"] = lxst_modes.mode_abbreviation(mode_id)
link = getattr(app.telephone_manager, "call_stats", {}).get("link")
if link:
active_call["tx_bytes"] = getattr(link, "txbytes", 0)
@ -497,20 +497,21 @@ def register_telephone_routes(routes, app):
@routes.get("/api/v1/telephone/call-modes")
async def telephone_call_modes(request):
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend import lxst_profiles_compat as lxst_modes
half = lxst_modes.mode_half_duplex()
modes = [
{
"id": mode_id,
"name": Profiles.mode_name(mode_id),
"abbrev": Profiles.mode_abbrevation(mode_id),
"is_half_duplex": mode_id == Profiles.MODE_HALF_DUPLEX,
"name": lxst_modes.mode_name(mode_id),
"abbrev": lxst_modes.mode_abbreviation(mode_id),
"is_half_duplex": mode_id == half,
}
for mode_id in Profiles.available_modes()
for mode_id in lxst_modes.available_modes()
]
return web.json_response(
{
"default_call_mode_id": Profiles.DEFAULT_MODE,
"default_call_mode_id": lxst_modes.default_mode(),
"call_modes": modes,
},
)
@ -529,14 +530,14 @@ def register_telephone_routes(routes, app):
int(mode_id),
)
app.config.telephone_call_mode_id.set(resolved)
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend import lxst_profiles_compat as lxst_modes
return web.json_response(
{
"message": f"Switched to mode {resolved}",
"mode_id": resolved,
"mode_name": Profiles.mode_name(resolved),
"is_half_duplex": resolved == Profiles.MODE_HALF_DUPLEX,
"mode_name": lxst_modes.mode_name(resolved),
"is_half_duplex": resolved == lxst_modes.mode_half_duplex(),
"is_ptt_active": bool(app.telephone_manager.ptt_active),
},
)

View file

@ -363,9 +363,15 @@ class IdentityContext:
)
if not getattr(self.app, "emergency", False):
self.telephone_manager.init_telephone()
with contextlib.suppress(Exception):
self.app.sync_telephone_call_policy(context=self)
# Telephony must not take down mesh startup. Android may ship an
# older LXST without duplex helpers, and audio backends can fail
# while LXMF/RNS remain usable.
try:
self.telephone_manager.init_telephone()
with contextlib.suppress(Exception):
self.app.sync_telephone_call_policy(context=self)
except Exception as exc:
print(f"Telephone init failed (mesh continues): {exc}", flush=True)
self.voicemail_manager = VoicemailManager(
db=self.database,

View file

@ -0,0 +1,79 @@
# SPDX-License-Identifier: 0BSD
"""LXST Profiles helpers that work across LXST versions.
Android previously vendored lxst 0.4.8 without call-mode helpers
(available_modes, MODE_FULL_DUPLEX, MODE_HALF_DUPLEX). Desktop and current
Android builds ship 0.5.0+. MeshChatX keeps this shim so identity setup never
crashes when an older Profiles class is present.
"""
from __future__ import annotations
# Match LXST 0.5+ Profiles constants when the installed package lacks them.
MODE_FULL_DUPLEX = 0x01
MODE_HALF_DUPLEX = 0x02
DEFAULT_MODE = MODE_FULL_DUPLEX
_MODE_NAMES = {
MODE_FULL_DUPLEX: "Full Duplex",
MODE_HALF_DUPLEX: "Half Duplex",
}
_MODE_ABBREVS = {
MODE_FULL_DUPLEX: "FDX",
MODE_HALF_DUPLEX: "HDX",
}
def import_profiles():
"""Import LXST Profiles, or raise ImportError if LXST is unavailable."""
from LXST.Primitives.Telephony import Profiles
return Profiles
def duplex_modes_supported(profiles=None) -> bool:
"""True when the installed LXST Profiles exposes call-mode helpers."""
Profiles = profiles if profiles is not None else import_profiles()
return callable(getattr(Profiles, "available_modes", None))
def mode_full_duplex(profiles=None) -> int:
Profiles = profiles if profiles is not None else import_profiles()
return int(getattr(Profiles, "MODE_FULL_DUPLEX", MODE_FULL_DUPLEX))
def mode_half_duplex(profiles=None) -> int:
Profiles = profiles if profiles is not None else import_profiles()
return int(getattr(Profiles, "MODE_HALF_DUPLEX", MODE_HALF_DUPLEX))
def default_mode(profiles=None) -> int:
Profiles = profiles if profiles is not None else import_profiles()
return int(getattr(Profiles, "DEFAULT_MODE", DEFAULT_MODE))
def available_modes(profiles=None) -> list[int]:
"""Return duplex mode ids supported by the installed LXST build."""
Profiles = profiles if profiles is not None else import_profiles()
native = getattr(Profiles, "available_modes", None)
if callable(native):
return list(native())
# Pre-duplex LXST: full duplex only. Half duplex needs switch_mode / squelch.
return [mode_full_duplex(Profiles)]
def mode_name(mode_id, profiles=None) -> str:
Profiles = profiles if profiles is not None else import_profiles()
native = getattr(Profiles, "mode_name", None)
if callable(native):
return str(native(mode_id))
return _MODE_NAMES.get(int(mode_id), "Default")
def mode_abbreviation(mode_id, profiles=None) -> str:
Profiles = profiles if profiles is not None else import_profiles()
native = getattr(Profiles, "mode_abbrevation", None)
if callable(native):
return str(native(mode_id))
return _MODE_ABBREVS.get(int(mode_id), "DFLT")

View file

@ -171,9 +171,9 @@ class TelephoneManager:
def resolve_call_mode_id(self, mode_id=None):
"""Return a valid LXST call mode id (full or half duplex)."""
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend import lxst_profiles_compat as lxst_modes
available = set(Profiles.available_modes())
available = set(lxst_modes.available_modes())
mid = mode_id
if mid is None and self.config_manager:
with contextlib.suppress(Exception):
@ -183,7 +183,7 @@ class TelephoneManager:
except (TypeError, ValueError):
mid = None
if mid not in available:
mid = Profiles.DEFAULT_MODE
mid = lxst_modes.default_mode()
return mid
def apply_preferred_mode(self, mode_id=None):
@ -199,7 +199,7 @@ class TelephoneManager:
def switch_mode(self, mode_id):
"""Switch live call duplex mode via LXST signalling and local squelch."""
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend import lxst_profiles_compat as lxst_modes
resolved = self.resolve_call_mode_id(mode_id)
self.preferred_mode_id = resolved
@ -216,7 +216,7 @@ class TelephoneManager:
self.telephone.active_call.call_mode = resolved
# Half duplex starts listen-only. Full duplex keeps continuous TX open.
self.ptt_active = False
if resolved == Profiles.MODE_HALF_DUPLEX:
if resolved == lxst_modes.mode_half_duplex():
with contextlib.suppress(Exception):
self.telephone.squelch_transmit(True)
else:
@ -226,23 +226,24 @@ class TelephoneManager:
def get_active_mode_id(self):
"""Return the active call mode, preferred mode, or LXST default."""
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend import lxst_profiles_compat as lxst_modes
available = set(lxst_modes.available_modes())
if self.telephone and self.telephone.active_call:
link_mode = getattr(self.telephone.active_call, "call_mode", None)
if link_mode in Profiles.available_modes():
if link_mode in available:
return link_mode
mode = getattr(self.telephone, "active_mode", None)
if mode in Profiles.available_modes():
if mode in available:
return mode
if self.preferred_mode_id in Profiles.available_modes():
if self.preferred_mode_id in available:
return self.preferred_mode_id
return self.resolve_call_mode_id()
def is_half_duplex(self):
from LXST.Primitives.Telephony import Profiles
from meshchatx.src.backend import lxst_profiles_compat as lxst_modes
return self.get_active_mode_id() == Profiles.MODE_HALF_DUPLEX
return self.get_active_mode_id() == lxst_modes.mode_half_duplex()
def is_transmit_squelched(self):
"""True when LXST packetizer is squelched (half-duplex idle / PTT up)."""

View file

@ -23,10 +23,12 @@
:network-degraded-label="networkDegradedBannerLabel"
:network-recovering="networkRecovering"
:recover-network-label="$t('app.recover_network')"
:open-settings-label="$t('app.open_settings')"
:open-interfaces-label="$t('app.open_interfaces')"
@restart-backend="onRestartBackend"
@view-backend-logs="onViewBackendCrashReport"
@recover-network="onRecoverNetwork"
@open-settings="onOpenSettingsForRecovery"
@open-interfaces="onOpenInterfacesForRecovery"
/>
@ -1205,6 +1207,9 @@ export default {
onOpenInterfacesForRecovery() {
this.$router.push({ name: "interfaces" });
},
onOpenSettingsForRecovery() {
this.$router.push({ name: "settings" });
},
async onRecoverNetwork() {
if (this.networkRecovering) {
return;

View file

@ -69,6 +69,13 @@
>
{{ recoverNetworkLabel }}
</button>
<button
type="button"
class="rounded-md bg-white/10 px-3 py-1 text-xs font-semibold hover:bg-white/20"
@click="$emit('open-settings')"
>
{{ openSettingsLabel }}
</button>
<button
type="button"
class="rounded-md bg-white/10 px-3 py-1 text-xs font-semibold hover:bg-white/20"
@ -152,11 +159,15 @@ export default {
type: String,
default: "",
},
openSettingsLabel: {
type: String,
default: "",
},
openInterfacesLabel: {
type: String,
default: "",
},
},
emits: ["restart-backend", "view-backend-logs", "recover-network", "open-interfaces"],
emits: ["restart-backend", "view-backend-logs", "recover-network", "open-settings", "open-interfaces"],
};
</script>

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: 0BSD
/**
* Classify mesh startup / degraded errors for recovery UI.
* Not every failure is an interface config problem.
*/
const INTERFACE_HINTS = [
"interface",
"interfaces",
"rnode",
"serial",
"usb",
"autointerface",
"tcpclient",
"udp",
"i2p",
"wlan",
"kiss",
"ax25",
"pipeinterface",
"rns config",
"configobj",
];
/**
* @param {unknown} error
* @returns {boolean}
*/
export function isLikelyInterfaceRecoveryError(error) {
const text = String(error || "").toLowerCase();
if (!text) {
return false;
}
return INTERFACE_HINTS.some((hint) => text.includes(hint));
}
/**
* Route name to open after degraded startup, or null to leave the user on the
* current page (banner actions still apply).
* @param {unknown} error
* @returns {"interfaces" | null}
*/
export function recoveryRouteForNetworkError(error) {
if (isLikelyInterfaceRecoveryError(error)) {
return "interfaces";
}
return null;
}

View file

@ -2,8 +2,8 @@
export const STARTUP_STAGE_LABELS = {
http: "Getting things ready…",
starting: "Getting the mesh ready…",
rns: "Connecting to the mesh…",
starting: "Getting RNS ready…",
rns: "Starting RNS…",
identity: "Almost there…",
ready: "Ready",
failed: "Startup failed",
@ -29,7 +29,7 @@ export function interpretStartupStatus(data) {
if (status === "failed") {
// HTTP is up and the backend marked UI as usable: mount the app in
// degraded mode so interfaces/settings remain reachable for recovery.
// degraded mode so settings/interfaces remain reachable for recovery.
if (uiReady || networkDegraded) {
return {
kind: "degraded",
@ -48,7 +48,7 @@ export function interpretStartupStatus(data) {
}
if (status === "starting" || status === undefined) {
const resolvedStage = stage || "starting";
const label = STARTUP_STAGE_LABELS[resolvedStage] || "Starting network…";
const label = STARTUP_STAGE_LABELS[resolvedStage] || "Starting RNS…";
// HTTP is bound and the shell may mount while RNS/identity finish.
if (uiReady) {
return {
@ -101,7 +101,7 @@ export async function waitForNetworkReady(options = {}) {
const data = await response.json();
const interpreted = interpretStartupStatus(data);
if (interpreted.kind === "degraded") {
onLine(interpreted.error || "Mesh network unavailable. Opening recovery UI…");
onLine(interpreted.error || "RNS unavailable. Opening recovery UI…");
onDegraded(interpreted.error);
return "degraded";
}

View file

@ -491,12 +491,13 @@
"rpc_key_hide": "RPC-Schlüssel verbergen",
"refresh_community_interfaces": "Von directory.rns.recipes aktualisieren",
"refresh_community_interfaces_busy": "Wird aktualisiert…",
"network_starting": "Verbinde mit dem Mesh…",
"network_degraded": "Mesh-Netzwerk nicht verfügbar. Die App läuft weiter, damit Sie Schnittstellen reparieren können, ohne Daten zu löschen.",
"recover_network": "Netzwerk erneut versuchen",
"network_starting": "RNS wird gestartet…",
"network_degraded": "RNS nicht verfügbar. Die App läuft weiter, damit Sie diagnostizieren und wiederherstellen können, ohne Daten zu löschen.",
"recover_network": "RNS erneut versuchen",
"open_settings": "Einstellungen öffnen",
"open_interfaces": "Schnittstellen öffnen",
"network_recovered": "Netzwerkstapel wiederhergestellt",
"network_recover_failed": "Netzwerkstapel konnte nicht wiederhergestellt werden. Prüfen Sie die Schnittstellen und versuchen Sie es erneut.",
"network_recovered": "RNS wiederhergestellt",
"network_recover_failed": "RNS konnte nicht wiederhergestellt werden. Prüfen Sie die Fehlerdetails, dann erneut versuchen oder Einstellungen und Schnittstellen prüfen.",
"battery_unavailable": "Akkustatus nicht verfügbar",
"battery_level_title": "Akku {percent}",
"battery_charging_title": "Lädt {percent}",

View file

@ -430,12 +430,13 @@
"backend_process_stopped": "Reticulum backend stopped",
"backend_reconnected": "Reconnected to backend",
"restart_backend": "Restart backend",
"network_starting": "Connecting to the mesh…",
"network_degraded": "Mesh network unavailable. The app is still running so you can fix interfaces without wiping data.",
"recover_network": "Retry network",
"network_starting": "Starting RNS…",
"network_degraded": "RNS unavailable. The app is still running so you can diagnose and recover without wiping data.",
"recover_network": "Retry RNS",
"open_settings": "Open settings",
"open_interfaces": "Open interfaces",
"network_recovered": "Network stack recovered",
"network_recover_failed": "Could not recover the network stack. Check interfaces and try again.",
"network_recovered": "RNS recovered",
"network_recover_failed": "Could not recover RNS. Check the error details, then retry or review settings and interfaces.",
"restart_backend_started": "Restarting backend…",
"restart_backend_failed": "Could not restart backend",
"memory_pressure_warning": "High memory use detected. Close large Nomad pages, voice notes, or downloads if you can, then dismiss this warning. Persistent pressure can come from hostile peer content.",

View file

@ -491,12 +491,13 @@
"rpc_key_hide": "Ocultar clave RPC",
"refresh_community_interfaces": "Actualizar desde directory.rns.recipes",
"refresh_community_interfaces_busy": "Actualizando…",
"network_starting": "Conectando a la mesh…",
"network_degraded": "Red mesh no disponible. La aplicación sigue en ejecución para que pueda reparar interfaces sin borrar datos.",
"recover_network": "Reintentar red",
"network_starting": "Iniciando RNS…",
"network_degraded": "RNS no disponible. La aplicación sigue en ejecución para que pueda diagnosticar y recuperar sin borrar datos.",
"recover_network": "Reintentar RNS",
"open_settings": "Abrir configuración",
"open_interfaces": "Abrir interfaces",
"network_recovered": "Pila de red recuperada",
"network_recover_failed": "No se pudo recuperar la pila de red. Revise las interfaces e inténtelo de nuevo.",
"network_recovered": "RNS recuperado",
"network_recover_failed": "No se pudo recuperar RNS. Revise el error y reintente, o revise la configuración y las interfaces.",
"battery_unavailable": "Estado de batería no disponible",
"battery_level_title": "Batería {percent}",
"battery_charging_title": "Cargando {percent}",

View file

@ -491,12 +491,13 @@
"rpc_key_hide": "Piilota RPC-avain",
"refresh_community_interfaces": "Päivitä lähteestä directory.rns.recipes",
"refresh_community_interfaces_busy": "Päivitetään…",
"network_starting": "Yhdistetään mesh-verkkoon…",
"network_degraded": "Mesh-verkko ei ole käytettävissä. Sovellus pysyy käynnissä, jotta voit korjata liittymiä ilman tietojen tyhjennystä.",
"recover_network": "Yritä verkkoa uudelleen",
"network_starting": "Käynnistetään RNS…",
"network_degraded": "RNS ei ole käytettävissä. Sovellus pysyy käynnissä, jotta voit diagnosoida ja palauttaa ilman tietojen tyhjennystä.",
"recover_network": "Yritä RNS uudelleen",
"open_settings": "Avaa asetukset",
"open_interfaces": "Avaa liittymät",
"network_recovered": "Verkkopinon palautus onnistui",
"network_recover_failed": "Verkkopinoa ei voitu palauttaa. Tarkista liittymät ja yritä uudelleen.",
"network_recovered": "RNS palautettu",
"network_recover_failed": "RNS:ää ei voitu palauttaa. Tarkista virhetiedot, yritä uudelleen tai tarkista asetukset ja liittymät.",
"battery_unavailable": "Akun tila ei ole saatavilla",
"battery_level_title": "Akku {percent}",
"battery_charging_title": "Lataa {percent}",

View file

@ -491,12 +491,13 @@
"rpc_key_hide": "Masquer la clé RPC",
"refresh_community_interfaces": "Actualiser depuis directory.rns.recipes",
"refresh_community_interfaces_busy": "Actualisation…",
"network_starting": "Connexion au mesh…",
"network_degraded": "Réseau mesh indisponible. L'application reste ouverte pour réparer les interfaces sans effacer les données.",
"recover_network": "Réessayer le réseau",
"network_starting": "Démarrage de RNS…",
"network_degraded": "RNS indisponible. L'application reste ouverte pour diagnostiquer et récupérer sans effacer les données.",
"recover_network": "Réessayer RNS",
"open_settings": "Ouvrir les paramètres",
"open_interfaces": "Ouvrir les interfaces",
"network_recovered": "Pile réseau rétablie",
"network_recover_failed": "Impossible de rétablir la pile réseau. Vérifiez les interfaces et réessayez.",
"network_recovered": "RNS rétabli",
"network_recover_failed": "Impossible de rétablir RNS. Vérifiez l'erreur, puis réessayez ou consultez les paramètres et les interfaces.",
"battery_unavailable": "État de la batterie indisponible",
"battery_level_title": "Batterie {percent}",
"battery_charging_title": "En charge {percent}",

View file

@ -491,12 +491,13 @@
"rpc_key_hide": "Nascondi chiave RPC",
"refresh_community_interfaces": "Aggiorna da directory.rns.recipes",
"refresh_community_interfaces_busy": "Aggiornamento…",
"network_starting": "Connessione alla mesh…",
"network_degraded": "Rete mesh non disponibile. L'app resta attiva così puoi riparare le interfacce senza cancellare i dati.",
"recover_network": "Riprova rete",
"network_starting": "Avvio di RNS…",
"network_degraded": "RNS non disponibile. L'app resta attiva così puoi diagnosticare e recuperare senza cancellare i dati.",
"recover_network": "Riprova RNS",
"open_settings": "Apri impostazioni",
"open_interfaces": "Apri interfacce",
"network_recovered": "Stack di rete ripristinato",
"network_recover_failed": "Impossibile ripristinare lo stack di rete. Controlla le interfacce e riprova.",
"network_recovered": "RNS ripristinato",
"network_recover_failed": "Impossibile ripristinare RNS. Controlla l'errore, poi riprova o rivedi impostazioni e interfacce.",
"battery_unavailable": "Stato batteria non disponibile",
"battery_level_title": "Batteria {percent}",
"battery_charging_title": "In carica {percent}",

View file

@ -491,12 +491,13 @@
"rpc_key_hide": "RPC-sleutel verbergen",
"refresh_community_interfaces": "Vernieuwen vanaf directory.rns.recipes",
"refresh_community_interfaces_busy": "Bezig met vernieuwen…",
"network_starting": "Verbinden met het mesh…",
"network_degraded": "Mesh-netwerk niet beschikbaar. De app blijft draaien zodat je interfaces kunt herstellen zonder gegevens te wissen.",
"recover_network": "Netwerk opnieuw proberen",
"network_starting": "RNS starten…",
"network_degraded": "RNS niet beschikbaar. De app blijft draaien zodat je kunt diagnosticeren en herstellen zonder gegevens te wissen.",
"recover_network": "RNS opnieuw proberen",
"open_settings": "Instellingen openen",
"open_interfaces": "Interfaces openen",
"network_recovered": "Netwerkstack hersteld",
"network_recover_failed": "Netwerkstack kon niet worden hersteld. Controleer interfaces en probeer opnieuw.",
"network_recovered": "RNS hersteld",
"network_recover_failed": "RNS kon niet worden hersteld. Controleer de fout, probeer opnieuw of bekijk instellingen en interfaces.",
"battery_unavailable": "Batterijstatus niet beschikbaar",
"battery_level_title": "Batterij {percent}",
"battery_charging_title": "Opladen {percent}",

View file

@ -491,12 +491,13 @@
"rpc_key_hide": "Скрыть RPC-ключ",
"refresh_community_interfaces": "Обновить из directory.rns.recipes",
"refresh_community_interfaces_busy": "Обновление…",
"network_starting": "Подключение к mesh…",
"network_degraded": "Mesh-сеть недоступна. Приложение продолжает работать, чтобы вы могли исправить интерфейсы без удаления данных.",
"recover_network": "Повторить сеть",
"network_starting": "Запуск RNS…",
"network_degraded": "RNS недоступен. Приложение продолжает работать, чтобы вы могли диагностировать и восстановить работу без удаления данных.",
"recover_network": "Повторить RNS",
"open_settings": "Открыть настройки",
"open_interfaces": "Открыть интерфейсы",
"network_recovered": "Сетевой стек восстановлен",
"network_recover_failed": "Не удалось восстановить сетевой стек. Проверьте интерфейсы и попробуйте снова.",
"network_recovered": "RNS восстановлен",
"network_recover_failed": "Не удалось восстановить RNS. Проверьте сведения об ошибке, затем повторите попытку или проверьте настройки и интерфейсы.",
"battery_unavailable": "Состояние батареи недоступно",
"battery_level_title": "Батарея {percent}",
"battery_charging_title": "Зарядка {percent}",

View file

@ -491,12 +491,13 @@
"rpc_key_hide": "隐藏 RPC 密钥",
"refresh_community_interfaces": "从 directory.rns.recipes 刷新",
"refresh_community_interfaces_busy": "正在刷新…",
"network_starting": "正在连接 mesh…",
"network_degraded": "Mesh 网络不可用。应用仍在运行,便于修复接口而无需清除数据。",
"recover_network": "重试网络",
"network_starting": "正在启动 RNS…",
"network_degraded": "RNS 不可用。应用仍在运行,便于诊断和恢复而无需清除数据。",
"recover_network": "重试 RNS",
"open_settings": "打开设置",
"open_interfaces": "打开接口",
"network_recovered": "网络栈已恢复",
"network_recover_failed": "无法恢复网络栈。请检查接口后重试。",
"network_recovered": "RNS 已恢复",
"network_recover_failed": "无法恢复 RNS。请查看错误详情后重试或检查设置与接口。",
"battery_unavailable": "无法获取电池状态",
"battery_level_title": "电池 {percent}",
"battery_charging_title": "充电中 {percent}",

View file

@ -18,6 +18,7 @@ import { registerCoreContributions } from "./js/registries/registerCoreContribut
import { installWsEventBridge } from "./js/registries/wsEventBridge.js";
import { pluginHost } from "./js/plugins/PluginHost.js";
import GlobalState from "./js/GlobalState.js";
import { recoveryRouteForNetworkError } from "./js/networkRecovery.js";
import "./js/HeapMonitor.js";
registerCoreContributions();
@ -360,7 +361,7 @@ const networkReady = await waitForNetworkReady({
onErrorState: markBootSplashError,
onDegraded: (error) => {
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError = error || "Mesh network unavailable";
GlobalState.networkDegradedError = error || "RNS unavailable";
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
},
@ -491,7 +492,7 @@ if (networkReady) {
onLine: () => {},
onDegraded: (error) => {
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError = error || "Mesh network unavailable";
GlobalState.networkDegradedError = error || "RNS unavailable";
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
},
@ -510,16 +511,18 @@ if (networkReady) {
GlobalState.networkStarting = false;
GlobalState.networkReady = false;
GlobalState.networkDegraded = true;
GlobalState.networkDegradedError =
GlobalState.networkDegradedError || "Mesh network startup timed out";
GlobalState.networkDegradedError = GlobalState.networkDegradedError || "RNS startup timed out";
}
});
}
if (GlobalState.networkDegraded) {
try {
router.replace({ name: "interfaces" });
} catch {
// Route may not exist yet during early boot, but the banner still guides the user.
const recoveryRoute = recoveryRouteForNetworkError(GlobalState.networkDegradedError);
if (recoveryRoute) {
try {
router.replace({ name: recoveryRoute });
} catch {
// Route may not exist yet during early boot, but the banner still guides the user.
}
}
}
}

View file

@ -28,7 +28,7 @@ Options:
--api-level N Android API level for wheel tag (default: 24)
--pycodec2-version V pycodec2 version to build (default: 4.1.1)
--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)
--lxst-version V LXST wheel version for metadata patch (default: 0.5.0)
--bleak-version V bleak pure-python wheel version to vendor (default: 3.0.2)
--rns-version V rns wheel version to patch (default: 1.4.0)
--no-lxst-patch Skip LXST metadata patch
@ -58,7 +58,7 @@ API_LEVEL="24"
PYCODEC2_VERSION="4.1.1"
LIBCODEC2_VERSION="1.2.0"
NUMPY_VERSION="1.26.2"
LXST_VERSION="0.4.8"
LXST_VERSION="0.5.0"
BLEAK_VERSION="3.0.2"
RNS_VERSION="1.4.0"
PATCH_LXST="1"
@ -784,6 +784,9 @@ with zipfile.ZipFile(src, "r") as zin, zipfile.ZipFile(dst, "w", compression=zip
text = data.decode("utf-8")
text = text.replace("Requires-Dist: numpy>=2.3.4", "Requires-Dist: numpy==${NUMPY_VERSION}")
text = text.replace("Requires-Dist: cffi==1.15.1", "Requires-Dist: cffi>=1.15.1")
# LXST 0.5.0 pins cffi>=2.0.0. Chaquopy Android may resolve older
# cffi wheels, so keep the lower bound flexible like prior releases.
text = text.replace("Requires-Dist: cffi>=2.0.0", "Requires-Dist: cffi>=1.15.1")
data = text.encode("utf-8")
zout.writestr(item, data)
PY

View file

@ -0,0 +1,53 @@
# SPDX-License-Identifier: 0BSD
"""Compat for LXST Profiles call modes on Android 0.4.x vs duplex builds."""
from types import SimpleNamespace
from meshchatx.src.backend import lxst_profiles_compat as lxst_modes
def test_legacy_profiles_without_modes_falls_back_to_full_duplex():
legacy = SimpleNamespace(
BANDWIDTH_LOW=0x30,
# No MODE_* or available_modes on Android lxst 0.4.8
)
assert lxst_modes.duplex_modes_supported(legacy) is False
assert lxst_modes.available_modes(legacy) == [lxst_modes.MODE_FULL_DUPLEX]
assert lxst_modes.default_mode(legacy) == lxst_modes.MODE_FULL_DUPLEX
assert lxst_modes.mode_name(lxst_modes.MODE_FULL_DUPLEX, legacy) == "Full Duplex"
assert lxst_modes.mode_abbreviation(lxst_modes.MODE_HALF_DUPLEX, legacy) == "HDX"
def test_native_profiles_modes_are_forwarded():
native = SimpleNamespace(
MODE_FULL_DUPLEX=1,
MODE_HALF_DUPLEX=2,
DEFAULT_MODE=1,
available_modes=lambda: [1, 2],
mode_name=lambda mid: f"mode-{mid}",
mode_abbrevation=lambda mid: f"m{mid}",
)
assert lxst_modes.duplex_modes_supported(native) is True
assert lxst_modes.available_modes(native) == [1, 2]
assert lxst_modes.mode_name(2, native) == "mode-2"
assert lxst_modes.mode_abbreviation(1, native) == "m1"
def test_telephone_manager_resolves_modes_on_legacy_profiles(monkeypatch):
from unittest.mock import MagicMock
from meshchatx.src.backend.telephone_manager import TelephoneManager
legacy = SimpleNamespace()
monkeypatch.setattr(
"meshchatx.src.backend.lxst_profiles_compat.import_profiles",
lambda: legacy,
)
tm = TelephoneManager(identity=MagicMock())
assert tm.resolve_call_mode_id(None) == lxst_modes.MODE_FULL_DUPLEX
assert (
tm.resolve_call_mode_id(lxst_modes.MODE_HALF_DUPLEX)
== lxst_modes.MODE_FULL_DUPLEX
)
assert tm.is_half_duplex() is False

View file

@ -299,6 +299,8 @@ describe("behavior contracts: phased startup and early UI mount", () => {
const banners = readSource("meshchatx/src/frontend/components/layout/AppShellBanners.vue");
expect(banners).toContain("showNetworkStarting");
expect(banners).toContain("networkStartingLabel");
expect(banners).toContain("open-settings");
expect(banners).toContain("open-interfaces");
});
it("backend publishes ui_ready early and defers secondary identity services", () => {

View file

@ -47,10 +47,7 @@
{
"id": "map",
"shell": "meshchatx/src/frontend/components/map/MapPage.vue",
"allowed_child_dirs": [
"meshchatx/src/frontend/components/map/internal",
"meshchatx/src/frontend/js"
],
"allowed_child_dirs": ["meshchatx/src/frontend/components/map/internal", "meshchatx/src/frontend/js"],
"required_children": [
"meshchatx/src/frontend/components/map/internal/MapClusterPanel.vue",
"meshchatx/src/frontend/components/map/internal/MapMarkerPanel.vue",
@ -161,12 +158,7 @@
"meshchatx/src/frontend/components/layout/AppIdentitySwitchOverlay.vue",
"meshchatx/src/frontend/components/call/CallOverlay.vue"
],
"required_shell_imports": [
"AppShellBanners",
"AppIdentitySwitchOverlay",
"CallOverlay",
"listNavItems"
]
"required_shell_imports": ["AppShellBanners", "AppIdentitySwitchOverlay", "CallOverlay", "listNavItems"]
}
]
}

View file

@ -0,0 +1,22 @@
// SPDX-License-Identifier: 0BSD
import { describe, expect, it } from "vitest";
import {
isLikelyInterfaceRecoveryError,
recoveryRouteForNetworkError,
} from "../../meshchatx/src/frontend/js/networkRecovery.js";
describe("networkRecovery", () => {
it("does not treat telephony or generic errors as interface recovery", () => {
expect(isLikelyInterfaceRecoveryError("type object 'Profiles' has no attribute 'available_modes'")).toBe(false);
expect(recoveryRouteForNetworkError("Telephone init failed")).toBeNull();
expect(recoveryRouteForNetworkError("")).toBeNull();
});
it("routes interface-shaped errors to the interfaces page", () => {
expect(isLikelyInterfaceRecoveryError("RNode interface failed to open")).toBe(true);
expect(recoveryRouteForNetworkError("AutoInterface bind error")).toBe("interfaces");
expect(recoveryRouteForNetworkError("I2P interface unavailable")).toBe("interfaces");
});
});