From a6764d3d38e8710c5352bbfe4024dbb36c3a48a2 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 19 Apr 2026 11:36:40 -0500 Subject: [PATCH] feat(android): add Bluetooth and USB permissions, implement JavaScript interfaces for Bluetooth and USB management, and enhance Android wheel verification in CI workflow --- .github/workflows/android-build.yml | 27 ++++ android/app/src/main/AndroidManifest.xml | 5 + .../main/java/com/meshchatx/MainActivity.java | 80 ++++++++++- android/chaquopy-recipes/cffi-2.0/meta.yaml | 10 ++ .../chaquopy-recipes/miniaudio-1.70/meta.yaml | 6 + .../miniaudio-1.70/patches/chaquopy.patch | 63 +++++++++ scripts/build-android-wheels-local.sh | 126 +++++++++++++----- 7 files changed, 282 insertions(+), 35 deletions(-) create mode 100644 android/chaquopy-recipes/cffi-2.0/meta.yaml create mode 100644 android/chaquopy-recipes/miniaudio-1.70/meta.yaml create mode 100644 android/chaquopy-recipes/miniaudio-1.70/patches/chaquopy.patch diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index cd408a6..87e1945 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -117,6 +117,33 @@ jobs: - name: Build Android wheels run: bash scripts/build-android-wheels-local.sh --python-minor "${PYTHON_VERSION}" --chaquopy-ref "${CHAQUOPY_REF}" --abis arm64-v8a,x86_64,armeabi-v7a + - name: Verify required Android wheels are present + run: | + set -euo pipefail + required=( + "miniaudio-1.70-*-cp311-cp311-android_24_arm64_v8a.whl" + "miniaudio-1.70-*-cp311-cp311-android_24_x86_64.whl" + "miniaudio-1.70-*-cp311-cp311-android_24_armeabi_v7a.whl" + "pycodec2-*-cp311-cp311-android_24_arm64_v8a.whl" + "pycodec2-*-cp311-cp311-android_24_x86_64.whl" + "pycodec2-*-cp311-cp311-android_24_armeabi_v7a.whl" + "lxst-*-py3-none-any.whl" + ) + missing=0 + for pattern in "${required[@]}"; do + if ! ls android/vendor/${pattern} >/dev/null 2>&1; then + echo "::error::Missing wheel matching android/vendor/${pattern}" + missing=1 + fi + done + if [[ "${missing}" -ne 0 ]]; then + echo "Built wheels:" + ls -la android/vendor/ || true + exit 1 + fi + echo "All required Android wheels present:" + ls -1 android/vendor/ + - name: Run unit tests if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_tests }} working-directory: android diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0ee6ecc..57567d1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,11 @@ + + + + + missingPermissions, String permission) { + void addIfMissing(List missingPermissions, String permission) { if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { missingPermissions.add(permission); } @@ -553,6 +556,81 @@ public class MainActivity extends AppCompatActivity { android.os.Process.killProcess(android.os.Process.myPid()); }); } + + @JavascriptInterface + public String getPlatform() { + return "android"; + } + + @JavascriptInterface + public boolean hasBluetoothPermissions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return true; + } + return ContextCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_CONNECT) + == PackageManager.PERMISSION_GRANTED + && ContextCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_SCAN) + == PackageManager.PERMISSION_GRANTED; + } + + @JavascriptInterface + public void requestBluetoothPermissions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return; + } + activity.runOnUiThread(() -> { + List missing = new ArrayList<>(); + activity.addIfMissing(missing, Manifest.permission.BLUETOOTH_CONNECT); + activity.addIfMissing(missing, Manifest.permission.BLUETOOTH_SCAN); + if (missing.isEmpty()) { + return; + } + ActivityCompat.requestPermissions( + activity, + missing.toArray(new String[0]), + RNODE_BLUETOOTH_PERMISSION_REQUEST_CODE + ); + }); + } + + @JavascriptInterface + public boolean hasUsbPermissions() { + // WebUSB / Web Serial polyfill drives the device picker; from the + // Android manifest standpoint USB host access is granted as soon as + // the user accepts the per-device dialog. Surface true when we + // have a UsbManager so the JS layer can short-circuit prompts. + UsbManager manager = (UsbManager) activity.getSystemService(Context.USB_SERVICE); + return manager != null; + } + + @JavascriptInterface + public void requestUsbPermissions() { + // No-op on android: per-device prompts are issued by WebUSB itself. + // Method is exposed so the JS bridge contract is symmetric. + } + + @JavascriptInterface + public void openBluetoothSettings() { + activity.runOnUiThread(() -> { + try { + activity.startActivity(new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)); + } catch (ActivityNotFoundException ignored) { + Toast.makeText(activity, "Bluetooth settings unavailable", Toast.LENGTH_SHORT).show(); + } + }); + } + + @JavascriptInterface + public void openUsbSettings() { + activity.runOnUiThread(() -> { + try { + activity.startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + activity.getPackageName()))); + } catch (ActivityNotFoundException ignored) { + Toast.makeText(activity, "USB settings unavailable", Toast.LENGTH_SHORT).show(); + } + }); + } } } diff --git a/android/chaquopy-recipes/cffi-2.0/meta.yaml b/android/chaquopy-recipes/cffi-2.0/meta.yaml new file mode 100644 index 0000000..4036c1e --- /dev/null +++ b/android/chaquopy-recipes/cffi-2.0/meta.yaml @@ -0,0 +1,10 @@ +package: + name: cffi + version: "2.0.0" + +build: + number: 0 + +requirements: + host: + - chaquopy-libffi 3.3 diff --git a/android/chaquopy-recipes/miniaudio-1.70/meta.yaml b/android/chaquopy-recipes/miniaudio-1.70/meta.yaml new file mode 100644 index 0000000..4cd196b --- /dev/null +++ b/android/chaquopy-recipes/miniaudio-1.70/meta.yaml @@ -0,0 +1,6 @@ +package: + name: miniaudio + version: "1.70" + +build: + number: 1 diff --git a/android/chaquopy-recipes/miniaudio-1.70/patches/chaquopy.patch b/android/chaquopy-recipes/miniaudio-1.70/patches/chaquopy.patch new file mode 100644 index 0000000..a15497d --- /dev/null +++ b/android/chaquopy-recipes/miniaudio-1.70/patches/chaquopy.patch @@ -0,0 +1,63 @@ +--- src-original/build_ffi_module.py 2026-04-18 01:06:29.502232803 -0500 ++++ src/build_ffi_module.py 2026-04-18 01:06:45.617868060 -0500 +@@ -834,33 +834,16 @@ + + + def check_linker_need_libatomic(): ++ """Chaquopy override: 32-bit Android ABIs need libatomic for 64-bit ++ atomic ops; 64-bit ABIs link them as compiler builtins. Upstream's ++ helper spawns the host ``c++`` to detect this, which is unreliable ++ inside Chaquopy's cross-compilation environment because the answer ++ reflects the build host (Linux x86_64), not the target ABI. + """ +- Test if linker on system needs libatomic. +- This has been copied from https://github.com/grpc/grpc/blob/master/setup.py#L205 +- """ +- code_test = ( +- b"#include \n" + b"int main() { return std::atomic{}; }" +- ) +- cxx = shlex.split(os.environ.get("CXX", "c++")) +- cpp_test = subprocess.Popen( +- cxx + ["-x", "c++", "-std=c++14", "-"], +- stdin=subprocess.PIPE, +- stdout=subprocess.PIPE, +- stderr=subprocess.PIPE, +- ) +- cpp_test.communicate(input=code_test) +- if cpp_test.returncode == 0: +- return False +- # Double-check to see if -latomic actually can solve the problem. +- # https://github.com/grpc/grpc/issues/22491 +- cpp_test = subprocess.Popen( +- cxx + ["-x", "c++", "-std=c++14", "-", "-latomic"], +- stdin=subprocess.PIPE, +- stdout=subprocess.PIPE, +- stderr=subprocess.PIPE, +- ) +- cpp_test.communicate(input=code_test) +- return cpp_test.returncode == 0 ++ abi = os.environ.get("CHAQUOPY_ABI", "") ++ if abi: ++ return abi in ("armeabi-v7a", "x86", "i686") ++ return False + + + if os.name == "posix": +@@ -868,6 +851,8 @@ + libraries = ["m", "pthread", "dl"] + if check_linker_need_libatomic(): + libraries.append("atomic") ++ if os.environ.get("CHAQUOPY_ABI", ""): ++ libraries.append("OpenSLES") + if "PYMINIAUDIO_EXTRA_CFLAGS" in os.environ: + compiler_args += shlex.split(os.environ.get("PYMINIAUDIO_EXTRA_CFLAGS", "")) + __macros = [ +@@ -877,6 +862,8 @@ + ("MA_NO_NODE_GRAPH", "1"), # high level api + ("MA_NO_ENGINE", "1"), # high level api + ] ++if os.environ.get("CHAQUOPY_ABI", ""): ++ __macros.append(("MA_NO_AAUDIO", "1")) + if sys.platform == "darwin": + __macros.insert(0, ("MA_NO_RUNTIME_LINKING", None)) + __link_args = ["-Wl,-needed_framework,AudioToolbox"] diff --git a/scripts/build-android-wheels-local.sh b/scripts/build-android-wheels-local.sh index 0500521..b2d9fe1 100755 --- a/scripts/build-android-wheels-local.sh +++ b/scripts/build-android-wheels-local.sh @@ -10,7 +10,9 @@ This script: 2) Downloads a matching Chaquopy Python target toolchain 3) Builds pycodec2 Android wheels with Chaquopy's build-wheel tool 4) Optionally patches LXST wheel metadata for local Android constraints -5) Copies outputs to android/vendor +5) Builds every recipe under android/chaquopy-recipes/ for each requested + ABI (currently: cryptography, miniaudio) +6) Copies outputs to android/vendor Usage: scripts/build-android-wheels-local.sh [options] @@ -25,6 +27,10 @@ Options: --numpy-version V NumPy version used during pycodec2 build (default: 1.26.2) --lxst-version V LXST wheel version for metadata patch (default: 0.4.6) --no-lxst-patch Skip LXST metadata patch + --only-recipes LIST Comma-separated recipe directory names under + android/chaquopy-recipes to build. When set, the + NumPy, pycodec2/chaquopy-libcodec2 and LXST steps + are skipped and only matching custom recipes run. --work-dir PATH Working directory (default: ./.local/chaquopy-build-wheel) --out-dir PATH Output wheel directory (default: ./android/vendor) -h, --help Show this help @@ -43,6 +49,7 @@ LIBCODEC2_VERSION="1.2.0" NUMPY_VERSION="1.26.2" LXST_VERSION="0.4.6" PATCH_LXST="1" +ONLY_RECIPES="" WORK_DIR="${ROOT_DIR}/.local/chaquopy-build-wheel" OUT_DIR="${ROOT_DIR}/android/vendor" @@ -84,6 +91,10 @@ while [[ $# -gt 0 ]]; do PATCH_LXST="0" shift ;; + --only-recipes) + ONLY_RECIPES="${2:?missing value for --only-recipes}" + shift 2 + ;; --work-dir) WORK_DIR="${2:?missing value for --work-dir}" shift 2 @@ -147,16 +158,21 @@ require_cmd sort case ",${ABI_LIST}," in *,armeabi-v7a,*) - if [[ -z "${ANDROID_HOME:-}" && -z "${ANDROID_SDK_ROOT:-}" ]]; then - echo "armeabi-v7a: NumPy is built from source and needs the Android SDK/NDK (Chaquopy android-env.sh)." >&2 - echo "Set ANDROID_HOME or ANDROID_SDK_ROOT to your SDK root (with cmdline-tools/latest/bin/sdkmanager)." >&2 - exit 1 - fi - export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT}}" - export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME}}" - if [[ ! -x "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" ]]; then - echo "armeabi-v7a: expected sdkmanager at ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" >&2 - exit 1 + if [[ -z "${ONLY_RECIPES}" ]]; then + if [[ -z "${ANDROID_HOME:-}" && -z "${ANDROID_SDK_ROOT:-}" ]]; then + echo "armeabi-v7a: NumPy is built from source and needs the Android SDK/NDK (Chaquopy android-env.sh)." >&2 + echo "Set ANDROID_HOME or ANDROID_SDK_ROOT to your SDK root (with cmdline-tools/latest/bin/sdkmanager)." >&2 + exit 1 + fi + export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT}}" + export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME}}" + if [[ ! -x "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" ]]; then + echo "armeabi-v7a: expected sdkmanager at ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" >&2 + exit 1 + fi + else + export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" fi ;; esac @@ -236,29 +252,53 @@ cache_chaquopy_openblas_for_abi() { curl -fsSL -o "${PYPIDIR}/dist/chaquopy-openblas/${name}" "${url}" } -for abi in ${ABI_LIST//,/ }; do - platform_tag="$(abi_to_platform_tag "${abi}")" - echo "Resolving NumPy wheel for ABI ${abi} (${platform_tag})" - if ! "${VENV_DIR}/bin/pip" download \ - --only-binary=:all: \ - --no-deps \ - --platform "${platform_tag}" \ - --python-version "${PYTHON_MINOR/./}" \ - --implementation cp \ - --abi "${PYTHON_ABI_TAG}" \ - "numpy==${NUMPY_VERSION}" \ - --index-url https://pypi.org/simple \ - --extra-index-url https://chaquo.com/pypi-13.1 \ - --dest "${NUMPY_DIST_DIR}"; then - echo "No prebuilt NumPy wheel for ${abi}; building locally via Chaquopy recipe" - cache_chaquopy_openblas_for_abi "${abi}" - "${VENV_DIR}/bin/python" "${PYPIDIR}/build-wheel.py" \ - --python "${PYTHON_MINOR}" \ - --api-level "${API_LEVEL}" \ - --abi "${abi}" \ - "${PYPIDIR}/packages/numpy" +cache_chaquopy_libffi_for_abi() { + local abi="$1" + local name url + mkdir -p "${PYPIDIR}/dist/chaquopy-libffi" + case "${abi}" in + armeabi-v7a) name="chaquopy_libffi-3.3-2-py3-none-android_16_armeabi_v7a.whl" ;; + arm64-v8a) name="chaquopy_libffi-3.3-3-py3-none-android_24_arm64_v8a.whl" ;; + x86_64) name="chaquopy_libffi-3.3-3-py3-none-android_24_x86_64.whl" ;; + *) return 0 ;; + esac + url="https://chaquo.com/pypi-13.1/chaquopy-libffi/${name}" + if [[ -f "${PYPIDIR}/dist/chaquopy-libffi/${name}" ]]; then + return 0 fi -done + echo "Caching Chaquopy libffi wheel for ${abi}" + curl -fsSL -o "${PYPIDIR}/dist/chaquopy-libffi/${name}" "${url}" +} + +if [[ -z "${ONLY_RECIPES}" ]]; then + for abi in ${ABI_LIST//,/ }; do + platform_tag="$(abi_to_platform_tag "${abi}")" + echo "Resolving NumPy wheel for ABI ${abi} (${platform_tag})" + if ! "${VENV_DIR}/bin/pip" download \ + --only-binary=:all: \ + --no-deps \ + --platform "${platform_tag}" \ + --python-version "${PYTHON_MINOR/./}" \ + --implementation cp \ + --abi "${PYTHON_ABI_TAG}" \ + "numpy==${NUMPY_VERSION}" \ + --index-url https://pypi.org/simple \ + --extra-index-url https://chaquo.com/pypi-13.1 \ + --dest "${NUMPY_DIST_DIR}"; then + echo "No prebuilt NumPy wheel for ${abi}; building locally via Chaquopy recipe" + cache_chaquopy_openblas_for_abi "${abi}" + "${VENV_DIR}/bin/python" "${PYPIDIR}/build-wheel.py" \ + --python "${PYTHON_MINOR}" \ + --api-level "${API_LEVEL}" \ + --abi "${abi}" \ + "${PYPIDIR}/packages/numpy" + fi + done +else + echo "Skipping NumPy wheel resolution (--only-recipes set)" +fi + +if [[ -z "${ONLY_RECIPES}" ]]; then RECIPE_DIR="${WORK_DIR}/recipes/pycodec2-local" LIBCODEC2_RECIPE_DIR="${WORK_DIR}/recipes/chaquopy-libcodec2-local" @@ -533,7 +573,13 @@ mkdir -p "${OUT_DIR}" cp -f "${PYPIDIR}/dist/chaquopy-libcodec2"/chaquopy_libcodec2-"${LIBCODEC2_VERSION}"-*.whl "${OUT_DIR}/" cp -f "${PYPIDIR}/dist/pycodec2"/pycodec2-"${PYCODEC2_VERSION}"-*.whl "${OUT_DIR}/" -if [[ "${PATCH_LXST}" == "1" ]]; then +else + echo "Skipping pycodec2/chaquopy-libcodec2 builds (--only-recipes set)" +fi + +mkdir -p "${OUT_DIR}" + +if [[ "${PATCH_LXST}" == "1" && -z "${ONLY_RECIPES}" ]]; then TMP_DIR="$(mktemp -d)" trap 'rm -rf "${TMP_DIR}"' EXIT @@ -612,6 +658,10 @@ PY fi CUSTOM_RECIPES_DIR="${ROOT_DIR}/android/chaquopy-recipes" +ONLY_RECIPES_FILTER="" +if [[ -n "${ONLY_RECIPES}" ]]; then + ONLY_RECIPES_FILTER=" ${ONLY_RECIPES//,/ } " +fi if [[ -d "${CUSTOM_RECIPES_DIR}" ]]; then echo "Building custom Android recipes from ${CUSTOM_RECIPES_DIR}" for RECIPE_SRC in "${CUSTOM_RECIPES_DIR}"/*; do @@ -620,6 +670,10 @@ if [[ -d "${CUSTOM_RECIPES_DIR}" ]]; then fi RECIPE_NAME="$(basename "${RECIPE_SRC}")" + if [[ -n "${ONLY_RECIPES_FILTER}" && "${ONLY_RECIPES_FILTER}" != *" ${RECIPE_NAME} "* ]]; then + echo "Skipping recipe ${RECIPE_NAME} (not in --only-recipes list)" + continue + fi RECIPE_DST="${PYPIDIR}/packages/${RECIPE_NAME}-local" rm -rf "${RECIPE_DST}" mkdir -p "${RECIPE_DST}" @@ -640,6 +694,9 @@ PY echo "Building ${PACKAGE_NAME} ${PACKAGE_VERSION} from recipe ${RECIPE_NAME}" for abi in ${ABI_LIST//,/ }; do abi_tag="${abi//-/_}" + if [[ "${PACKAGE_NAME}" == "cffi" ]]; then + cache_chaquopy_libffi_for_abi "${abi}" + fi echo "Building ${PACKAGE_NAME} ${PACKAGE_VERSION} for ${abi}" "${VENV_DIR}/bin/python" "${PYPIDIR}/build-wheel.py" \ --python "${PYTHON_MINOR}" \ @@ -680,3 +737,4 @@ fi echo "Done." echo "Built wheels in: ${OUT_DIR}" +ls -1 "${OUT_DIR}" | sort