feat: fix Android support with Codec2 fallback, improve RNode flasher UI, and refine WebSocket connection handling

This commit is contained in:
Ivan 2026-07-26 05:44:02 -05:00
parent c604e99895
commit 9ea4efe98a
No known key found for this signature in database
27 changed files with 868 additions and 168 deletions

View file

@ -6,9 +6,10 @@ All notable changes to this project will be documented in this file.
### Fixed
- **Android LXST / Codec2**: Preload libcodec2 after the Chaquopy runtime starts, reload LXST Codec2 bindings when a soft import left Codec2 unset, and probe pycodec2 before mesh imports so Codec2 voice profiles work instead of reporting no codec on device.
- **Android LXST / Codec2**: When the Chaquopy `pycodec2.so` extension is an empty stub, fall back to a ctypes Codec2 binding over the bundled `libcodec2.so` so LXST Codec2 voice profiles work on device. Still preload jniLibs Codec2 and reload soft-imported LXST bindings after probe.
- **Android RNode flasher**: Open native flasher returns a real status, keeps USB-serial classes through R8, uses an ActionBar theme, and surfaces startup failures instead of silently doing nothing. Bluetooth Open settings tries GrapheneOS-friendly fallbacks (app details, Bluetooth settings, general Settings) instead of toasting unavailable.
- **Connection banners**: Do not flash disconnected on startup before the first successful WebSocket open. Debounce disconnect UI for 2.5s and only show reconnected when the disconnect banner was actually shown. Foreground recovery prefers a ping for longer before forcing a reconnect.
- **Android calls**: Clarify that the web audio bridge on Android uses native mic and speaker through the telephone audio bridge, not browser getUserMedia.
- **Android RNode flasher**: Bluetooth Allow and Open settings open the system permission UI when runtime permission is missing or permanently denied. The capabilities banner shows Allow Bluetooth only when needed and otherwise steers users to the native flasher for USB.
- **Browser calls (Docker / HTTPS)**: Refresh Devices calls getUserMedia first so Brave and Chromium show the microphone permission prompt instead of failing early when enumerateDevices lists no inputs before permission is granted.
- **HTTP security headers**: Send Permissions-Policy allowing microphone and camera for this origin so reverse proxies that omit the header do not block capture by default.
- **UI language**: Persist language changes over the config HTTP API (not WebSocket-only), normalize legacy locale codes, and stop the Reticulum manual language picker from overwriting app UI language.

View file

@ -1,5 +1,9 @@
-keep class com.chaquo.python.** { *; }
-keep class com.meshchatx.** { *; }
-keepclassmembers class com.meshchatx.MainActivity$MeshChatXAndroidBridge {
@android.webkit.JavascriptInterface <methods>;
}
-keep class com.hoho.android.usbserial.** { *; }
-keep class org.json.** { *; }
-keep class org.conscrypt.** { *; }
-dontwarn com.chaquo.python.**

View file

@ -95,6 +95,7 @@
android:exported="false"
android:label="@string/rnode_flasher_title"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.MeshChatX.Flasher"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|fontScale|keyboard|keyboardHidden|navigation|uiMode|colorMode|layoutDirection" />
<!--

View file

@ -0,0 +1,103 @@
package com.meshchatx;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.provider.Settings;
import android.util.Log;
import android.widget.Toast;
/**
* Open app / Bluetooth settings with GrapheneOS-friendly fallbacks.
*
* ACTION_APPLICATION_DETAILS_SETTINGS alone can fail on hardened builds. Try
* several intents before surfacing an error to the user.
*/
public final class AppSettingsLauncher {
private static final String TAG = "AppSettingsLauncher";
private AppSettingsLauncher() {
}
public static boolean openAppDetails(Context context) {
if (context == null) {
return false;
}
String packageName = context.getPackageName();
Intent[] candidates =
new Intent[] {
detailsIntent(packageName),
detailsIntentWithSettingsPackage(packageName),
new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.parse("package:" + packageName)),
new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS),
new Intent(Settings.ACTION_SETTINGS),
};
return startFirstAvailable(context, candidates, "Could not open app settings");
}
public static boolean openBluetoothSettings(Context context) {
if (context == null) {
return false;
}
String packageName = context.getPackageName();
Intent[] candidates =
new Intent[] {
new Intent(Settings.ACTION_BLUETOOTH_SETTINGS),
detailsIntent(packageName),
detailsIntentWithSettingsPackage(packageName),
new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS),
new Intent(Settings.ACTION_SETTINGS),
};
return startFirstAvailable(
context,
candidates,
"Could not open Bluetooth or app settings"
);
}
private static Intent detailsIntent(String packageName) {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", packageName, null));
return intent;
}
private static Intent detailsIntentWithSettingsPackage(String packageName) {
Intent intent = detailsIntent(packageName);
intent.setPackage("com.android.settings");
return intent;
}
private static boolean startFirstAvailable(
Context context,
Intent[] candidates,
String failureMessage
) {
Exception last = null;
for (Intent base : candidates) {
if (base == null) {
continue;
}
try {
Intent intent = new Intent(base);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
return true;
} catch (ActivityNotFoundException | SecurityException e) {
last = e;
Log.w(TAG, "Settings intent failed: " + base.getAction() + " " + e.getMessage());
} catch (Exception e) {
last = e;
Log.w(TAG, "Settings intent failed: " + base.getAction() + " " + e.getMessage());
}
}
String detail = last != null && last.getMessage() != null ? last.getMessage() : "";
Toast.makeText(
context,
detail.isEmpty() ? failureMessage : failureMessage + ": " + detail,
Toast.LENGTH_LONG)
.show();
return false;
}
}

View file

@ -646,17 +646,11 @@ public class MainActivity extends AppCompatActivity {
}
void openAppPermissionSettings() {
try {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", getPackageName(), null));
startActivity(intent);
} catch (ActivityNotFoundException ignored) {
try {
startActivity(new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS));
} catch (ActivityNotFoundException ignoredAgain) {
Toast.makeText(this, "App settings unavailable", Toast.LENGTH_SHORT).show();
}
}
AppSettingsLauncher.openAppDetails(this);
}
boolean openBluetoothPermissionSettings() {
return AppSettingsLauncher.openBluetoothSettings(this);
}
private static final String PREF_BT_PERM_PROMPTED_PREFIX = "bt_perm_prompted_";
@ -804,7 +798,7 @@ public class MainActivity extends AppCompatActivity {
}
final boolean ok = granted;
if (!ok && isBluetoothPermanentlyDenied()) {
openAppPermissionSettings();
openBluetoothPermissionSettings();
Toast.makeText(
this,
"Bluetooth blocked. Enable it in app settings.",
@ -1716,7 +1710,7 @@ public class MainActivity extends AppCompatActivity {
}
if (activity.isBluetoothPermanentlyDenied()) {
activity.runOnUiThread(() -> {
activity.openAppPermissionSettings();
activity.openBluetoothPermissionSettings();
Toast.makeText(
activity,
"Bluetooth blocked. Enable it in app settings.",
@ -1765,19 +1759,7 @@ public class MainActivity extends AppCompatActivity {
@JavascriptInterface
public String requestUsbPermissions() {
activity.runOnUiThread(() -> {
try {
activity.startActivity(
new Intent(activity, com.meshchatx.rnode.RNodeFlasherActivity.class)
);
} catch (Exception e) {
Toast.makeText(
activity,
"Could not open native RNode flasher",
Toast.LENGTH_SHORT).show();
}
});
return "requested";
return openRNodeFlasher();
}
@JavascriptInterface
@ -1786,29 +1768,83 @@ public class MainActivity extends AppCompatActivity {
}
@JavascriptInterface
public void openRNodeFlasher() {
activity.runOnUiThread(() -> {
try {
activity.startActivity(
new Intent(activity, com.meshchatx.rnode.RNodeFlasherActivity.class)
);
} catch (Exception e) {
Toast.makeText(
activity,
"Could not open native RNode flasher",
Toast.LENGTH_SHORT).show();
public String openRNodeFlasher() {
try {
final java.util.concurrent.CountDownLatch latch =
new java.util.concurrent.CountDownLatch(1);
final String[] result = new String[] {"error:unknown"};
activity.runOnUiThread(() -> {
try {
Intent intent =
new Intent(activity, com.meshchatx.rnode.RNodeFlasherActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
activity.startActivity(intent);
result[0] = "ok";
} catch (Exception e) {
String msg =
e.getMessage() != null && !e.getMessage().isEmpty()
? e.getMessage()
: "Could not open native RNode flasher";
result[0] = "error:" + msg;
Toast.makeText(activity, msg, Toast.LENGTH_LONG).show();
} finally {
latch.countDown();
}
});
if (!latch.await(3, java.util.concurrent.TimeUnit.SECONDS)) {
return "timeout";
}
});
return result[0];
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
}
}
@JavascriptInterface
public void openBluetoothSettings() {
activity.runOnUiThread(activity::openAppPermissionSettings);
public String openBluetoothSettings() {
try {
final java.util.concurrent.CountDownLatch latch =
new java.util.concurrent.CountDownLatch(1);
final boolean[] launched = new boolean[] {false};
activity.runOnUiThread(() -> {
try {
launched[0] = activity.openBluetoothPermissionSettings();
} finally {
latch.countDown();
}
});
if (!latch.await(2, java.util.concurrent.TimeUnit.SECONDS)) {
return "timeout";
}
return launched[0] ? "ok" : "unavailable";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
}
}
@JavascriptInterface
public void openUsbSettings() {
activity.runOnUiThread(activity::openAppPermissionSettings);
public String openUsbSettings() {
try {
final java.util.concurrent.CountDownLatch latch =
new java.util.concurrent.CountDownLatch(1);
final boolean[] launched = new boolean[] {false};
activity.runOnUiThread(() -> {
try {
launched[0] = AppSettingsLauncher.openAppDetails(activity);
} finally {
latch.countDown();
}
});
if (!latch.await(2, java.util.concurrent.TimeUnit.SECONDS)) {
return "timeout";
}
return launched[0] ? "ok" : "unavailable";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
}
}
@JavascriptInterface

View file

@ -1,7 +1,6 @@
package com.meshchatx.rnode;
import android.Manifest;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
@ -10,7 +9,6 @@ import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.view.View;
import android.webkit.CookieManager;
import android.widget.AdapterView;
@ -26,6 +24,7 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.meshchatx.AppSettingsLauncher;
import com.meshchatx.LocalhostTrustOkHttpClient;
import com.meshchatx.R;
@ -82,63 +81,75 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_rnode_flasher);
if (getSupportActionBar() != null) {
getSupportActionBar().setTitle(R.string.rnode_flasher_title);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
usbSpinner = findViewById(R.id.rnodeUsbSpinner);
productSpinner = findViewById(R.id.rnodeProductSpinner);
modelSpinner = findViewById(R.id.rnodeModelSpinner);
progressBar = findViewById(R.id.rnodeProgress);
statusView = findViewById(R.id.rnodeStatus);
logView = findViewById(R.id.rnodeLog);
flashButton = findViewById(R.id.rnodeFlash);
downloadButton = findViewById(R.id.rnodeDownloadFirmware);
usbAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ports);
usbSpinner.setAdapter(usbAdapter);
try {
catalog = ProductCatalog.load(this);
} catch (Exception e) {
appendLog("Failed to load product catalog: " + e.getMessage());
catalog = ProductCatalog.empty();
}
productAdapter =
new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, catalog.products());
productSpinner.setAdapter(productAdapter);
modelAdapter =
new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, new ArrayList<>());
modelSpinner.setAdapter(modelAdapter);
productSpinner.setOnItemSelectedListener(
new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
refreshModels();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
setContentView(R.layout.activity_rnode_flasher);
if (getSupportActionBar() != null) {
getSupportActionBar().setTitle(R.string.rnode_flasher_title);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
);
findViewById(R.id.rnodeRefreshUsb).setOnClickListener(v -> refreshPorts());
findViewById(R.id.rnodeRequestUsb).setOnClickListener(v -> requestUsbPermission());
findViewById(R.id.rnodeRequestBluetooth).setOnClickListener(v -> requestBluetooth());
findViewById(R.id.rnodeOpenAppSettings).setOnClickListener(v -> openAppSettings());
downloadButton.setOnClickListener(v -> downloadFirmware());
flashButton.setOnClickListener(v -> flashSelected());
usbSpinner = findViewById(R.id.rnodeUsbSpinner);
productSpinner = findViewById(R.id.rnodeProductSpinner);
modelSpinner = findViewById(R.id.rnodeModelSpinner);
progressBar = findViewById(R.id.rnodeProgress);
statusView = findViewById(R.id.rnodeStatus);
logView = findViewById(R.id.rnodeLog);
flashButton = findViewById(R.id.rnodeFlash);
downloadButton = findViewById(R.id.rnodeDownloadFirmware);
usbHub = new UsbSerialHub(this);
usbHub.addListener(this);
usbHub.start();
refreshPorts();
refreshModels();
appendLog("Native RNode flasher ready.");
usbAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ports);
usbSpinner.setAdapter(usbAdapter);
try {
catalog = ProductCatalog.load(this);
} catch (Exception e) {
appendLog("Failed to load product catalog: " + e.getMessage());
catalog = ProductCatalog.empty();
}
productAdapter =
new ArrayAdapter<>(
this, android.R.layout.simple_spinner_dropdown_item, catalog.products());
productSpinner.setAdapter(productAdapter);
modelAdapter =
new ArrayAdapter<>(
this, android.R.layout.simple_spinner_dropdown_item, new ArrayList<>());
modelSpinner.setAdapter(modelAdapter);
productSpinner.setOnItemSelectedListener(
new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(
AdapterView<?> parent, View view, int position, long id) {
refreshModels();
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
}
);
findViewById(R.id.rnodeRefreshUsb).setOnClickListener(v -> refreshPorts());
findViewById(R.id.rnodeRequestUsb).setOnClickListener(v -> requestUsbPermission());
findViewById(R.id.rnodeRequestBluetooth).setOnClickListener(v -> requestBluetooth());
findViewById(R.id.rnodeOpenAppSettings).setOnClickListener(v -> openAppSettings());
downloadButton.setOnClickListener(v -> downloadFirmware());
flashButton.setOnClickListener(v -> flashSelected());
usbHub = new UsbSerialHub(this);
usbHub.addListener(this);
usbHub.start();
refreshPorts();
refreshModels();
appendLog("Native RNode flasher ready.");
} catch (Exception e) {
String msg =
e.getMessage() != null && !e.getMessage().isEmpty()
? e.getMessage()
: "Native RNode flasher failed to start";
Toast.makeText(this, msg, Toast.LENGTH_LONG).show();
finish();
}
}
@Override
@ -197,7 +208,7 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
"Bluetooth blocked. Enable it in app settings.",
Toast.LENGTH_LONG
).show();
openAppSettings();
openBluetoothSettings();
return;
}
Toast.makeText(this, "Bluetooth denied", Toast.LENGTH_SHORT).show();
@ -253,7 +264,7 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
"Bluetooth blocked. Enable it in app settings.",
Toast.LENGTH_LONG
).show();
openAppSettings();
openBluetoothSettings();
return;
}
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putBoolean(PREF_BT_PROMPTED, true).apply();
@ -262,18 +273,11 @@ public final class RNodeFlasherActivity extends AppCompatActivity implements Usb
}
private void openAppSettings() {
try {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", getPackageName(), null));
startActivity(intent);
} catch (ActivityNotFoundException e) {
try {
startActivity(new Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS));
} catch (ActivityNotFoundException ignored) {
Toast.makeText(this, "App settings unavailable", Toast.LENGTH_SHORT).show();
appendLog("App settings unavailable: " + e.getMessage());
}
}
AppSettingsLauncher.openAppDetails(this);
}
private void openBluetoothSettings() {
AppSettingsLauncher.openBluetoothSettings(this);
}
private void refreshPorts() {

View file

@ -12,4 +12,18 @@
<item name="android:windowBackground">@color/meshchat_canvas</item>
<item name="android:colorBackground">@color/meshchat_canvas</item>
</style>
<!-- Native RNode flasher needs an ActionBar for up-navigation. -->
<style name="Theme.MeshChatX.Flasher" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@color/meshchat_canvas</item>
<item name="android:windowBackground">@color/meshchat_canvas</item>
<item name="android:colorBackground">@color/meshchat_canvas</item>
</style>
</resources>

Binary file not shown.

View file

@ -164,12 +164,36 @@ def ensure_codec2_native_library(*, force: bool = False) -> bool:
return False
def _install_pycodec2_ctypes_fallback() -> tuple[bool, str | None]:
"""Install a ctypes pycodec2 stand-in when the Cython extension cannot load.
Some Android vendor wheels ship an empty pycodec2.so (no PyInit). libcodec2.so
is still present. Expose the same Codec2 API LXST expects via ctypes.
"""
try:
from meshchatx import pycodec2_ctypes
ok, err = pycodec2_ctypes.probe()
if not ok:
return False, err
# Drop a broken partial import so LXST picks up the stand-in.
sys.modules.pop("pycodec2", None)
sys.modules["pycodec2"] = pycodec2_ctypes
logger.warning(
"Using ctypes Codec2 fallback (native pycodec2 extension unavailable)"
)
return True, None
except Exception as exc:
return False, str(exc)
def probe_pycodec2() -> tuple[bool, str | None]:
"""Import pycodec2 after preload and report whether Codec2 works."""
if _is_chaquopy_android() and not ensure_codec2_native_library():
# Retry once in case native libs appeared after an early failed attempt.
if not ensure_codec2_native_library(force=True):
return False, codec2_preload_error()
# Still try ctypes against whatever path we can find.
return _install_pycodec2_ctypes_fallback()
try:
import pycodec2
@ -177,7 +201,13 @@ def probe_pycodec2() -> tuple[bool, str | None]:
_ = c2.samples_per_frame()
return True, None
except Exception as exc:
return False, str(exc)
native_error = str(exc)
if _is_chaquopy_android():
ok, fallback_error = _install_pycodec2_ctypes_fallback()
if ok:
return True, None
return False, fallback_error or native_error
return False, native_error
def ensure_lxst_codec2_binding() -> bool:

View file

@ -0,0 +1,213 @@
# SPDX-License-Identifier: 0BSD
"""ctypes-backed pycodec2-compatible Codec2 for Android when the extension is broken.
Chaquopy vendor wheels have shipped an empty pycodec2.so (no PyInit). libcodec2.so
is valid and preloaded. This module exposes the subset of the pycodec2 API that
LXST.Codecs.Codec2 and MeshChatX probes need.
"""
from __future__ import annotations
import ctypes
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
# codec2.h mode constants (bitrate arg maps through this table)
_MODES = {
700: 8, # CODEC2_MODE_700C
1200: 5,
1300: 4,
1400: 3,
1600: 2,
2400: 1,
3200: 0,
}
_lib: ctypes.CDLL | None = None
_lib_error: str | None = None
class _CODEC2(ctypes.Structure):
pass
_CODEC2_p = ctypes.POINTER(_CODEC2)
def _cdll_load(path_or_name: str) -> ctypes.CDLL:
mode = getattr(ctypes, "RTLD_GLOBAL", None)
if mode is None:
return ctypes.CDLL(path_or_name)
return ctypes.CDLL(path_or_name, mode=mode)
def _candidate_lib_paths() -> list[str]:
paths: list[str] = []
seen: set[str] = set()
def add(value: str) -> None:
if value and value not in seen:
seen.add(value)
paths.append(value)
explicit = os.environ.get("MESHCHAT_LIBCODEC2_PATH", "") or ""
if explicit:
add(explicit)
native_dir = os.environ.get("MESHCHAT_NATIVE_LIB_DIR", "") or ""
if native_dir:
add(str(Path(native_dir) / "libcodec2.so"))
for entry in list(__import__("sys").path):
if not entry:
continue
root = Path(entry)
add(str(root / "pycodec2" / "libcodec2.so"))
add(str(root / "chaquopy" / "lib" / "libcodec2.so"))
add(str(root / "libcodec2.so"))
add("libcodec2.so")
return paths
def _bind(lib: ctypes.CDLL) -> None:
lib.codec2_create.argtypes = [ctypes.c_int]
lib.codec2_create.restype = _CODEC2_p
lib.codec2_destroy.argtypes = [_CODEC2_p]
lib.codec2_destroy.restype = None
lib.codec2_encode.argtypes = [
_CODEC2_p,
ctypes.POINTER(ctypes.c_uint8),
ctypes.POINTER(ctypes.c_short),
]
lib.codec2_encode.restype = None
lib.codec2_decode.argtypes = [
_CODEC2_p,
ctypes.POINTER(ctypes.c_short),
ctypes.POINTER(ctypes.c_uint8),
]
lib.codec2_decode.restype = None
lib.codec2_samples_per_frame.argtypes = [_CODEC2_p]
lib.codec2_samples_per_frame.restype = ctypes.c_int
lib.codec2_bits_per_frame.argtypes = [_CODEC2_p]
lib.codec2_bits_per_frame.restype = ctypes.c_int
lib.codec2_bytes_per_frame.argtypes = [_CODEC2_p]
lib.codec2_bytes_per_frame.restype = ctypes.c_int
def load_libcodec2(*, force: bool = False) -> ctypes.CDLL:
"""Load and bind libcodec2 for ctypes Codec2 wrappers."""
global _lib, _lib_error
if _lib is not None and not force:
return _lib
last_error: str | None = None
for candidate in _candidate_lib_paths():
try:
if candidate != "libcodec2.so" and not Path(candidate).is_file():
continue
lib = _cdll_load(candidate)
_bind(lib)
# Touch create/destroy so we fail early on a stub library.
state = lib.codec2_create(_MODES[1600])
if not state:
raise OSError("codec2_create returned NULL")
lib.codec2_destroy(state)
_lib = lib
_lib_error = None
logger.info("pycodec2_ctypes loaded libcodec2 from %s", candidate)
return lib
except Exception as exc:
last_error = f"{candidate}: {exc}"
_lib = None
_lib_error = last_error or "libcodec2.so not found"
raise OSError(_lib_error)
def libcodec2_load_error() -> str | None:
return _lib_error
class Codec2:
"""Minimal pycodec2.Codec2 stand-in used by LXST."""
def __init__(self, mode: int):
if mode not in _MODES:
raise ValueError(f"Unsupported Codec2 mode: {mode}")
lib = load_libcodec2()
self._lib = lib
self._state = lib.codec2_create(_MODES[mode])
if not self._state:
raise MemoryError("codec2_create failed")
def __del__(self):
state = getattr(self, "_state", None)
lib = getattr(self, "_lib", None)
if state and lib is not None:
try:
lib.codec2_destroy(state)
except Exception:
pass
self._state = None
def samples_per_frame(self) -> int:
return int(self._lib.codec2_samples_per_frame(self._state))
def bits_per_frame(self) -> int:
return int(self._lib.codec2_bits_per_frame(self._state))
def bytes_per_frame(self) -> int:
return int(self._lib.codec2_bytes_per_frame(self._state))
def encode(self, speech_in):
import numpy as np
samples = np.ascontiguousarray(speech_in, dtype=np.int16)
spf = self.samples_per_frame()
if samples.size == 0 or samples.size % spf != 0:
raise AssertionError(
"encode input length must be a multiple of samples_per_frame"
)
frames = samples.size // spf
bpf = self.bytes_per_frame()
out = (ctypes.c_uint8 * (frames * bpf))()
for index in range(frames):
frame_speech = (ctypes.c_short * spf).from_buffer(samples, index * spf * 2)
frame_bits = ctypes.cast(
ctypes.addressof(out) + index * bpf,
ctypes.POINTER(ctypes.c_uint8),
)
self._lib.codec2_encode(self._state, frame_bits, frame_speech)
return bytes(out)
def decode(self, frames: bytes):
import numpy as np
raw = bytes(frames)
bpf = self.bytes_per_frame()
if len(raw) < bpf:
raise AssertionError("decode input shorter than bytes_per_frame")
frame_count = len(raw) // bpf
spf = self.samples_per_frame()
speech = np.empty(frame_count * spf, dtype=np.int16)
for index in range(frame_count):
chunk = raw[index * bpf : (index + 1) * bpf]
bit_buf = (ctypes.c_uint8 * bpf).from_buffer_copy(chunk)
frame_speech = (ctypes.c_short * spf).from_buffer(speech, index * spf * 2)
self._lib.codec2_decode(
self._state,
frame_speech,
ctypes.cast(bit_buf, ctypes.POINTER(ctypes.c_uint8)),
)
return speech
def probe() -> tuple[bool, str | None]:
"""Return whether ctypes Codec2 can construct and report frame sizes."""
try:
c2 = Codec2(1600)
_ = c2.samples_per_frame()
_ = c2.bytes_per_frame()
return True, None
except Exception as exc:
return False, str(exc)

View file

@ -726,6 +726,8 @@ export default {
wsDisconnectedDurationText: "",
wsReconnectedBanner: false,
wsDisconnectTickTimer: null,
wsDisconnectGraceTimer: null,
wsDisconnectBannerShown: false,
wsReconnectedHideTimer: null,
backendProcessExited: false,
backendExitCode: null,
@ -1194,6 +1196,7 @@ export default {
this.wsDisconnected = false;
this.wsDisconnectedAt = null;
this.wsDisconnectedDurationText = "";
this.wsDisconnectBannerShown = false;
this.wsReconnectedBanner = false;
this.backendProcessExited = false;
this.backendExitCode = null;
@ -1205,6 +1208,10 @@ export default {
clearInterval(this.wsDisconnectTickTimer);
this.wsDisconnectTickTimer = null;
}
if (this.wsDisconnectGraceTimer != null) {
clearTimeout(this.wsDisconnectGraceTimer);
this.wsDisconnectGraceTimer = null;
}
if (this.wsReconnectedHideTimer != null) {
clearTimeout(this.wsReconnectedHideTimer);
this.wsReconnectedHideTimer = null;
@ -1216,7 +1223,8 @@ export default {
}
this.backendProcessExited = true;
this.backendExitCode = payload?.code ?? null;
this.onWsShellDisconnected();
// Process exit is serious: show disconnect immediately.
this._showWsDisconnectedBannerNow();
},
async onRestartBackend() {
if (!window.electron?.restartBackend) {
@ -1280,18 +1288,41 @@ export default {
ToastUtils.error(this.$t("app.view_backend_logs_failed"));
}
},
onWsShellDisconnected() {
_showWsDisconnectedBannerNow() {
if (!this.shellRunning) {
return;
}
if (this.wsDisconnectGraceTimer != null) {
clearTimeout(this.wsDisconnectGraceTimer);
this.wsDisconnectGraceTimer = null;
}
this.wsDisconnected = true;
this.wsDisconnectedAt = Date.now();
this.wsDisconnectBannerShown = true;
this.wsDisconnectedAt = this.wsDisconnectedAt || Date.now();
this._tickWsDisconnectedLabel();
if (this.wsDisconnectTickTimer != null) {
clearInterval(this.wsDisconnectTickTimer);
}
this.wsDisconnectTickTimer = setInterval(() => this._tickWsDisconnectedLabel(), 1000);
},
onWsShellDisconnected() {
if (!this.shellRunning) {
return;
}
// Ignore brief reconnect blips (startup, Android resume). Only scare
// the user if the socket stays down past the grace window.
if (this.wsDisconnected) {
return;
}
if (this.wsDisconnectGraceTimer != null) {
return;
}
this.wsDisconnectedAt = Date.now();
this.wsDisconnectGraceTimer = setTimeout(() => {
this.wsDisconnectGraceTimer = null;
this._showWsDisconnectedBannerNow();
}, 2500);
},
_tickWsDisconnectedLabel() {
if (!this.wsDisconnectedAt) {
this.wsDisconnectedDurationText = "";
@ -1303,9 +1334,15 @@ export default {
if (!this.shellRunning) {
return;
}
const sawDisconnectBanner = this.wsDisconnectBannerShown;
if (this.wsDisconnectGraceTimer != null) {
clearTimeout(this.wsDisconnectGraceTimer);
this.wsDisconnectGraceTimer = null;
}
this.wsDisconnected = false;
this.wsDisconnectedAt = null;
this.wsDisconnectedDurationText = "";
this.wsDisconnectBannerShown = false;
this.backendProcessExited = false;
this.backendExitCode = null;
if (this.wsDisconnectTickTimer != null) {
@ -1315,14 +1352,17 @@ export default {
const isReconnect = payload.isReconnect === true;
if (isReconnect) {
await this.resyncShellAfterWebsocketReconnect();
this.wsReconnectedBanner = true;
if (this.wsReconnectedHideTimer != null) {
clearTimeout(this.wsReconnectedHideTimer);
// Only celebrate when the user actually saw a disconnect banner.
if (sawDisconnectBanner) {
this.wsReconnectedBanner = true;
if (this.wsReconnectedHideTimer != null) {
clearTimeout(this.wsReconnectedHideTimer);
}
this.wsReconnectedHideTimer = setTimeout(() => {
this.wsReconnectedBanner = false;
this.wsReconnectedHideTimer = null;
}, 4500);
}
this.wsReconnectedHideTimer = setTimeout(() => {
this.wsReconnectedBanner = false;
this.wsReconnectedHideTimer = null;
}, 4500);
}
},
async resyncShellAfterWebsocketReconnect() {

View file

@ -303,6 +303,8 @@ export default {
if (action === "open-native-flasher" || action === "request-usb") {
if (this.androidBridge.openRNodeFlasher()) {
ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.opened_native"));
} else {
ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.open_native_failed"));
}
return;
}
@ -324,7 +326,7 @@ export default {
if (this.androidBridge.openBluetoothSettings()) {
ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.bluetooth_open_settings"));
} else {
ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.bluetooth_unsupported"));
ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.bluetooth_settings_unavailable"));
}
return;
}

View file

@ -6,6 +6,9 @@ const PONG_TIMEOUT_MS = 12000;
const BASE_RECONNECT_MS = 1000;
const MAX_RECONNECT_MS = 60000;
const JITTER_MAX_MS = 400;
// Foreground recovery: prefer a ping for longer before tearing down a still-OPEN socket.
// Android WebViews often idle past one ping interval while backgrounded without a dead link.
const FOREGROUND_FORCE_RECONNECT_IDLE_MS = 90000;
class WebSocketConnection {
constructor() {
@ -176,9 +179,27 @@ class WebSocketConnection {
this._isForcedReconnect = false;
return;
}
if (this._hadSuccessfulOpen) {
this._pendingReconnectUi = true;
// Startup races (backend still binding) must not flash a disconnect banner.
if (!this._hadSuccessfulOpen) {
const delay = reconnectDelayWithJitterMs(
this._reconnectAttempt,
BASE_RECONNECT_MS,
MAX_RECONNECT_MS,
JITTER_MAX_MS
);
this._reconnectAttempt += 1;
if (this._reconnectTimeout != null) {
clearTimeout(this._reconnectTimeout);
}
this._reconnectTimeout = setTimeout(() => {
this._reconnectTimeout = null;
if (!this.destroyed) {
this.reconnect();
}
}, delay);
return;
}
this._pendingReconnectUi = true;
this.emit("disconnected");
const delay = reconnectDelayWithJitterMs(
this._reconnectAttempt,
@ -235,7 +256,7 @@ class WebSocketConnection {
}
const idleTime = Date.now() - this._lastReceivedTime;
if (idleTime > PING_INTERVAL_MS) {
if (idleTime > FOREGROUND_FORCE_RECONNECT_IDLE_MS) {
this.forceReconnect();
} else {
this._sendAppPing();
@ -247,8 +268,9 @@ class WebSocketConnection {
return;
}
if (this.ws) {
// Suppress the disconnect banner, but still tell the shell this is a
// reconnect so CSRF/config/status resync after background-tab stalls.
// Suppress the disconnect banner. Still mark reconnect so CSRF/config
// resync after background-tab stalls, but App only celebrates if the
// disconnect banner was actually shown.
if (this._hadSuccessfulOpen) {
this._pendingReconnectUi = true;
}

View file

@ -105,7 +105,10 @@ export default class AndroidBridge {
return false;
}
return safeCall(() => {
this.bridge.openRNodeFlasher();
const result = this.bridge.openRNodeFlasher();
if (typeof result === "string") {
return result === "ok" || result === "requested";
}
return true;
}, false);
}
@ -115,7 +118,10 @@ export default class AndroidBridge {
return false;
}
return safeCall(() => {
this.bridge.openBluetoothSettings();
const result = this.bridge.openBluetoothSettings();
if (typeof result === "string") {
return result === "ok" || result === "settings";
}
return true;
}, false);
}
@ -125,7 +131,10 @@ export default class AndroidBridge {
return false;
}
return safeCall(() => {
this.bridge.openUsbSettings();
const result = this.bridge.openUsbSettings();
if (typeof result === "string") {
return result === "ok" || result === "settings";
}
return true;
}, false);
}

View file

@ -2581,14 +2581,16 @@
"actions": {
"load_polyfill": "Polyfill laden",
"request_bluetooth": "Bluetooth erlauben",
"open_settings": "Einstellungen öffnen",
"polyfill_loading": "USB-Serial-Polyfill wird geladen...",
"bluetooth_requested": "Bluetooth-Berechtigung angefordert.",
"request_usb": "USB erlauben",
"open_native": "Nativen Flasher öffnen",
"open_settings": "Einstellungen öffnen",
"polyfill_loading": "USB-Serial-Polyfill wird geladen...",
"opened_native": "Nativer RNode-Flasher geöffnet.",
"open_native_failed": "Nativer RNode-Flasher konnte nicht geöffnet werden.",
"bluetooth_requested": "Bluetooth-Berechtigung angefordert.",
"bluetooth_already_granted": "Bluetooth-Berechtigung ist bereits erteilt.",
"bluetooth_open_settings": "Öffnen Sie die App-Einstellungen und aktivieren Sie Bluetooth-Berechtigungen.",
"bluetooth_settings_unavailable": "Bluetooth- oder App-Einstellungen konnten auf diesem Gerät nicht geöffnet werden.",
"bluetooth_unsupported": "Anfrage der Bluetooth-Berechtigung ist nicht verfügbar.",
"bluetooth_granted": "Bluetooth-Berechtigung erteilt.",
"bluetooth_denied": "Bluetooth-Berechtigung verweigert.",

View file

@ -2796,9 +2796,11 @@
"open_settings": "Open settings",
"polyfill_loading": "Loading USB serial polyfill...",
"opened_native": "Opened the native RNode flasher.",
"open_native_failed": "Could not open the native RNode flasher.",
"bluetooth_requested": "Bluetooth permission requested.",
"bluetooth_already_granted": "Bluetooth permission is already granted.",
"bluetooth_open_settings": "Open app settings and enable Bluetooth permissions.",
"bluetooth_settings_unavailable": "Could not open Bluetooth or app settings on this device.",
"bluetooth_unsupported": "Bluetooth permission request is unavailable.",
"bluetooth_granted": "Bluetooth permission granted.",
"bluetooth_denied": "Bluetooth permission denied.",

View file

@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "Cargar polyfill",
"request_bluetooth": "Permitir Bluetooth",
"open_settings": "Abrir configuración",
"polyfill_loading": "Cargando polyfill de USB serial...",
"bluetooth_requested": "Permiso de Bluetooth solicitado.",
"request_usb": "Permitir USB",
"open_native": "Abrir flasher nativo",
"open_settings": "Abrir configuración",
"polyfill_loading": "Cargando polyfill de USB serial...",
"opened_native": "Se abrió el flasher nativo de RNode.",
"open_native_failed": "No se pudo abrir el flasher nativo de RNode.",
"bluetooth_requested": "Permiso de Bluetooth solicitado.",
"bluetooth_already_granted": "El permiso de Bluetooth ya está concedido.",
"bluetooth_open_settings": "Abre la configuración de la app y activa los permisos de Bluetooth.",
"bluetooth_settings_unavailable": "No se pudo abrir la configuración de Bluetooth o de la app en este dispositivo.",
"bluetooth_unsupported": "La solicitud de permiso de Bluetooth no está disponible.",
"bluetooth_granted": "Permiso de Bluetooth concedido.",
"bluetooth_denied": "Permiso de Bluetooth denegado.",

View file

@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "Lataa polyfill",
"request_bluetooth": "Salli Bluetooth",
"open_settings": "Avaa asetukset",
"polyfill_loading": "Ladataan USB-sarjaportin polyfilliä...",
"bluetooth_requested": "Bluetooth-lupaa pyydetty.",
"request_usb": "Salli USB",
"open_native": "Avaa natiivi flasher",
"open_settings": "Avaa asetukset",
"polyfill_loading": "Ladataan USB-sarjaportin polyfilliä...",
"opened_native": "Natiivi RNode-flasher avattu.",
"open_native_failed": "Natiivia RNode-flasheria ei voitu avata.",
"bluetooth_requested": "Bluetooth-lupaa pyydetty.",
"bluetooth_already_granted": "Bluetooth-lupa on jo myönnetty.",
"bluetooth_open_settings": "Avaa sovelluksen asetukset ja ota Bluetooth-luvat käyttöön.",
"bluetooth_settings_unavailable": "Bluetooth- tai sovellusasetuksia ei voitu avata tällä laitteella.",
"bluetooth_unsupported": "Bluetooth-lupapyyntö ei ole käytettävissä.",
"bluetooth_granted": "Bluetooth-lupa myönnetty.",
"bluetooth_denied": "Bluetooth-lupa evätty.",

View file

@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "Charger le polyfill",
"request_bluetooth": "Autoriser le Bluetooth",
"open_settings": "Ouvrir les paramètres",
"polyfill_loading": "Chargement du polyfill USB série...",
"bluetooth_requested": "Permission Bluetooth demandée.",
"request_usb": "Autoriser l'USB",
"open_native": "Ouvrir le flasher natif",
"open_settings": "Ouvrir les paramètres",
"polyfill_loading": "Chargement du polyfill USB série...",
"opened_native": "Flasher RNode natif ouvert.",
"open_native_failed": "Impossible d'ouvrir le flasher RNode natif.",
"bluetooth_requested": "Permission Bluetooth demandée.",
"bluetooth_already_granted": "La permission Bluetooth est déjà accordée.",
"bluetooth_open_settings": "Ouvrez les paramètres de l'application et activez les permissions Bluetooth.",
"bluetooth_settings_unavailable": "Impossible d'ouvrir les paramètres Bluetooth ou de l'application sur cet appareil.",
"bluetooth_unsupported": "La demande de permission Bluetooth est indisponible.",
"bluetooth_granted": "Permission Bluetooth accordée.",
"bluetooth_denied": "Permission Bluetooth refusée.",

View file

@ -2839,14 +2839,16 @@
"actions": {
"load_polyfill": "Carica polyfill",
"request_bluetooth": "Consenti Bluetooth",
"open_settings": "Apri impostazioni",
"polyfill_loading": "Caricamento polyfill USB seriale...",
"bluetooth_requested": "Autorizzazione Bluetooth richiesta.",
"request_usb": "Consenti USB",
"open_native": "Apri flasher nativo",
"open_settings": "Apri impostazioni",
"polyfill_loading": "Caricamento polyfill USB seriale...",
"opened_native": "Flasher nativo RNode aperto.",
"open_native_failed": "Impossibile aprire il flasher nativo RNode.",
"bluetooth_requested": "Autorizzazione Bluetooth richiesta.",
"bluetooth_already_granted": "L'autorizzazione Bluetooth è già concessa.",
"bluetooth_open_settings": "Apri le impostazioni dell'app e abilita i permessi Bluetooth.",
"bluetooth_settings_unavailable": "Impossibile aprire le impostazioni Bluetooth o dell'app su questo dispositivo.",
"bluetooth_unsupported": "La richiesta di autorizzazione Bluetooth non è disponibile.",
"bluetooth_granted": "Autorizzazione Bluetooth concessa.",
"bluetooth_denied": "Autorizzazione Bluetooth negata.",

View file

@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "Polyfill laden",
"request_bluetooth": "Bluetooth toestaan",
"open_settings": "Instellingen openen",
"polyfill_loading": "USB-serieel polyfill laden...",
"bluetooth_requested": "Bluetooth-toestemming aangevraagd.",
"request_usb": "USB toestaan",
"open_native": "Native flasher openen",
"open_settings": "Instellingen openen",
"polyfill_loading": "USB-serieel polyfill laden...",
"opened_native": "Native RNode-flasher geopend.",
"open_native_failed": "Kon de native RNode-flasher niet openen.",
"bluetooth_requested": "Bluetooth-toestemming aangevraagd.",
"bluetooth_already_granted": "Bluetooth-toestemming is al verleend.",
"bluetooth_open_settings": "Open de app-instellingen en schakel Bluetooth-machtigingen in.",
"bluetooth_settings_unavailable": "Kon Bluetooth- of app-instellingen op dit apparaat niet openen.",
"bluetooth_unsupported": "Bluetooth-toestemmingsverzoek is niet beschikbaar.",
"bluetooth_granted": "Bluetooth-toestemming verleend.",
"bluetooth_denied": "Bluetooth-toestemming geweigerd.",

View file

@ -2581,14 +2581,16 @@
"actions": {
"load_polyfill": "Загрузить полифилл",
"request_bluetooth": "Разрешить Bluetooth",
"open_settings": "Открыть настройки",
"polyfill_loading": "Загрузка полифилла USB-последовательного порта...",
"bluetooth_requested": "Запрошено разрешение Bluetooth.",
"request_usb": "Разрешить USB",
"open_native": "Открыть встроенный прошивальщик",
"open_settings": "Открыть настройки",
"polyfill_loading": "Загрузка полифилла USB-последовательного порта...",
"opened_native": "Встроенный прошивальщик RNode открыт.",
"open_native_failed": "Не удалось открыть встроенный прошивальщик RNode.",
"bluetooth_requested": "Запрошено разрешение Bluetooth.",
"bluetooth_already_granted": "Разрешение Bluetooth уже выдано.",
"bluetooth_open_settings": "Откройте настройки приложения и включите разрешения Bluetooth.",
"bluetooth_settings_unavailable": "Не удалось открыть настройки Bluetooth или приложения на этом устройстве.",
"bluetooth_unsupported": "Запрос разрешения Bluetooth недоступен.",
"bluetooth_granted": "Разрешение Bluetooth выдано.",
"bluetooth_denied": "В разрешении Bluetooth отказано.",

View file

@ -2787,14 +2787,16 @@
"actions": {
"load_polyfill": "加载 polyfill",
"request_bluetooth": "允许蓝牙",
"open_settings": "打开设置",
"polyfill_loading": "正在加载 USB 串口 polyfill...",
"bluetooth_requested": "已请求蓝牙权限。",
"request_usb": "允许 USB",
"open_native": "打开原生刷写工具",
"open_settings": "打开设置",
"polyfill_loading": "正在加载 USB 串口 polyfill...",
"opened_native": "已打开原生 RNode 刷写工具。",
"open_native_failed": "无法打开原生 RNode 刷写工具。",
"bluetooth_requested": "已请求蓝牙权限。",
"bluetooth_already_granted": "已授予蓝牙权限。",
"bluetooth_open_settings": "打开应用设置并启用蓝牙权限。",
"bluetooth_settings_unavailable": "无法在此设备上打开蓝牙或应用设置。",
"bluetooth_unsupported": "无法请求蓝牙权限。",
"bluetooth_granted": "已授予蓝牙权限。",
"bluetooth_denied": "蓝牙权限被拒绝。",

View file

@ -139,6 +139,37 @@ def test_ensure_lxst_codec2_binding_reloads_when_codec2_none():
assert reload_mock.called
def test_probe_falls_back_to_ctypes_on_android_when_pycodec2_broken():
android_codec2.reset_codec2_preload_state_for_tests()
with (
patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
patch.object(android_codec2, "ensure_codec2_native_library", return_value=True),
patch.dict("sys.modules", {"pycodec2": None}),
):
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "pycodec2":
raise ImportError("empty pycodec2.so")
return real_import(name, *args, **kwargs)
with (
patch("builtins.__import__", side_effect=fake_import),
patch.object(
android_codec2,
"_install_pycodec2_ctypes_fallback",
return_value=(True, None),
) as install,
):
ok, err = android_codec2.probe_pycodec2()
assert ok is True
assert err is None
install.assert_called_once()
def test_vendor_wheels_bundle_libcodec2_for_all_abis():
import zipfile
@ -154,6 +185,12 @@ def test_vendor_wheels_bundle_libcodec2_for_all_abis():
with zipfile.ZipFile(wheels[-1]) as zin:
assert "pycodec2/libcodec2.so" in zin.namelist()
assert "pycodec2/pycodec2.so" in zin.namelist()
# Empty stub extensions (a few KB, no PyInit) cannot provide Codec2.
# ctypes fallback covers those builds until wheels are rebuilt.
stub_size = zin.getinfo("pycodec2/pycodec2.so").file_size
assert stub_size > 0
lib_size = zin.getinfo("pycodec2/libcodec2.so").file_size
assert lib_size > 100_000
lib_wheels = sorted(vendor.glob(f"chaquopy_libcodec2-*-android_24_{abi}.whl"))
if not lib_wheels:
pytest.skip(f"missing chaquopy_libcodec2 for {abi}")
@ -229,3 +266,50 @@ def test_repack_script_bundles_libcodec2(tmp_path):
size_field = line.rsplit(",", 1)[-1]
assert size_field
int(size_field)
def test_pycodec2_ctypes_roundtrip_with_system_or_jni_lib():
"""Ctypes Codec2 must encode/decode when libcodec2 is available."""
import ctypes.util
from meshchatx import pycodec2_ctypes
repo = Path(__file__).resolve().parents[2]
jni_lib = (
repo
/ "android"
/ "app"
/ "src"
/ "main"
/ "jniLibs"
/ "arm64-v8a"
/ "libcodec2.so"
)
system_lib = ctypes.util.find_library("codec2")
if system_lib:
lib_path = system_lib
elif jni_lib.is_file():
# Android aarch64 .so will not load on x86_64 hosts.
pytest.skip("host cannot load Android arm64 libcodec2.so")
else:
pytest.skip("libcodec2 not available on host")
pycodec2_ctypes._lib = None
pycodec2_ctypes._lib_error = None
with patch.dict("os.environ", {"MESHCHAT_LIBCODEC2_PATH": str(lib_path)}):
ok, err = pycodec2_ctypes.probe()
if not ok:
pytest.skip(f"ctypes Codec2 probe failed: {err}")
import numpy as np
c2 = pycodec2_ctypes.Codec2(1600)
spf = c2.samples_per_frame()
samples = np.zeros(spf, dtype=np.int16)
encoded = c2.encode(samples)
assert isinstance(encoded, (bytes, bytearray))
assert len(encoded) == c2.bytes_per_frame()
decoded = c2.decode(encoded)
assert decoded.dtype == np.int16
assert decoded.size == spf
pycodec2_ctypes._lib = None
pycodec2_ctypes._lib_error = None

View file

@ -20,6 +20,7 @@ describe("App websocket reconnect shell resync", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
function makeShellCtx(overrides = {}) {
@ -28,6 +29,8 @@ describe("App websocket reconnect shell resync", () => {
wsDisconnected: true,
wsDisconnectedAt: Date.now() - 5000,
wsDisconnectedDurationText: "5s",
wsDisconnectBannerShown: true,
wsDisconnectGraceTimer: null,
backendProcessExited: false,
backendExitCode: null,
wsDisconnectTickTimer: null,
@ -42,6 +45,9 @@ describe("App websocket reconnect shell resync", () => {
updatePropagationNodeStatus: vi.fn(async () => {}),
resyncShellAfterWebsocketReconnect: App.methods.resyncShellAfterWebsocketReconnect,
onWsShellConnected: App.methods.onWsShellConnected,
onWsShellDisconnected: App.methods.onWsShellDisconnected,
_showWsDisconnectedBannerNow: App.methods._showWsDisconnectedBannerNow,
_tickWsDisconnectedLabel: App.methods._tickWsDisconnectedLabel,
...overrides,
};
}
@ -64,9 +70,52 @@ describe("App websocket reconnect shell resync", () => {
emitSpy.mockRestore();
});
it("resyncs silently after a brief reconnect without celebrating", async () => {
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
const ctx = makeShellCtx({
wsDisconnected: false,
wsDisconnectedAt: null,
wsDisconnectBannerShown: false,
});
await App.methods.onWsShellConnected.call(ctx, { isReconnect: true });
expect(fetchCsrfToken).toHaveBeenCalledTimes(1);
expect(ctx.wsReconnectedBanner).toBe(false);
expect(emitSpy).toHaveBeenCalledWith("websocket-reconnected");
emitSpy.mockRestore();
});
it("does not show disconnect banner during the grace window", async () => {
vi.useFakeTimers();
const ctx = makeShellCtx({
wsDisconnected: false,
wsDisconnectedAt: null,
wsDisconnectBannerShown: false,
wsDisconnectGraceTimer: null,
wsDisconnectTickTimer: null,
});
App.methods.onWsShellDisconnected.call(ctx);
expect(ctx.wsDisconnected).toBe(false);
expect(ctx.wsDisconnectGraceTimer).not.toBeNull();
await vi.advanceTimersByTimeAsync(2499);
expect(ctx.wsDisconnected).toBe(false);
await vi.advanceTimersByTimeAsync(2);
expect(ctx.wsDisconnected).toBe(true);
expect(ctx.wsDisconnectBannerShown).toBe(true);
});
it("does not resync shell on the first websocket connect", async () => {
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
const ctx = makeShellCtx({ wsDisconnected: false, wsDisconnectedAt: null });
const ctx = makeShellCtx({
wsDisconnected: false,
wsDisconnectedAt: null,
wsDisconnectBannerShown: false,
});
await App.methods.onWsShellConnected.call(ctx, { isReconnect: false });

View file

@ -42,23 +42,42 @@ describe("AndroidBridge", () => {
});
it("hasNativeRNodeFlasher delegates to the bridge", () => {
const bridge = { hasNativeRNodeFlasher: vi.fn().mockReturnValue(true), openRNodeFlasher: vi.fn() };
const bridge = {
hasNativeRNodeFlasher: vi.fn().mockReturnValue(true),
openRNodeFlasher: vi.fn().mockReturnValue("ok"),
};
const ab = new AndroidBridge(bridge, {});
expect(ab.hasNativeRNodeFlasher()).toBe(true);
expect(ab.openRNodeFlasher()).toBe(true);
expect(bridge.openRNodeFlasher).toHaveBeenCalled();
});
it("openRNodeFlasher treats error status as failure", () => {
const bridge = { openRNodeFlasher: vi.fn().mockReturnValue("error:boom") };
const ab = new AndroidBridge(bridge, {});
expect(ab.openRNodeFlasher()).toBe(false);
});
it("settings helpers return true when bridge accepts the call", () => {
const bridge = {
openBluetoothSettings: vi.fn(),
openUsbSettings: vi.fn(),
openBluetoothSettings: vi.fn().mockReturnValue("ok"),
openUsbSettings: vi.fn().mockReturnValue("ok"),
};
const ab = new AndroidBridge(bridge, {});
expect(ab.openBluetoothSettings()).toBe(true);
expect(ab.openUsbSettings()).toBe(true);
});
it("settings helpers treat unavailable status as failure", () => {
const bridge = {
openBluetoothSettings: vi.fn().mockReturnValue("unavailable"),
openUsbSettings: vi.fn().mockReturnValue("unavailable"),
};
const ab = new AndroidBridge(bridge, {});
expect(ab.openBluetoothSettings()).toBe(false);
expect(ab.openUsbSettings()).toBe(false);
});
it("settings helpers swallow exceptions and return false", () => {
const bridge = {
openBluetoothSettings: () => {

View file

@ -177,7 +177,7 @@ describe("WebSocketConnection module", () => {
expect(WebSocketConnection.ws).toBe(firstWs);
// 2. If idleTime is large, it should force a reconnect
WebSocketConnection._lastReceivedTime = Date.now() - 60000;
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
WebSocketConnection.handleForegroundOrNetworkChange();
// Wait for the new WebSocket to be created and opened
@ -225,7 +225,7 @@ describe("WebSocketConnection module", () => {
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
const firstWs = WebSocketConnection.ws;
WebSocketConnection._lastReceivedTime = Date.now() - 60000;
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
WebSocketConnection.handleForegroundOrNetworkChange();
const connectingWs = WebSocketConnection.ws;
@ -300,7 +300,7 @@ describe("WebSocketConnection module", () => {
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
const firstWs = WebSocketConnection.ws;
WebSocketConnection._lastReceivedTime = Date.now() - 60000;
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
WebSocketConnection.handleForegroundOrNetworkChange();
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
@ -315,6 +315,57 @@ describe("WebSocketConnection module", () => {
WebSocketConnection.destroy();
});
it("does not emit disconnected before the first successful open", async () => {
global.WebSocket = class FailingWS {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
constructor(url) {
this.url = url;
this.readyState = FailingWS.CONNECTING;
this._listeners = { open: [], close: [], error: [], message: [] };
queueMicrotask(() => {
this.readyState = FailingWS.CLOSED;
this._listeners.close.forEach((fn) => fn({ code: 1006, reason: "startup" }));
});
}
addEventListener(type, fn) {
this._listeners[type]?.push(fn);
}
send() {}
close() {
if (this.readyState === FailingWS.CLOSED) {
return;
}
this.readyState = FailingWS.CLOSED;
queueMicrotask(() => {
this._listeners.close.forEach((fn) => fn({}));
});
}
};
vi.useFakeTimers({ shouldAdvanceTime: true });
const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
const connected = vi.fn();
const disconnected = vi.fn();
WebSocketConnection.on("connected", connected);
WebSocketConnection.on("disconnected", disconnected);
WebSocketConnection.connect();
await vi.advanceTimersByTimeAsync(50);
expect(disconnected).not.toHaveBeenCalled();
expect(connected).not.toHaveBeenCalled();
WebSocketConnection.destroy();
});
it("marks forced reconnect as isReconnect after a prior successful open (background-tab stall)", async () => {
const MockWS = makeWsImpl();
global.WebSocket = MockWS;
@ -331,7 +382,7 @@ describe("WebSocketConnection module", () => {
const firstWs = WebSocketConnection.ws;
// Simulate a zombie OPEN socket after the tab slept: readyState still
// OPEN, but no frames for longer than the ping interval.
WebSocketConnection._lastReceivedTime = Date.now() - 60000;
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
WebSocketConnection.forceReconnect();
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
@ -411,7 +462,7 @@ describe("WebSocketConnection module", () => {
await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
const firstWs = WebSocketConnection.ws;
WebSocketConnection._lastReceivedTime = Date.now() - 60000;
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
// still hidden - must not trigger a reconnect
global.window.dispatchEvent(new Event("visibilitychange"));
@ -448,7 +499,7 @@ describe("WebSocketConnection module", () => {
expect(sendSpy).toHaveBeenCalled();
expect(WebSocketConnection.ws).toBe(firstWs);
WebSocketConnection._lastReceivedTime = Date.now() - 60000;
WebSocketConnection._lastReceivedTime = Date.now() - 120000;
global.window.dispatchEvent(new Event("online"));
await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);