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