Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Android

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
android_test:
name: Execute tests on Android (${{ matrix.swift_version }}, API ${{ matrix.android_api_level }})
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"
# 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
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
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<major>.<minor>.<patch>`.
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -41,6 +44,7 @@ clean:
.PHONY: debug release \
test-debug \
test-release \
test-android \
swift-version \
test-compatibility-debug \
library-evolution \
Expand Down
7 changes: 6 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions Tests/OpenCombineTests/Helpers/ExecuteOnBackgroundThread.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Android)
import Android
#elseif os(Windows)
import WinSDK
#else
Expand All @@ -28,7 +30,7 @@ func executeOnBackgroundThread<ResultType>(

#if canImport(Darwin)
typealias ThreadHandle = UnsafeMutablePointer<pthread_t?>
#elseif canImport(Glibc)
#elseif canImport(Glibc) || canImport(Android)
typealias ThreadHandle = UnsafeMutablePointer<pthread_t>
#elseif os(Windows)
typealias ThreadHandle = HANDLE?
Expand Down Expand Up @@ -84,12 +86,11 @@ func executeOnBackgroundThread<ResultType>(
_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()
},
Expand Down
178 changes: 178 additions & 0 deletions utils/android_test.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
Usage: bash utils/android_test.sh [build [FLAGS...] | run [FILTER...]]

build Build the tests with the Swift SDK for Android and stage the test
runner with the Swift runtime libraries. FLAGS go to 'swift build'.
run Push the staged tests to the connected device or emulator and run
them there. FILTER goes to XCTest, e.g. OpenCombineTests.JustTests.

Without a command, builds and then runs the tests.

Requirements: a swift.org toolchain with the matching Swift SDK for Android
installed, the Android NDK, and for 'run' adb with a device on API 28+.

Environment:
ANDROID_ARCH x86_64 or aarch64 (default: the connected device's
ABI for 'run', the host architecture for 'build')
ANDROID_API_LEVEL API level of the target triple (default: 28)
ANDROID_NDK_HOME NDK to link into the Swift SDK if not linked yet
SWIFT_ANDROID_SDK_BUNDLE Path to the Swift SDK for Android artifact bundle
(default: the one matching the host toolchain)
ANDROID_SERIAL Device to use when several are connected
EOF
}

readonly api_level=${ANDROID_API_LEVEL:-28}
readonly test_dir=OpenCombineTests
readonly remote_parent=/data/local/tmp
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
readonly repo_root

log() { printf '** %s\n' "$*" >&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