2026-05-30 12:44:26 -05:00
|
|
|
# SPDX-License-Identifier: 0BSD
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
2026-07-14 17:27:30 -05:00
|
|
|
import pytest
|
|
|
|
|
|
2026-07-16 11:22:53 -05:00
|
|
|
from meshchatx import android_codec2
|
2026-05-30 12:44:26 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codec2_skips_non_android():
|
2026-07-16 16:17:54 -05:00
|
|
|
android_codec2.reset_codec2_preload_state_for_tests()
|
2026-05-30 12:44:26 -05:00
|
|
|
with patch.object(android_codec2, "_is_chaquopy_android", return_value=False):
|
|
|
|
|
assert android_codec2.ensure_codec2_native_library() is True
|
|
|
|
|
assert android_codec2.codec2_preload_error() is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codec2_loads_bundled_library(tmp_path):
|
|
|
|
|
lib = tmp_path / "libcodec2.so"
|
|
|
|
|
lib.write_bytes(b"\x7fELF")
|
|
|
|
|
|
2026-07-16 16:17:54 -05:00
|
|
|
android_codec2.reset_codec2_preload_state_for_tests()
|
2026-05-30 12:44:26 -05:00
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
|
2026-07-24 11:29:05 -05:00
|
|
|
patch.object(android_codec2, "_java_system_load_library", return_value=False),
|
2026-05-30 14:24:11 -05:00
|
|
|
patch.object(
|
2026-07-16 16:17:54 -05:00
|
|
|
android_codec2,
|
|
|
|
|
"_cdll_load",
|
2026-07-16 11:22:53 -05:00
|
|
|
side_effect=[OSError(), None],
|
2026-05-30 14:24:11 -05:00
|
|
|
) as cdll,
|
2026-05-30 12:44:26 -05:00
|
|
|
patch.object(
|
|
|
|
|
android_codec2,
|
|
|
|
|
"_libcodec2_candidates",
|
|
|
|
|
return_value=[lib],
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
assert android_codec2.ensure_codec2_native_library() is True
|
2026-07-16 16:17:54 -05:00
|
|
|
assert cdll.call_count == 2
|
|
|
|
|
assert cdll.call_args_list[0].args[0] == "libcodec2.so"
|
|
|
|
|
assert cdll.call_args_list[1].args[0] == str(lib)
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 11:29:05 -05:00
|
|
|
def test_ensure_codec2_prefers_java_load_library():
|
|
|
|
|
android_codec2.reset_codec2_preload_state_for_tests()
|
|
|
|
|
with (
|
|
|
|
|
patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
|
2026-07-24 11:29:43 -05:00
|
|
|
patch.object(
|
|
|
|
|
android_codec2, "_java_system_load_library", return_value=True
|
|
|
|
|
) as java_load,
|
2026-07-25 18:19:39 -05:00
|
|
|
patch.object(android_codec2, "_java_system_load_absolute", return_value=False),
|
2026-07-24 11:29:05 -05:00
|
|
|
patch.object(android_codec2, "_cdll_load") as cdll,
|
2026-07-25 18:19:39 -05:00
|
|
|
patch.object(android_codec2, "_libcodec2_candidates", return_value=[]),
|
2026-07-24 11:29:05 -05:00
|
|
|
):
|
2026-07-25 18:19:39 -05:00
|
|
|
# Bare soname CDLL may still fail after Java load. Absolute candidates empty.
|
|
|
|
|
cdll.side_effect = [OSError("no bare soname")]
|
2026-07-24 11:29:05 -05:00
|
|
|
assert android_codec2.ensure_codec2_native_library() is True
|
|
|
|
|
java_load.assert_called_once_with("codec2")
|
2026-07-25 18:19:39 -05:00
|
|
|
assert cdll.call_count >= 1
|
2026-07-24 11:29:05 -05:00
|
|
|
assert android_codec2.codec2_preload_error() is None
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 16:17:54 -05:00
|
|
|
def test_libcodec2_candidates_find_without_importing_pycodec2(tmp_path, monkeypatch):
|
|
|
|
|
"""Discovery must not import pycodec2 (extension needs libcodec2 already loaded)."""
|
|
|
|
|
site = tmp_path / "site-packages"
|
|
|
|
|
pkg = site / "pycodec2"
|
|
|
|
|
pkg.mkdir(parents=True)
|
|
|
|
|
lib = pkg / "libcodec2.so"
|
|
|
|
|
lib.write_bytes(b"\x7fELF")
|
|
|
|
|
monkeypatch.syspath_prepend(str(site))
|
|
|
|
|
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
real_import = builtins.__import__
|
|
|
|
|
|
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
|
|
|
if name == "pycodec2" or name.startswith("pycodec2."):
|
|
|
|
|
raise ImportError("pycodec2 must not be imported during candidate search")
|
|
|
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
with patch("builtins.__import__", side_effect=fake_import):
|
|
|
|
|
candidates = android_codec2._libcodec2_candidates()
|
|
|
|
|
|
|
|
|
|
assert lib.resolve() in [c.resolve() for c in candidates]
|
2026-05-30 12:44:26 -05:00
|
|
|
|
|
|
|
|
|
2026-07-09 14:04:46 -05:00
|
|
|
def test_probe_pycodec2_reports_failure_when_import_breaks():
|
2026-07-16 16:17:54 -05:00
|
|
|
android_codec2.reset_codec2_preload_state_for_tests()
|
2026-07-25 18:19:39 -05:00
|
|
|
android_codec2._codec2_preload_attempted = True
|
|
|
|
|
android_codec2._codec2_preload_ok = True
|
2026-07-09 14:04:46 -05:00
|
|
|
android_codec2._codec2_preload_error = None
|
|
|
|
|
with (
|
|
|
|
|
patch.object(android_codec2, "_is_chaquopy_android", return_value=False),
|
|
|
|
|
patch.dict("sys.modules", {"pycodec2": None}),
|
|
|
|
|
):
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
real_import = builtins.__import__
|
|
|
|
|
|
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
|
|
|
if name == "pycodec2":
|
|
|
|
|
raise ImportError("no pycodec2")
|
|
|
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
with patch("builtins.__import__", side_effect=fake_import):
|
|
|
|
|
ok, err = android_codec2.probe_pycodec2()
|
|
|
|
|
assert ok is False
|
|
|
|
|
assert err
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 18:19:39 -05:00
|
|
|
def test_ensure_lxst_codec2_binding_reloads_when_codec2_none():
|
|
|
|
|
import types
|
|
|
|
|
|
|
|
|
|
android_codec2.reset_codec2_preload_state_for_tests()
|
|
|
|
|
|
|
|
|
|
fake_codecs = types.ModuleType("LXST.Codecs")
|
|
|
|
|
fake_codecs.Codec2 = None
|
|
|
|
|
|
|
|
|
|
reloaded = types.ModuleType("LXST.Codecs")
|
|
|
|
|
reloaded.Codec2 = object()
|
|
|
|
|
|
|
|
|
|
def import_module(name):
|
|
|
|
|
if name == "LXST.Codecs":
|
|
|
|
|
return fake_codecs
|
|
|
|
|
if name == "LXST.Primitives.Telephony":
|
|
|
|
|
raise ImportError("telephony skipped in test")
|
|
|
|
|
raise ImportError(name)
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch.object(android_codec2, "probe_pycodec2", return_value=(True, None)),
|
2026-07-25 18:47:19 -05:00
|
|
|
patch.object(
|
|
|
|
|
android_codec2.importlib, "import_module", side_effect=import_module
|
|
|
|
|
),
|
|
|
|
|
patch.object(
|
|
|
|
|
android_codec2.importlib, "reload", return_value=reloaded
|
|
|
|
|
) as reload_mock,
|
2026-07-25 18:19:39 -05:00
|
|
|
):
|
|
|
|
|
assert android_codec2.ensure_lxst_codec2_binding() is True
|
|
|
|
|
assert reload_mock.called
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 05:44:02 -05:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 14:04:46 -05:00
|
|
|
def test_vendor_wheels_bundle_libcodec2_for_all_abis():
|
|
|
|
|
import zipfile
|
|
|
|
|
|
|
|
|
|
repo = Path(__file__).resolve().parents[2]
|
|
|
|
|
vendor = repo / "android" / "vendor"
|
2026-07-13 09:43:19 -05:00
|
|
|
if not vendor.is_dir():
|
|
|
|
|
pytest.skip("android/vendor not present (gitignored)")
|
2026-07-09 14:04:46 -05:00
|
|
|
abis = ("arm64_v8a", "armeabi_v7a", "x86_64")
|
|
|
|
|
for abi in abis:
|
|
|
|
|
wheels = sorted(vendor.glob(f"pycodec2-*-android_24_{abi}.whl"))
|
2026-07-13 09:43:19 -05:00
|
|
|
if not wheels:
|
|
|
|
|
pytest.skip(f"missing pycodec2 wheel for {abi}")
|
2026-07-09 14:04:46 -05:00
|
|
|
with zipfile.ZipFile(wheels[-1]) as zin:
|
|
|
|
|
assert "pycodec2/libcodec2.so" in zin.namelist()
|
|
|
|
|
assert "pycodec2/pycodec2.so" in zin.namelist()
|
2026-07-26 05:44:02 -05:00
|
|
|
# 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
|
2026-07-09 14:04:46 -05:00
|
|
|
lib_wheels = sorted(vendor.glob(f"chaquopy_libcodec2-*-android_24_{abi}.whl"))
|
2026-07-13 09:43:19 -05:00
|
|
|
if not lib_wheels:
|
|
|
|
|
pytest.skip(f"missing chaquopy_libcodec2 for {abi}")
|
2026-07-09 14:04:46 -05:00
|
|
|
with zipfile.ZipFile(lib_wheels[-1]) as zin:
|
|
|
|
|
assert "chaquopy/lib/libcodec2.so" in zin.namelist()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_jni_libs_synced_for_all_abis():
|
|
|
|
|
repo = Path(__file__).resolve().parents[2]
|
|
|
|
|
jni = repo / "android" / "app" / "src" / "main" / "jniLibs"
|
|
|
|
|
for abi in ("arm64-v8a", "armeabi-v7a", "x86_64"):
|
|
|
|
|
lib = jni / abi / "libcodec2.so"
|
2026-07-14 17:27:30 -05:00
|
|
|
if not lib.is_file():
|
2026-07-22 16:18:22 -05:00
|
|
|
pytest.skip(f"missing jniLibs {lib}: not an Android build")
|
2026-07-09 14:04:46 -05:00
|
|
|
assert lib.stat().st_size > 100_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_android_lxst_wheel_get_codec_guards_missing_codec2():
|
|
|
|
|
import zipfile
|
|
|
|
|
|
|
|
|
|
repo = Path(__file__).resolve().parents[2]
|
|
|
|
|
whl = repo / "android" / "vendor" / "lxst-0.4.8-py3-none-any.whl"
|
2026-07-13 09:43:19 -05:00
|
|
|
if not whl.is_file():
|
|
|
|
|
pytest.skip("android/vendor/lxst wheel not present (gitignored)")
|
2026-07-09 14:04:46 -05:00
|
|
|
with zipfile.ZipFile(whl) as zin:
|
|
|
|
|
telephony = zin.read("LXST/Primitives/Telephony.py").decode()
|
|
|
|
|
codecs_init = zin.read("LXST/Codecs/__init__.py").decode()
|
|
|
|
|
assert "if Codec2 is not None:" in telephony
|
|
|
|
|
assert "_CODEC2_IMPORT_ERROR" in codecs_init
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 12:44:26 -05:00
|
|
|
def test_repack_script_bundles_libcodec2(tmp_path):
|
|
|
|
|
import importlib.util
|
|
|
|
|
import zipfile
|
|
|
|
|
|
|
|
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
|
|
|
script = repo_root / "scripts" / "repack-android-pycodec2-wheels.py"
|
|
|
|
|
spec = importlib.util.spec_from_file_location("repack_pycodec2", script)
|
|
|
|
|
repack_mod = importlib.util.module_from_spec(spec)
|
|
|
|
|
spec.loader.exec_module(repack_mod)
|
|
|
|
|
repack_pycodec2_wheel = repack_mod.repack_pycodec2_wheel
|
|
|
|
|
|
2026-05-30 14:24:11 -05:00
|
|
|
lib_wheel = (
|
|
|
|
|
tmp_path / "chaquopy_libcodec2-1.2.0-0-py3-none-android_24_arm64_v8a.whl"
|
|
|
|
|
)
|
2026-05-30 12:44:26 -05:00
|
|
|
py_wheel = tmp_path / "pycodec2-4.1.1-0-cp311-cp311-android_24_arm64_v8a.whl"
|
|
|
|
|
|
|
|
|
|
with zipfile.ZipFile(lib_wheel, "w") as zout:
|
|
|
|
|
zout.writestr("chaquopy/lib/libcodec2.so", b"\x7fELF-lib")
|
|
|
|
|
|
2026-05-30 14:02:56 -05:00
|
|
|
record = (
|
|
|
|
|
"pycodec2/pycodec2.so,sha256=deadbeef,8\n"
|
|
|
|
|
"pycodec2.dist-info/METADATA,sha256=deadbeef,4\n"
|
|
|
|
|
"pycodec2.dist-info/RECORD,,\n"
|
|
|
|
|
)
|
2026-05-30 12:44:26 -05:00
|
|
|
with zipfile.ZipFile(py_wheel, "w") as zout:
|
|
|
|
|
zout.writestr("pycodec2/pycodec2.so", b"\x7fELF-mod")
|
2026-05-30 14:02:56 -05:00
|
|
|
zout.writestr("pycodec2.dist-info/METADATA", b"meta")
|
|
|
|
|
zout.writestr("pycodec2.dist-info/RECORD", record)
|
2026-05-30 12:44:26 -05:00
|
|
|
|
|
|
|
|
lib_src = tmp_path / "libcodec2.so"
|
|
|
|
|
lib_src.write_bytes(b"\x7fELF-lib")
|
|
|
|
|
assert repack_pycodec2_wheel(py_wheel, lib_src)
|
|
|
|
|
|
|
|
|
|
with zipfile.ZipFile(py_wheel) as zin:
|
|
|
|
|
names = zin.namelist()
|
2026-05-30 14:02:56 -05:00
|
|
|
record_text = zin.read("pycodec2.dist-info/RECORD").decode()
|
2026-05-30 12:44:26 -05:00
|
|
|
assert "pycodec2/libcodec2.so" in names
|
2026-05-30 14:02:56 -05:00
|
|
|
assert ",,\n" not in record_text
|
|
|
|
|
for line in record_text.splitlines():
|
|
|
|
|
if not line.strip():
|
|
|
|
|
continue
|
|
|
|
|
size_field = line.rsplit(",", 1)[-1]
|
|
|
|
|
assert size_field
|
|
|
|
|
int(size_field)
|
2026-07-26 05:44:02 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|