From 034a0a6edefdd9ab64a338e45711f607e35d1c71 Mon Sep 17 00:00:00 2001 From: Ivan Date: Fri, 17 Apr 2026 23:28:53 -0500 Subject: [PATCH] feat(scripts): add Argos Translate script for JSON localization and new APK signing script --- scripts/argos_translate.py | 266 ++++++++++++++++++++++++++ scripts/build-android-wheels-local.sh | 50 ++++- scripts/sign-android-apks.sh | 134 +++++++++++++ 3 files changed, 444 insertions(+), 6 deletions(-) create mode 100755 scripts/argos_translate.py create mode 100755 scripts/sign-android-apks.sh diff --git a/scripts/argos_translate.py b/scripts/argos_translate.py new file mode 100755 index 0000000..40e9602 --- /dev/null +++ b/scripts/argos_translate.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Argos Translate JSON localization script. + +This script provides an automated workflow to translate JSON localization files +(such as `en.json`) to target languages using Argos Translate. It ensures that +interpolated variables (e.g., `{count}`, `{status}`) are preserved and not +altered during the translation process. + +Requirements: + - argostranslate (pip install argostranslate) + +Usage: + python scripts/argos_translate.py --from en --to zh --input locales/en.json --output locales/zh.json + + You can also use environment variables instead of CLI flags: + ARGOS_FROM_LANG="en" + ARGOS_TO_LANG="zh" + ARGOS_INPUT_FILE="locales/en.json" + ARGOS_OUTPUT_FILE="locales/zh.json" +""" + +import argparse +import json +import os +import re +import sys +import time + + +# ANSI color codes for terminal output +class Colors: + HEADER = "\033[95m" + OKBLUE = "\033[94m" + OKCYAN = "\033[96m" + OKGREEN = "\033[92m" + WARNING = "\033[93m" + FAIL = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + UNDERLINE = "\033[4m" + + +def print_info(msg): + print(f"{Colors.OKCYAN}[INFO]{Colors.ENDC} {msg}") + + +def print_success(msg): + print(f"{Colors.OKGREEN}[SUCCESS]{Colors.ENDC} {msg}") + + +def print_warning(msg): + print(f"{Colors.WARNING}[WARNING]{Colors.ENDC} {msg}") + + +def print_error(msg): + print(f"{Colors.FAIL}[ERROR]{Colors.ENDC} {msg}") + + +try: + import argostranslate.package + import argostranslate.translate +except ImportError: + print_error("The 'argostranslate' module is not installed.") + print_info("Please install it using: pip install argostranslate") + sys.exit(1) + + +def ensure_package_installed(from_code, to_code): + """Ensure the translation package from `from_code` to `to_code` is installed. + + If not installed, attempts to download and install it automatically. + """ + installed = argostranslate.translate.get_installed_languages() + installed_dict = {lang.code: lang for lang in installed} + + from_lang = installed_dict.get(from_code) + to_lang = installed_dict.get(to_code) + + if not from_lang or not to_lang or to_lang not in from_lang.translations_from: + print_warning( + f"Translation package {from_code} -> {to_code} not found. Attempting to install..." + ) + try: + argostranslate.package.update_package_index() + available_packages = argostranslate.package.get_available_packages() + + pkg_to_install = None + for pkg in available_packages: + if pkg.from_code == from_code and pkg.to_code == to_code: + pkg_to_install = pkg + break + + if pkg_to_install: + print_info(f"Downloading package: {pkg_to_install}") + argostranslate.package.install_from_path(pkg_to_install.download()) + print_success( + f"Successfully installed package: {from_code} -> {to_code}" + ) + + # Refresh installed languages + installed = argostranslate.translate.get_installed_languages() + installed_dict = {lang.code: lang for lang in installed} + else: + print_error( + f"Could not find a translation package for {from_code} -> {to_code}" + ) + sys.exit(1) + except Exception as e: + print_error(f"Failed to install language package: {e}") + sys.exit(1) + + return installed_dict.get(from_code), installed_dict.get(to_code) + + +def get_translation_func(from_code, to_code): + """Returns a translation function for the specified language pair.""" + from_lang, to_lang = ensure_package_installed(from_code, to_code) + translator = from_lang.get_translation(to_lang) + if not translator: + print_error(f"No translation available from {from_code} to {to_code}") + sys.exit(1) + return translator.translate + + +def replace_vars_with_tokens(text): + """Replaces `{variable}` patterns with a standard token like `XVAR0X`. + + So the translation engine doesn't attempt to translate variable names. + Returns the modified text and the list of found variables. + """ + vars_found = [] + + def replacer(match): + vars_found.append(match.group(0)) + return f" XVAR{len(vars_found) - 1}X " + + replaced_text = re.sub(r"\{+.*?\}+", replacer, text) + return replaced_text, vars_found + + +def restore_vars_from_tokens(text, vars_found): + """Restores the original `{variable}` patterns back into the translated text. + + Looks for the `XVAR0X` tokens. + """ + for i, var in enumerate(vars_found): + # The translation engine might change case or spacing around the token + text = re.sub(rf"\s*[xX][vV][aA][rR]{i}[xX]\s*", var, text) + return text.strip() + + +def translate_dict(data, translate_func, target_name=None): + """Recursively iterates over a dictionary and translates all string values. + + Skips the `_languageName` key, which can be explicitly set. + """ + if isinstance(data, dict): + new_dict = {} + for k, v in data.items(): + if k == "_languageName" and target_name: + new_dict[k] = target_name + continue + elif k == "_languageName": + # Keep original if no target name provided + new_dict[k] = v + continue + + new_dict[k] = translate_dict(v, translate_func, target_name) + return new_dict + elif isinstance(data, list): + return [translate_dict(item, translate_func, target_name) for item in data] + elif isinstance(data, str): + if not data.strip(): + return data + + temp_text, vars_found = replace_vars_with_tokens(data) + try: + translated_temp = translate_func(temp_text) + return restore_vars_from_tokens(translated_temp, vars_found) + except Exception as e: + print_warning( + f"Failed to translate '{data}': {e}. Falling back to original." + ) + return data + else: + return data + + +def main(): + parser = argparse.ArgumentParser( + description="Translate JSON localization files using Argos Translate." + ) + parser.add_argument( + "--from", dest="from_lang", help="Source language code (e.g. 'en')" + ) + parser.add_argument("--to", dest="to_lang", help="Target language code (e.g. 'zh')") + parser.add_argument("--input", dest="input_file", help="Path to input JSON file") + parser.add_argument("--output", dest="output_file", help="Path to output JSON file") + parser.add_argument( + "--name", + dest="target_name", + help="Native name of the target language (e.g. '中文' for Chinese)", + ) + + args = parser.parse_args() + + # Fallback to Environment Variables if arguments aren't provided + from_lang = args.from_lang or os.environ.get("ARGOS_FROM_LANG") + to_lang = args.to_lang or os.environ.get("ARGOS_TO_LANG") + input_file = args.input_file or os.environ.get("ARGOS_INPUT_FILE") + output_file = args.output_file or os.environ.get("ARGOS_OUTPUT_FILE") + target_name = args.target_name or os.environ.get("ARGOS_TARGET_NAME") + + if not all([from_lang, to_lang, input_file, output_file]): + parser.print_help() + print_error( + "Missing required arguments. Please provide --from, --to, --input, and --output." + ) + sys.exit(1) + + if not os.path.exists(input_file): + print_error(f"Input file not found: {input_file}") + sys.exit(1) + + print_info(f"Source Language: {Colors.BOLD}{from_lang}{Colors.ENDC}") + print_info(f"Target Language: {Colors.BOLD}{to_lang}{Colors.ENDC}") + print_info(f"Input File: {Colors.BOLD}{input_file}{Colors.ENDC}") + print_info(f"Output File: {Colors.BOLD}{output_file}{Colors.ENDC}") + + # Load JSON + try: + with open(input_file, "r", encoding="utf-8") as f: + source_data = json.load(f) + except json.JSONDecodeError as e: + print_error(f"Invalid JSON in input file: {e}") + sys.exit(1) + + start_time = time.time() + + # Get Translator + translate_func = get_translation_func(from_lang, to_lang) + + print_info( + "Starting translation. This may take a moment depending on the file size..." + ) + translated_data = translate_dict(source_data, translate_func, target_name) + + # Ensure output directory exists + os.makedirs(os.path.dirname(os.path.abspath(output_file)), exist_ok=True) + + # Save translated JSON + try: + with open(output_file, "w", encoding="utf-8") as f: + json.dump(translated_data, f, ensure_ascii=False, indent=4) + f.write("\n") + except IOError as e: + print_error(f"Could not write to output file: {e}") + sys.exit(1) + + elapsed_time = time.time() - start_time + print_success(f"Translation complete in {elapsed_time:.2f} seconds!") + print_success(f"Output saved to {Colors.BOLD}{output_file}{Colors.ENDC}") + + +if __name__ == "__main__": + main() diff --git a/scripts/build-android-wheels-local.sh b/scripts/build-android-wheels-local.sh index 31aa70b..0500521 100755 --- a/scripts/build-android-wheels-local.sh +++ b/scripts/build-android-wheels-local.sh @@ -19,7 +19,7 @@ Options: --python-minor X.Y Python minor for target wheels (default: 3.11) --target-version V Explicit Chaquopy target version (default: auto latest for python minor) --chaquopy-ref REF Chaquopy git ref/commit to checkout (default: master) - --abis LIST Comma-separated ABIs (default: arm64-v8a,x86_64) + --abis LIST Comma-separated ABIs (default: arm64-v8a,x86_64,armeabi-v7a) --api-level N Android API level for wheel tag (default: 24) --pycodec2-version V pycodec2 version to build (default: 4.1.1) --numpy-version V NumPy version used during pycodec2 build (default: 1.26.2) @@ -36,7 +36,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PYTHON_MINOR="3.11" TARGET_VERSION="" CHAQUOPY_REF="${CHAQUOPY_REF:-master}" -ABI_LIST="arm64-v8a,x86_64" +ABI_LIST="arm64-v8a,x86_64,armeabi-v7a" API_LEVEL="24" PYCODEC2_VERSION="4.1.1" LIBCODEC2_VERSION="1.2.0" @@ -145,10 +145,26 @@ require_cmd sed require_cmd awk require_cmd sort -PYTHON_BIN="python${PYTHON_MINOR}" +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 + fi + ;; +esac + +PYTHON_BIN="${PYTHON_BIN:-python${PYTHON_MINOR}}" if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then - echo "Required interpreter not found on PATH: ${PYTHON_BIN}" >&2 - echo "Install Python ${PYTHON_MINOR} locally before running this script." >&2 + echo "Required interpreter not found: ${PYTHON_BIN}" >&2 + echo "Install Python ${PYTHON_MINOR} (e.g. uv python install ${PYTHON_MINOR}) or set PYTHON_BIN." >&2 exit 1 fi @@ -198,6 +214,28 @@ fi NUMPY_DIST_DIR="${PYPIDIR}/dist/numpy" mkdir -p "${NUMPY_DIST_DIR}" PYTHON_ABI_TAG="cp${PYTHON_MINOR/./}" + +# Chaquopy's numpy recipe lists chaquopy-openblas as a host requirement. build-wheel.py +# only loads it from ${PYPIDIR}/dist/chaquopy-openblas/ (it does not fetch from the index). +# Official wheels: https://chaquo.com/pypi-13.1/chaquopy-openblas/ +cache_chaquopy_openblas_for_abi() { + local abi="$1" + local name url + mkdir -p "${PYPIDIR}/dist/chaquopy-openblas" + case "${abi}" in + armeabi-v7a) name="chaquopy_openblas-0.2.20-5-py3-none-android_16_armeabi_v7a.whl" ;; + arm64-v8a) name="chaquopy_openblas-0.2.20-5-py3-none-android_21_arm64_v8a.whl" ;; + x86_64) name="chaquopy_openblas-0.2.20-5-py3-none-android_21_x86_64.whl" ;; + *) return 0 ;; + esac + url="https://chaquo.com/pypi-13.1/chaquopy-openblas/${name}" + if [[ -f "${PYPIDIR}/dist/chaquopy-openblas/${name}" ]]; then + return 0 + fi + echo "Caching Chaquopy OpenBLAS wheel 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})" @@ -213,12 +251,12 @@ for abi in ${ABI_LIST//,/ }; do --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" - cp -f "${PYPIDIR}/dist/numpy"/numpy-"${NUMPY_VERSION}"-*.whl "${NUMPY_DIST_DIR}/" fi done diff --git a/scripts/sign-android-apks.sh b/scripts/sign-android-apks.sh new file mode 100755 index 0000000..3492b3d --- /dev/null +++ b/scripts/sign-android-apks.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Sign Android release APKs (and optionally add SourceStamp). + +Required env vars: + SIGNING_KEYSTORE_PATH Path to signing keystore (.jks/.keystore) + SIGNING_KEY_ALIAS Alias of signing key + SIGNING_KEYSTORE_PASSWORD Keystore password + SIGNING_KEY_PASSWORD Key password (defaults to SIGNING_KEYSTORE_PASSWORD) + +Optional env vars: + ANDROID_HOME Android SDK root (or ANDROID_SDK_ROOT) + APK_GLOB Glob for unsigned APKs + (default: android/app/build/outputs/apk/release/*-unsigned.apk) + ENABLE_SOURCESTAMP true/false (default: false) + SOURCESTAMP_KEYSTORE_PATH Path to SourceStamp keystore + SOURCESTAMP_KEY_ALIAS Alias in SourceStamp keystore + SOURCESTAMP_KEYSTORE_PASSWORD SourceStamp keystore password + SOURCESTAMP_KEY_PASSWORD SourceStamp key password + +Output: + Creates sibling files for each unsigned APK: + *-aligned.apk + *-signed.apk + +Examples: + SIGNING_KEYSTORE_PATH=android/keystore/meshchatx-release.jks \ + SIGNING_KEY_ALIAS=meshchatx-release \ + SIGNING_KEYSTORE_PASSWORD='...' \ + SIGNING_KEY_PASSWORD='...' \ + bash scripts/sign-android-apks.sh + + SIGNING_KEYSTORE_PATH=android/keystore/meshchatx-release.jks \ + SIGNING_KEY_ALIAS=meshchatx-release \ + SIGNING_KEYSTORE_PASSWORD='...' \ + SIGNING_KEY_PASSWORD='...' \ + ENABLE_SOURCESTAMP=true \ + SOURCESTAMP_KEYSTORE_PATH=android/keystore/meshchatx-stamp.jks \ + SOURCESTAMP_KEY_ALIAS=meshchatx-stamp \ + SOURCESTAMP_KEYSTORE_PASSWORD='...' \ + SOURCESTAMP_KEY_PASSWORD='...' \ + bash scripts/sign-android-apks.sh +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "Missing required env var: ${name}" >&2 + exit 1 + fi +} + +require_env SIGNING_KEYSTORE_PATH +require_env SIGNING_KEY_ALIAS +require_env SIGNING_KEYSTORE_PASSWORD +SIGNING_KEY_PASSWORD="${SIGNING_KEY_PASSWORD:-${SIGNING_KEYSTORE_PASSWORD}}" + +ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -z "${ANDROID_HOME}" ]]; then + echo "Set ANDROID_HOME (or ANDROID_SDK_ROOT) to your Android SDK path." >&2 + exit 1 +fi + +BT_DIR="$(ls -d "${ANDROID_HOME}"/build-tools/* 2>/dev/null | sort -V | tail -n 1)" +if [[ -z "${BT_DIR}" ]]; then + echo "No Android build-tools found under ${ANDROID_HOME}/build-tools." >&2 + exit 1 +fi +if [[ ! -x "${BT_DIR}/zipalign" || ! -x "${BT_DIR}/apksigner" ]]; then + echo "Missing zipalign/apksigner in ${BT_DIR}. Install build-tools via sdkmanager." >&2 + exit 1 +fi + +APK_GLOB="${APK_GLOB:-android/app/build/outputs/apk/release/*-unsigned.apk}" +shopt -s nullglob +APKS=( ${APK_GLOB} ) +shopt -u nullglob +if [[ ${#APKS[@]} -eq 0 ]]; then + echo "No unsigned APKs matched: ${APK_GLOB}" >&2 + exit 1 +fi + +ENABLE_SOURCESTAMP="${ENABLE_SOURCESTAMP:-false}" +if [[ "${ENABLE_SOURCESTAMP}" == "true" ]]; then + require_env SOURCESTAMP_KEYSTORE_PATH + require_env SOURCESTAMP_KEY_ALIAS + require_env SOURCESTAMP_KEYSTORE_PASSWORD + SOURCESTAMP_KEY_PASSWORD="${SOURCESTAMP_KEY_PASSWORD:-${SOURCESTAMP_KEYSTORE_PASSWORD}}" +fi + +for apk in "${APKS[@]}"; do + base="${apk%-unsigned.apk}" + aligned="${base}-aligned.apk" + signed="${base}-signed.apk" + + echo "Aligning ${apk}" + "${BT_DIR}/zipalign" -p -f 4 "${apk}" "${aligned}" + + sign_args=( + sign + --ks "${SIGNING_KEYSTORE_PATH}" + --ks-key-alias "${SIGNING_KEY_ALIAS}" + --ks-pass "pass:${SIGNING_KEYSTORE_PASSWORD}" + --key-pass "pass:${SIGNING_KEY_PASSWORD}" + --out "${signed}" + ) + + if [[ "${ENABLE_SOURCESTAMP}" == "true" ]]; then + sign_args+=( + --stamp-signer + --stamp-ks "${SOURCESTAMP_KEYSTORE_PATH}" + --stamp-key-alias "${SOURCESTAMP_KEY_ALIAS}" + --stamp-ks-pass "pass:${SOURCESTAMP_KEYSTORE_PASSWORD}" + --stamp-key-pass "pass:${SOURCESTAMP_KEY_PASSWORD}" + ) + fi + + echo "Signing ${aligned}" + "${BT_DIR}/apksigner" "${sign_args[@]}" "${aligned}" + + echo "Verifying ${signed}" + "${BT_DIR}/apksigner" verify --verbose --print-certs "${signed}" +done + +echo "Done."