diff --git a/.gitea/workflows/build-android.yml b/.gitea/workflows/build-android.yml new file mode 100644 index 0000000..96ae212 --- /dev/null +++ b/.gitea/workflows/build-android.yml @@ -0,0 +1,192 @@ +name: Build Android APK + +on: + push: + branches: [master] + tags: ['v*'] + paths: + - 'android/**' + - 'server/src/ledgrab/**' + - '.gitea/workflows/build-android.yml' + workflow_dispatch: + inputs: + version: + description: 'Version label (e.g. dev, 0.3.0-test)' + required: false + default: 'dev' + +jobs: + build-android: + runs-on: ubuntu-latest + env: + JAVA_VERSION: '17' + PYTHON_VERSION: '3.11' + ANDROID_CMDLINE_TOOLS_VERSION: '11076708' + ANDROID_SDK_PLATFORM: 'android-34' + ANDROID_BUILD_TOOLS: '34.0.0' + ANDROID_NDK_VERSION: '26.1.10909125' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve build label + id: label + run: | + REF="${{ gitea.ref_name }}" + if echo "$REF" | grep -qE '^v[0-9]'; then + LABEL="${REF#v}" + IS_RELEASE="true" + elif [ -n "${{ inputs.version }}" ]; then + LABEL="${{ inputs.version }}" + IS_RELEASE="false" + else + LABEL="dev-${{ gitea.sha }}" + IS_RELEASE="false" + fi + LABEL="${LABEL:0:40}" + echo "label=$LABEL" >> "$GITHUB_OUTPUT" + echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + echo "Build label: $LABEL (release=$IS_RELEASE)" + + - name: Setup JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Setup Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Setup Android SDK + NDK + run: | + set -euo pipefail + SDK_ROOT="$HOME/android-sdk" + mkdir -p "$SDK_ROOT/cmdline-tools" + cd "$SDK_ROOT/cmdline-tools" + curl -sSL --retry 3 \ + -o cmdline-tools.zip \ + "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" + unzip -q cmdline-tools.zip + rm cmdline-tools.zip + mv cmdline-tools latest + + export ANDROID_SDK_ROOT="$SDK_ROOT" + export ANDROID_HOME="$SDK_ROOT" + SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + + yes | "$SDKMANAGER" --licenses > /dev/null 2>&1 || true + "$SDKMANAGER" --install \ + "platform-tools" \ + "platforms;${ANDROID_SDK_PLATFORM}" \ + "build-tools;${ANDROID_BUILD_TOOLS}" \ + "ndk;${ANDROID_NDK_VERSION}" > /dev/null + + echo "ANDROID_SDK_ROOT=$SDK_ROOT" >> "$GITHUB_ENV" + echo "ANDROID_HOME=$SDK_ROOT" >> "$GITHUB_ENV" + echo "ANDROID_NDK_HOME=$SDK_ROOT/ndk/${ANDROID_NDK_VERSION}" >> "$GITHUB_ENV" + echo "$SDK_ROOT/platform-tools" >> "$GITHUB_PATH" + echo "$SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH" + + - name: Create local.properties + run: | + echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties + + - name: Link Python source (junction equivalent) + run: | + # Chaquopy reads Python modules from android/app/src/main/python/ + # On Windows dev machines this is a directory junction; on Linux CI use a symlink. + ln -sfn "$(pwd)/server/src/ledgrab" android/app/src/main/python/ledgrab + ls -la android/app/src/main/python/ + + - name: Decode signing keystore + id: keystore + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + mkdir -p android/keystore + echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/keystore/release.jks + echo "path=$(pwd)/android/keystore/release.jks" >> "$GITHUB_OUTPUT" + echo "present=true" >> "$GITHUB_OUTPUT" + + - name: Build APK + working-directory: android + env: + ANDROID_KEYSTORE_PATH: ${{ steps.keystore.outputs.path }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + chmod +x gradlew + if [ "${{ steps.keystore.outputs.present }}" = "true" ] && [ "${{ steps.label.outputs.is_release }}" = "true" ]; then + echo "Building signed release APK" + ./gradlew --no-daemon assembleRelease + else + echo "Building debug APK (no signing keystore available or not a release tag)" + ./gradlew --no-daemon assembleDebug + fi + + - name: Locate and rename APK + id: apk + run: | + set -euo pipefail + SRC=$(ls android/app/build/outputs/apk/release/*.apk 2>/dev/null | head -1 || true) + if [ -z "$SRC" ]; then + SRC=$(ls android/app/build/outputs/apk/debug/*.apk | head -1) + VARIANT="debug" + else + VARIANT="release" + fi + DEST="build/LedGrab-${{ steps.label.outputs.label }}-android-${VARIANT}.apk" + mkdir -p build + cp "$SRC" "$DEST" + echo "path=$DEST" >> "$GITHUB_OUTPUT" + echo "name=$(basename "$DEST")" >> "$GITHUB_OUTPUT" + ls -lh "$DEST" + + - name: Upload APK artifact + uses: actions/upload-artifact@v3 + with: + name: LedGrab-${{ steps.label.outputs.label }}-android + path: ${{ steps.apk.outputs.path }} + retention-days: 90 + + - name: Attach APK to Gitea release + if: ${{ steps.label.outputs.is_release == 'true' }} + env: + GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ gitea.ref_name }}" + BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" + APK_PATH="${{ steps.apk.outputs.path }}" + APK_NAME="${{ steps.apk.outputs.name }}" + + # Fetch release by tag (created by release.yml `create-release` job) + RELEASE_ID=$(curl -s "$BASE_URL/releases/tags/$TAG" \ + -H "Authorization: token $GITEA_TOKEN" \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") + if [ -z "$RELEASE_ID" ]; then + echo "::warning::No release found for tag $TAG — skipping asset upload" + exit 0 + fi + + # Replace existing asset if present (re-run safety) + EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \ + -H "Authorization: token $GITEA_TOKEN" \ + | python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$APK_NAME'),''))" 2>/dev/null) + if [ -n "$EXISTING_ID" ]; then + curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \ + -H "Authorization: token $GITEA_TOKEN" + fi + + curl -s -X POST \ + "$BASE_URL/releases/$RELEASE_ID/assets?name=$APK_NAME" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$APK_PATH" + echo "Uploaded: $APK_NAME" diff --git a/TODO.md b/TODO.md index a42dd1b..a7973f6 100644 --- a/TODO.md +++ b/TODO.md @@ -4,13 +4,10 @@ During emulator testing, we switched the build to **x86 only** (see `android/app/build.gradle.kts` `abiFilters`) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs: -- [ ] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings: - - `arm64-v8a` (real TV boxes — the primary target) - - `x86_64` (modern emulators) - - `x86` (legacy emulators) -- [ ] Verify wheels with `readelf -d`: SONAME must be `libpython3.11.so` in NEEDED -- [ ] Restore `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.gradle.kts` -- [ ] Re-test on real ARM64 Android TV hardware +- [x] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings (`android/build-scripts/build-pydantic-core.sh` — now supports `arm64`, `x86_64`, `x86` args; defaults to all three). +- [x] Verify wheels: all three now list `libpython3.11.so` in `NEEDED` (`llvm-readelf -d`), automated in the build script. +- [x] Restored `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.gradle.kts`. Multi-ABI debug APK builds cleanly (~99 MB). +- [ ] Re-test on real ARM64 Android TV hardware (still pending — only emulator-verified build). Build cache + scripts live in `android/build-scripts/` and `android/.build-cache/` (junction host + sysconfigdata for each ABI). @@ -18,16 +15,22 @@ Build cache + scripts live in `android/build-scripts/` and `android/.build-cache Build the Android APK automatically on push/tag. -- [ ] Generate Gradle wrapper (`gradlew`) and commit it -- [ ] Create CI workflow (`.gitea/workflows/build-android.yaml` or `.github/workflows/`) +- [x] Generate Gradle wrapper (`gradlew`) and commit it +- [x] Create CI workflow (`.gitea/workflows/build-android.yml`) - JDK 17 + Android SDK + NDK setup - Python 3.11 for Chaquopy build - - Recreate the directory junction (`ln -s` on Linux, `mklink /J` on Windows) - - `./gradlew assembleRelease` - - Upload APK as artifact -- [ ] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64) -- [ ] APK signing for release builds (keystore setup) -- [ ] Consider: publish APK to GitHub/Gitea releases on tag push + - Recreate the directory junction via `ln -s` on Linux CI + - `./gradlew assembleDebug` on master push, `assembleRelease` on `v*` tags (if signing secrets set) + - Uploads APK as CI artifact; attaches to Gitea release on tag push +- [x] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64) +- [x] APK signing for release builds — conditional signing config reads keystore from env vars (`ANDROID_KEYSTORE_PATH/_PASSWORD/_ALIAS/_KEY_PASSWORD`), falls back to debug signing locally +- [ ] Provision a real keystore and add the four CI secrets: + - `ANDROID_KEYSTORE_BASE64` (base64-encoded .jks) + - `ANDROID_KEYSTORE_PASSWORD` + - `ANDROID_KEY_ALIAS` + - `ANDROID_KEY_PASSWORD` +- [ ] Add `LedGrab-{tag}-android-release.apk` row to the release description table in `.gitea/workflows/release.yml` → `create-release` job +- [ ] Verify the CI workflow passes end-to-end with the now-restored multi-ABI build (larger APK, longer Android build step) ## Android Root Capture (No Permission Dialog, No System Indicator) diff --git a/android/.gitignore b/android/.gitignore index cda6433..0fc55fb 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -15,3 +15,6 @@ local.properties # Python source junction (points at ../server/src/ledgrab — do not commit) /app/src/main/python/ledgrab + +# Signing keystore decoded from CI secrets +/keystore/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3217dbd..b2be883 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,14 +16,41 @@ android { versionName = "0.3.0" ndk { - // Temporarily x86 only for emulator testing with new wheel - abiFilters += listOf("x86") + // All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern + // emulators), x86 (legacy emulators). Wheels in android/wheels/ + // must be kept in sync — see build-scripts/build-pydantic-core.sh. + abiFilters += listOf("arm64-v8a", "x86_64", "x86") + } + } + + // Signing config from env vars (CI) — only registered when all four are set. + // Local release builds fall back to the debug signing config. + val ciKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH") + val ciKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") + val ciKeyAlias = System.getenv("ANDROID_KEY_ALIAS") + val ciKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") + val hasCiSigning = listOf(ciKeystorePath, ciKeystorePassword, ciKeyAlias, ciKeyPassword) + .all { !it.isNullOrBlank() } && file(ciKeystorePath!!).exists() + + signingConfigs { + if (hasCiSigning) { + create("release") { + storeFile = file(ciKeystorePath!!) + storePassword = ciKeystorePassword + keyAlias = ciKeyAlias + keyPassword = ciKeyPassword + } } } buildTypes { release { isMinifyEnabled = false + signingConfig = if (hasCiSigning) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } diff --git a/android/build-scripts/build-pydantic-core.sh b/android/build-scripts/build-pydantic-core.sh index 8382452..d8e226c 100644 --- a/android/build-scripts/build-pydantic-core.sh +++ b/android/build-scripts/build-pydantic-core.sh @@ -1,98 +1,198 @@ #!/usr/bin/env bash # -# Cross-compile pydantic-core for Android ARM64. +# Cross-compile pydantic-core for Android across all three ABIs: +# arm64-v8a (primary — real TV hardware) +# x86_64 (modern emulators) +# x86 (legacy emulators) # -# Prerequisites: -# - Rust toolchain (rustup) -# - Android NDK (set ANDROID_NDK_HOME or let this script find it) +# Outputs wheels into android/wheels/. Wheels are linked against the real +# libpython3.11.so shipped by Chaquopy (stub .so does NOT work — see +# memory/project_android_app.md for the incident notes). +# +# Prerequisites (on host): +# - Rust + cargo (rustup) with targets: aarch64/x86_64/i686-linux-android +# - Android NDK (ANDROID_NDK_HOME, or installed at Sdk/ndk/*) +# - Python 3.11 (matches Chaquopy's embedded version) # - maturin (pip install maturin) -# - Python 3.11 (matching Chaquopy's embedded version) # -# Output: ../wheels/pydantic_core-*.whl +# Rebuild cadence: whenever PYDANTIC_CORE_VERSION changes, or pydantic's +# core dependency version changes. +# +# Usage: +# ./build-pydantic-core.sh # build all three ABIs +# ./build-pydantic-core.sh arm64 # build a single ABI +# ./build-pydantic-core.sh arm64 x86_64 # build a subset # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WHEELS_DIR="$SCRIPT_DIR/../wheels" -BUILD_DIR="$SCRIPT_DIR/../.build-cache" +ANDROID_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +WHEELS_DIR="$ANDROID_DIR/wheels" +BUILD_DIR="$ANDROID_DIR/.build-cache" -# ── Pydantic-core version (must match pydantic>=2.9.2 requirement) ── -PYDANTIC_CORE_VERSION="2.27.2" +PYDANTIC_CORE_VERSION="2.46.0" +API_LEVEL=24 +PY_VERSION="3.11" + +# Resolve a concrete python3.11 interpreter path (maturin needs it callable). +# Windows Git Bash doesn't ship `python3.11` on PATH; fall back to `py -3.11`. +if [ -z "${PYTHON_BIN:-}" ]; then + # Prefer `py -3.11` on Windows (Git Bash's `python3.11` resolves to a + # non-functional MSStore stub). Fall back to `python3.11` on Linux/macOS. + if command -v py >/dev/null 2>&1 && py -3.11 -c '' 2>/dev/null; then + PYTHON_BIN="$(py -3.11 -c 'import sys; print(sys.executable)' 2>/dev/null | tr -d '\r')" + elif command -v python3.11 >/dev/null 2>&1 && python3.11 -c '' 2>/dev/null; then + PYTHON_BIN="$(command -v python3.11)" + fi +fi +[ -n "${PYTHON_BIN:-}" ] || { echo "ERROR: python3.11 not found; set PYTHON_BIN"; exit 1; } +echo "Python: $PYTHON_BIN" + +mkdir -p "$WHEELS_DIR" # ── Find Android NDK ──────────────────────────────────────────────── if [ -z "${ANDROID_NDK_HOME:-}" ]; then - # Try common locations for candidate in \ "$HOME/Library/Android/sdk/ndk"/* \ "$HOME/Android/Sdk/ndk"/* \ - "$LOCALAPPDATA/Android/Sdk/ndk"/* \ - "/usr/local/lib/android/sdk/ndk"/*; do + "${LOCALAPPDATA:-}/Android/Sdk/ndk"/* \ + "/c/Users/$(whoami)/AppData/Local/Android/Sdk/ndk"/* \ + "/usr/local/lib/android/sdk/ndk"/* \ + "${ANDROID_SDK_ROOT:-}/ndk"/*; do if [ -d "$candidate" ]; then ANDROID_NDK_HOME="$candidate" break fi done fi +[ -n "${ANDROID_NDK_HOME:-}" ] || { echo "ERROR: NDK not found; set ANDROID_NDK_HOME"; exit 1; } +echo "NDK: $ANDROID_NDK_HOME" -if [ -z "${ANDROID_NDK_HOME:-}" ]; then - echo "ERROR: Android NDK not found. Set ANDROID_NDK_HOME or install via Android Studio." - exit 1 -fi -echo "Using Android NDK: $ANDROID_NDK_HOME" - -# ── Determine NDK API level and toolchain ─────────────────────────── -API_LEVEL=24 -HOST_TAG="" case "$(uname -s)" in - Linux*) HOST_TAG="linux-x86_64" ;; - Darwin*) HOST_TAG="darwin-x86_64" ;; + Linux*) HOST_TAG="linux-x86_64" ;; + Darwin*) HOST_TAG="darwin-x86_64" ;; MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;; *) echo "Unsupported host OS"; exit 1 ;; esac - TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG" -CC="$TOOLCHAIN/bin/aarch64-linux-android${API_LEVEL}-clang" -AR="$TOOLCHAIN/bin/llvm-ar" +READELF="$TOOLCHAIN/bin/llvm-readelf" -if [ ! -f "$CC" ] && [ ! -f "${CC}.cmd" ]; then - echo "ERROR: NDK compiler not found at $CC" - echo "Check your NDK installation." - exit 1 +# ── Prepare source tree ───────────────────────────────────────────── +SRC_DIR="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION" +if [ ! -d "$SRC_DIR" ]; then + echo "Downloading pydantic_core $PYDANTIC_CORE_VERSION sdist..." + mkdir -p "$BUILD_DIR" + SDIST="$BUILD_DIR/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz" + [ -f "$SDIST" ] || curl -sSL --retry 3 -o "$SDIST" \ + "https://files.pythonhosted.org/packages/source/p/pydantic_core/pydantic_core-$PYDANTIC_CORE_VERSION.tar.gz" + tar -xzf "$SDIST" -C "$BUILD_DIR" fi -# ── Install Rust Android target ───────────────────────────────────── -echo "Adding Rust target aarch64-linux-android..." -rustup target add aarch64-linux-android +# ── ABI table ─────────────────────────────────────────────────────── +# Columns: short_name rust_target clang_prefix sysconfig_dir +ABI_TABLE=( + "arm64 aarch64-linux-android aarch64-linux-android${API_LEVEL} cross-sysconfig" + "x86_64 x86_64-linux-android x86_64-linux-android${API_LEVEL} cross-sysconfig-x86_64" + "x86 i686-linux-android i686-linux-android${API_LEVEL} cross-sysconfig-x86" +) -# ── Configure Cargo for cross-compilation ─────────────────────────── -mkdir -p "$BUILD_DIR" -export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$CC" -export CC_aarch64_linux_android="$CC" -export AR_aarch64_linux_android="$AR" +declare -A ABI_TAG_MAP=( + [arm64]="arm64_v8a" + [x86_64]="x86_64" + [x86]="x86" +) -# ── Clone pydantic-core ───────────────────────────────────────────── -REPO_DIR="$BUILD_DIR/pydantic-core" -if [ -d "$REPO_DIR" ]; then - echo "Updating existing pydantic-core checkout..." - cd "$REPO_DIR" - git fetch --tags - git checkout "v$PYDANTIC_CORE_VERSION" -else - echo "Cloning pydantic-core v$PYDANTIC_CORE_VERSION..." - git clone --depth 1 --branch "v$PYDANTIC_CORE_VERSION" \ - https://github.com/pydantic/pydantic-core.git "$REPO_DIR" - cd "$REPO_DIR" +# ── Select which ABIs to build ────────────────────────────────────── +SELECTED=("$@") +if [ ${#SELECTED[@]} -eq 0 ]; then + SELECTED=(arm64 x86_64 x86) fi -# ── Build with maturin ────────────────────────────────────────────── -echo "Building pydantic-core for aarch64-linux-android..." -maturin build \ - --release \ - --target aarch64-linux-android \ - --interpreter python3.11 \ - --out "$WHEELS_DIR" +# ── Ensure rust targets are installed ─────────────────────────────── +for name in "${SELECTED[@]}"; do + for row in "${ABI_TABLE[@]}"; do + read -r sname rtarget _ _ <<<"$row" + if [ "$sname" = "$name" ]; then + rustup target add "$rtarget" >/dev/null 2>&1 || true + fi + done +done + +# ── Build loop ────────────────────────────────────────────────────── +cd "$SRC_DIR" + +for name in "${SELECTED[@]}"; do + MATCHED="" + for row in "${ABI_TABLE[@]}"; do + read -r sname rtarget cprefix sysdir <<<"$row" + if [ "$sname" = "$name" ]; then + MATCHED="1" + break + fi + done + [ -n "$MATCHED" ] || { echo "Unknown ABI: $name"; exit 1; } + + CROSS_LIB_DIR="$BUILD_DIR/$sysdir" + [ -f "$CROSS_LIB_DIR/libpython3.11.so" ] || { + echo "ERROR: missing $CROSS_LIB_DIR/libpython3.11.so (extract from a Chaquopy APK)" + exit 1 + } + + # On Windows hosts the clang wrapper is a .cmd batch file; cargo/rustc + # can't exec it as a linker directly (os error 193). Use the .cmd path + # explicitly so CreateProcess picks up cmd.exe as interpreter. + CC_BIN="$TOOLCHAIN/bin/${cprefix}-clang" + if [ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${CC_BIN}.cmd" ]; then + CC_BIN="${CC_BIN}.cmd" + fi + AR_BIN="$TOOLCHAIN/bin/llvm-ar" + [ "$HOST_TAG" = "windows-x86_64" ] && [ -f "${AR_BIN}.exe" ] && AR_BIN="${AR_BIN}.exe" + [ -f "$CC_BIN" ] || { echo "ERROR: $CC_BIN not found"; exit 1; } + + # Normalize rust target name for env var (cargo wants UPPER_WITH_UNDERSCORES) + TARGET_ENV=$(echo "$rtarget" | tr 'a-z-' 'A-Z_') + + echo "" + echo "════════════════════════════════════════════════════════════" + echo " Building pydantic_core $PYDANTIC_CORE_VERSION for $name ($rtarget)" + echo "════════════════════════════════════════════════════════════" + + env \ + CARGO_TARGET_DIR="$SRC_DIR/target" \ + "CC_${rtarget//-/_}=$CC_BIN" \ + "AR_${rtarget//-/_}=$AR_BIN" \ + "CARGO_TARGET_${TARGET_ENV}_LINKER=$CC_BIN" \ + PYO3_CROSS=1 \ + PYO3_CROSS_LIB_DIR="$CROSS_LIB_DIR" \ + PYO3_CROSS_PYTHON_VERSION="$PY_VERSION" \ + RUSTFLAGS="-C link-arg=-Wl,--no-as-needed -C link-arg=-lpython3.11 -C link-arg=-L$CROSS_LIB_DIR" \ + maturin build \ + --release \ + --target "$rtarget" \ + --interpreter "$PYTHON_BIN" \ + --out "$WHEELS_DIR" \ + --compatibility linux + + # maturin writes the wheel with the correct android__ platform + # tag directly (the sysconfigdata provides MULTIARCH). Find + verify. + ABI_TAG="${ABI_TAG_MAP[$name]}" + OUT_WHL="$WHEELS_DIR/pydantic_core-$PYDANTIC_CORE_VERSION-cp311-cp311-android_${API_LEVEL}_${ABI_TAG}.whl" + [ -f "$OUT_WHL" ] || { echo "ERROR: expected wheel not found: $OUT_WHL"; exit 1; } + + TMP=$(mktemp -d) + unzip -qo "$OUT_WHL" -d "$TMP" "pydantic_core/*.so" + SO=$(find "$TMP" -name "*.so" | head -1) + if "$READELF" -d "$SO" | grep -q "libpython3.11.so"; then + echo " NEEDED OK: libpython3.11.so present in $(basename "$OUT_WHL")" + else + echo " ERROR: libpython3.11.so NOT in NEEDED — wheel will crash at import" + "$READELF" -d "$SO" | grep NEEDED + rm -rf "$TMP" + exit 1 + fi + rm -rf "$TMP" +done echo "" echo "=== Build complete ===" -echo "Wheels:" -ls -la "$WHEELS_DIR"/pydantic_core-*.whl 2>/dev/null || echo "WARNING: No wheel found!" +ls -lh "$WHEELS_DIR"/pydantic_core-*.whl diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_arm64_v8a.whl b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_arm64_v8a.whl index 03718e7..daae174 100644 Binary files a/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_arm64_v8a.whl and b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_arm64_v8a.whl differ diff --git a/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86_64.whl b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86_64.whl index b19e265..acbc162 100644 Binary files a/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86_64.whl and b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86_64.whl differ diff --git a/contexts/ci-cd.md b/contexts/ci-cd.md index e2aaed6..464a7a1 100644 --- a/contexts/ci-cd.md +++ b/contexts/ci-cd.md @@ -7,7 +7,8 @@ | File | Trigger | Purpose | |------|---------|---------| | `.gitea/workflows/test.yml` | Push/PR to master | Lint (ruff) + pytest | -| `.gitea/workflows/release.yml` | Tag `v*` | Build artifacts + create Gitea release | +| `.gitea/workflows/release.yml` | Tag `v*` | Build Windows/Linux/Docker artifacts + create Gitea release | +| `.gitea/workflows/build-android.yml` | Push master (android/server paths), tag `v*`, manual | Build Android APK; attach to release on tag | ## Release Pipeline (`release.yml`)