From 313693dc0b353b842fe22724e33ead614d933fda Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 28 Sep 2026 15:15:56 +0000 Subject: [PATCH 1/3] Add Android CI that runs the tests on an emulator Cross-compile the test suite with the official Swift SDK for Android (Swift 6.3.3, checksum from swift.org) and run it on an x86_64 API 28 emulator via reactivecircus/android-emulator-runner. The host toolchain is downloaded from swift.org and its signature verified, and the NDK preinstalled on the runner (r27d) is linked into the SDK. utils/android_test.sh does the building, staging and running, so the same steps work locally against any emulator or device reachable via adb (make test-android). It stages the test runner with the Swift runtime libraries from the SDK and libc++_shared.so from the NDK, pushes them to /data/local/tmp and propagates the XCTest exit code. ExecuteOnBackgroundThread.swift now imports the Android module: the Swift SDK for Android exposes bionic as `Android`, not `Glibc`, so the test helper hit its #error on Android. --- .github/workflows/android.yml | 74 ++++++++ CONTRIBUTING.md | 17 ++ Makefile | 4 + README.md | 1 + .../Helpers/ExecuteOnBackgroundThread.swift | 4 +- utils/android_test.sh | 178 ++++++++++++++++++ 6 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/android.yml create mode 100755 utils/android_test.sh diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..454c5b3cb --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,74 @@ +name: Android + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + android_test: + name: Execute tests on Android + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + swift_version: "6.3.3" + android_sdk_checksum: "d160cc3206dd1886dae3fef2337af5e25ec034692cd0ec225721c56cc69da7f5" + android_api_level: 28 + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + defaults: + run: + shell: bash + env: + SWIFT_VERSION: ${{ matrix.swift_version }} + SWIFT_ANDROID_SDK_CHECKSUM: ${{ matrix.android_sdk_checksum }} + ANDROID_API_LEVEL: ${{ matrix.android_api_level }} + ANDROID_ARCH: x86_64 + steps: + - uses: actions/checkout@v6 + - name: Free disk space for the Android emulator + run: | + sudo rm -rf /opt/microsoft /opt/google /opt/az /opt/ghc /usr/share/dotnet \ + /usr/local/share/boost /opt/hostedtoolcache /usr/local/share/chromium + df -h / + - name: Install Swift toolchain + run: | + tag="swift-${SWIFT_VERSION}-RELEASE" + url="https://download.swift.org/swift-${SWIFT_VERSION}-release/ubuntu2404/${tag}/${tag}-ubuntu24.04.tar.gz" + cd "$RUNNER_TEMP" + curl -fsSL --retry 3 -o swift.tar.gz "$url" + curl -fsSL --retry 3 -o swift.tar.gz.sig "$url.sig" + curl -fsSL --retry 3 https://www.swift.org/keys/all-keys.asc | gpg --batch --import + gpg --batch --verify swift.tar.gz.sig swift.tar.gz + mkdir swift + tar -xzf swift.tar.gz -C swift --strip-components=1 + rm swift.tar.gz swift.tar.gz.sig + echo "$RUNNER_TEMP/swift/usr/bin" >> "$GITHUB_PATH" + - name: Swift version + run: swift --version + - name: Install Swift SDK for Android + run: | + swift sdk install \ + "https://download.swift.org/swift-${SWIFT_VERSION}-release/android-sdk/swift-${SWIFT_VERSION}-RELEASE/swift-${SWIFT_VERSION}-RELEASE_android.artifactbundle.tar.gz" \ + --checksum "$SWIFT_ANDROID_SDK_CHECKSUM" + # Links the Android NDK preinstalled on the runner ($ANDROID_NDK_HOME) + # into the Swift SDK before building. + - name: Build tests for Android + run: bash utils/android_test.sh build + - name: Enable KVM for the Android emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run tests on Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.android_api_level }} + arch: ${{ env.ANDROID_ARCH }} + script: bash utils/android_test.sh run diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16d1df285..1568382cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,23 @@ Or enable the `-DOPENCOMBINE_COMPATIBILITY_TEST` compiler flag in Xcode's build Run `bash utils/lint.sh origin/main` to check SwiftLint violations introduced by your changes. The script uses the base revision as a temporary baseline for existing violations. +#### Testing on Android + +The Android CI job builds the tests with the +[Swift SDK for Android](https://www.swift.org/documentation/articles/swift-sdk-for-android-getting-started.html) +and runs them on an Android emulator. To do the same locally: + +1. Install a swift.org toolchain (Xcode's toolchain can't cross-compile for Android), the Swift SDK + for Android of the same version, and the Android NDK (r27d or later). Point `ANDROID_NDK_HOME` + at the NDK. +1. Start an emulator or connect a device running Android 9 (API level 28) or later. + Use an x86_64 system image on Intel and Linux hosts, and an arm64 one on Apple silicon. +1. Run `make test-android`. + +`bash utils/android_test.sh build` and `bash utils/android_test.sh run` run the two steps +separately. `run` accepts XCTest filters, for example +`bash utils/android_test.sh run OpenCombineTests.JustTests`. + #### Releasing a new version 1. Create a new branch from master and call it `release/..`. diff --git a/Makefile b/Makefile index f8823ee07..79f499efb 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ test-debug-sanitize-thread: test-release: $(SWIFT_EXE) test -c release $(SWIFT_BUILD_FLAGS) $(SWIFT_TEST_FLAGS) +test-android: + bash utils/android_test.sh + swift-version: $(SWIFT_EXE) -version @@ -41,6 +44,7 @@ clean: .PHONY: debug release \ test-debug \ test-release \ + test-android \ swift-version \ test-compatibility-debug \ library-evolution \ diff --git a/README.md b/README.md index 796f4b965..8b9cddf1d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The documentation of the package can be found at [OpenCombine Documentation](htt |[![Ubuntu](https://github.com/OpenSwiftUIProject/OpenCombine/actions/workflows/ubuntu.yml/badge.svg)](https://github.com/OpenSwiftUIProject/OpenCombine/actions/workflows/ubuntu.yml)| |[![Windows](https://github.com/OpenSwiftUIProject/OpenCombine/actions/workflows/windows.yml/badge.svg)](https://github.com/OpenSwiftUIProject/OpenCombine/actions/workflows/windows.yml)| |[![Wasm](https://github.com/OpenSwiftUIProject/OpenCombine/actions/workflows/wasm.yml/badge.svg)](https://github.com/OpenSwiftUIProject/OpenCombine/actions/workflows/wasm.yml)| +|[![Android](https://github.com/OpenSwiftUIProject/OpenCombine/actions/workflows/android.yml/badge.svg)](https://github.com/OpenSwiftUIProject/OpenCombine/actions/workflows/android.yml)| ### Installation diff --git a/Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift b/Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift index 10c04d6dd..276b50fd8 100644 --- a/Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift +++ b/Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift @@ -11,6 +11,8 @@ import Darwin #elseif canImport(Glibc) import Glibc +#elseif canImport(Android) +import Android #elseif os(Windows) import WinSDK #else @@ -28,7 +30,7 @@ func executeOnBackgroundThread( #if canImport(Darwin) typealias ThreadHandle = UnsafeMutablePointer -#elseif canImport(Glibc) +#elseif canImport(Glibc) || canImport(Android) typealias ThreadHandle = UnsafeMutablePointer #elseif os(Windows) typealias ThreadHandle = HANDLE? diff --git a/utils/android_test.sh b/utils/android_test.sh new file mode 100755 index 000000000..e14189672 --- /dev/null +++ b/utils/android_test.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# +# Cross-compiles the tests with the Swift SDK for Android and runs them on an +# Android device or emulator through adb. + +set -euo pipefail + +usage() { + cat <&2; } +fatal() { printf '** error: %s\n' "$*" >&2; exit 1; } + +host_arch() { + case "$(uname -m)" in + x86_64 | amd64) echo x86_64 ;; + arm64 | aarch64) echo aarch64 ;; + *) fatal "unsupported host architecture $(uname -m), set ANDROID_ARCH" ;; + esac +} + +device_arch() { + local abi + command -v adb > /dev/null || fatal "adb not found in PATH" + abi=$(adb shell getprop ro.product.cpu.abi | tr -d '\r') || + fatal "no Android device or emulator available" + case "$abi" in + x86_64) echo x86_64 ;; + arm64-v8a) echo aarch64 ;; + *) fatal "unsupported device ABI $abi" ;; + esac +} + +stage_dir() { + echo "$repo_root/.build/android-test/$1-unknown-linux-android$api_level/$test_dir" +} + +# The Swift SDK for Android must match the host toolchain exactly, so look for +# the bundle named after the toolchain tag, e.g. swift-6.3.3-RELEASE_android. +find_sdk_bundle() { + if [ -n "${SWIFT_ANDROID_SDK_BUNDLE:-}" ]; then + echo "$SWIFT_ANDROID_SDK_BUNDLE" + return + fi + local tag dir + tag=$(swift --version 2> /dev/null | sed -n 's/.*(\(swift-[^)]*\)).*/\1/p' | head -n 1) + [ -n "$tag" ] || + fatal "cannot determine the host toolchain version, use a swift.org toolchain" + for dir in \ + ${XDG_CONFIG_HOME:+"$XDG_CONFIG_HOME/swiftpm/swift-sdks"} \ + "$HOME/.swiftpm/swift-sdks" \ + "$HOME/.config/swiftpm/swift-sdks" \ + "$HOME/Library/org.swift.swiftpm/swift-sdks"; do + if [ -d "$dir/${tag}_android.artifactbundle" ]; then + echo "$dir/${tag}_android.artifactbundle" + return + fi + done + fatal "Swift SDK for Android matching $tag is not installed, see" \ + "https://www.swift.org/documentation/articles/swift-sdk-for-android-getting-started.html" +} + +build() { + local arch=$1 + shift + local triple="$arch-unknown-linux-android$api_level" + local bundle sdk bin_path stage runners + + bundle=$(find_sdk_bundle) + sdk="$bundle/swift-android" + if [ ! -d "$sdk/ndk-sysroot" ]; then + [ -n "${ANDROID_NDK_HOME:-}" ] || + fatal "set ANDROID_NDK_HOME to link the Android NDK into $bundle" + log "Linking the Android NDK at $ANDROID_NDK_HOME" + "$sdk/scripts/setup-android-sdk.sh" + fi + + log "Building tests for $triple" + cd "$repo_root" + swift build --build-tests --swift-sdk "$triple" "$@" + bin_path=$(swift build --build-tests --swift-sdk "$triple" "$@" --show-bin-path) + + stage=$(stage_dir "$arch") + log "Staging tests in $stage" + rm -rf "$stage" + mkdir -p "$stage" + shopt -s nullglob + runners=("$bin_path"/*.xctest "$bin_path"/*-test-runner) + [ ${#runners[@]} -gt 0 ] || fatal "no test runner found in $bin_path" + cp "${runners[@]}" "$bin_path"/*.so "$stage" + shopt -u nullglob + cp "$sdk/swift-resources/usr/lib/swift-$arch/android/"*.so "$stage" + cp "$sdk/ndk-sysroot/usr/lib/$arch-linux-android/libc++_shared.so" "$stage" +} + +run() { + local arch=$1 + shift + local remote_dir="$remote_parent/$test_dir" + local stage runners runner arg filters="" + + stage=$(stage_dir "$arch") + [ -d "$stage" ] || fatal "no tests staged in $stage, run the build command first" + shopt -s nullglob + runners=("$stage"/*-test-runner "$stage"/*.xctest) + shopt -u nullglob + [ ${#runners[@]} -gt 0 ] || fatal "no test runner found in $stage" + runner=$(basename "${runners[0]}") + + # Quote the filters for the device shell. + for arg in "$@"; do + filters+=" '${arg//\'/\'\\\'\'}'" + done + + log "Pushing tests to $remote_dir" + adb shell rm -rf "$remote_dir" + adb push "$stage" "$remote_parent" + + log "Running $runner" + adb shell "cd $remote_dir && LD_LIBRARY_PATH=$remote_dir ./$runner$filters" +} + +command=${1:-all} +if [ $# -gt 0 ]; then + shift +fi + +case "$command" in + build) + arch=${ANDROID_ARCH:-$(host_arch)} + build "$arch" "$@" + ;; + run) + arch=${ANDROID_ARCH:-$(device_arch)} + run "$arch" "$@" + ;; + all) + [ $# -eq 0 ] || fatal "pass flags or filters to the build or run command" + arch=${ANDROID_ARCH:-$(device_arch)} + build "$arch" + run "$arch" + ;; + -h | --help) + usage + ;; + *) + usage >&2 + exit 64 + ;; +esac From ac4238ce6b4d807e670cbf02cc6653ff06bdd977 Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 28 Sep 2026 15:29:18 +0000 Subject: [PATCH 2/3] Fix swift.org key import in the Android workflow The first run failed with "gpg: no valid OpenPGP data found": the signing keys arrived compressed. Let curl decode any declared content encoding and pass the result through zcat -f for gzip bytes served without one, as swiftlang/github-workflows does. Also name the job explicitly so the SDK checksum from the matrix no longer shows up in the check name. --- .github/workflows/android.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 454c5b3cb..09d503121 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,7 +8,7 @@ on: jobs: android_test: - name: Execute tests on Android + name: Execute tests on Android (${{ matrix.swift_version }}, API ${{ matrix.android_api_level }}) permissions: contents: read strategy: @@ -43,7 +43,9 @@ jobs: cd "$RUNNER_TEMP" curl -fsSL --retry 3 -o swift.tar.gz "$url" curl -fsSL --retry 3 -o swift.tar.gz.sig "$url.sig" - curl -fsSL --retry 3 https://www.swift.org/keys/all-keys.asc | gpg --batch --import + # The CDN may serve the keys compressed; zcat -f passes plain text through. + curl -fsSL --retry 3 --compressed https://www.swift.org/keys/all-keys.asc \ + | zcat -f | gpg --batch --import gpg --batch --verify swift.tar.gz.sig swift.tar.gz mkdir swift tar -xzf swift.tar.gz -C swift --strip-components=1 From b584daa38affc6a0538434667f565bb86e6ed98e Mon Sep 17 00:00:00 2001 From: Kyle Date: Mon, 28 Sep 2026 15:39:08 +0000 Subject: [PATCH 3/3] Fix the Android build of the C++ helpers and test helper The Android job failed to compile ExecuteOnBackgroundThread.swift: pthread_create's start routine takes `void* _Nonnull` in the NDK r27 headers but `void* _Nullable` from r28 on, and on Glibc. Bind the argument as an optional so the closure compiles with either. Link libc++_shared for COpenCombineHelpers on Android. SwiftPM adds -lstdc++ for C++ targets there, but Android's libstdc++ only provides operator new/delete, so std::system_error, std::terminate and the exception runtime the helpers use would be left undefined. --- Package.swift | 7 ++++++- .../Helpers/ExecuteOnBackgroundThread.swift | 11 +++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Package.swift b/Package.swift index 1afd032ac..676f459e7 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,12 @@ let supportedPlatforms: [Platform] = [ ] let cOpenCombineHelpersTarget: Target = .target( - name: "COpenCombineHelpers" + name: "COpenCombineHelpers", + linkerSettings: [ + // SwiftPM links libstdc++ for C++ targets, which on Android is only a stub + // for operator new/delete. The C++ standard library there is libc++_shared. + .linkedLibrary("c++_shared", .when(platforms: [.android])), + ] ) let openCombineShimTarget: Target = .target( name: "OpenCombineShim", diff --git a/Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift b/Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift index 276b50fd8..19d7e361d 100644 --- a/Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift +++ b/Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift @@ -86,12 +86,11 @@ func executeOnBackgroundThread( _backgroundThread, nil, { context in -#if canImport(Darwin) - let context = context -#else - let context = context! -#endif - return context + // Optional with Glibc and Android NDK r28+, but non-optional on + // Darwin and with Android NDK r27. + let context: UnsafeMutableRawPointer? = context + + return context! .assumingMemoryBound(to: ThreadRoutine.self) .pointee() },