diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..f3227c98 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "microsoft.dotnet.xharness.cli": { + "version": "11.0.0-prerelease.26217.1", + "commands": [ + "xharness" + ] + } + } +} diff --git a/.gitattributes b/.gitattributes index d223b22b..7f1904f9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,5 +24,6 @@ *.xml -whitespace *.yml -whitespace *.json -whitespace +*.sh text eol=lf changelog -whitespace GitVersion.yml -whitespace diff --git a/.github/workflows/CI-CD.yml b/.github/workflows/CI-CD.yml index 6bb9e854..75445846 100644 --- a/.github/workflows/CI-CD.yml +++ b/.github/workflows/CI-CD.yml @@ -88,6 +88,85 @@ jobs: output/*.snupkg if: matrix.os == 'ubuntu-latest' + android-build-and-test: + name: "Android Build and Test" + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + fetch-depth: '0' + + - name: Install .NET + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Install Java + uses: actions/setup-java@5896cecc08fd8a1fbdfaf517e29b571164b031f7 # v4.2.0 + with: + distribution: temurin + java-version: 17 + + - name: Install MAUI Android workload + run: dotnet workload install maui-android + + - name: Install Android NDK + run: | + SDK_ROOT="${ANDROID_HOME:-/usr/local/lib/android/sdk}" + echo "ANDROID_HOME=$SDK_ROOT" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=$SDK_ROOT" >> "$GITHUB_ENV" + yes | "$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses || true + "$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --install "ndk;27.0.12077973" + echo "ANDROID_NDK_HOME=$SDK_ROOT/ndk/$(ls "$SDK_ROOT/ndk" | sort -V | tail -1)" >> "$GITHUB_ENV" + + - name: Cache ICU Android build + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: output/android-icu + key: android-icu-${{ runner.os }}-72.1-x86_64-${{ hashFiles('scripts/build-icu-android.sh') }} + restore-keys: | + android-icu-${{ runner.os }}-72.1-x86_64- + + - name: Build ICU for Android + run: bash scripts/build-icu-android.sh --arch=x86_64 + + - name: Build Android test project + run: | + export IcuDotNetIncludeAndroid=true + dotnet build --configuration Release source/icu.net.android.tests/icu.net.android.tests.csproj \ + -f net10.0-android -p:IcuDotNetIncludeAndroid=true + + - name: Enable KVM + 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 Android device tests + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.35.0 + with: + api-level: 30 + arch: x86_64 + target: google_apis + profile: pixel_6 + disable-animations: true + script: bash scripts/ci-android-tests.sh + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: Test Results (android) + path: | + source/icu.net.android.tests/test-results/** + **/TestResults/*.xml + if-no-files-found: warn + publish-nuget: name: "Publish NuGet package" runs-on: ubuntu-22.04 diff --git a/.github/workflows/test-results.yml b/.github/workflows/test-results.yml index 0d70c454..f8d09a7a 100644 --- a/.github/workflows/test-results.yml +++ b/.github/workflows/test-results.yml @@ -40,4 +40,6 @@ jobs: commit: ${{ github.event.workflow_run.head_sha }} event_file: artifacts/Event File/event.json event_name: ${{ github.event.workflow_run.event }} - nunit_files: "artifacts/**/*.xml" + files: | + artifacts/**/*.xml + artifacts/**/*.trx diff --git a/.gitignore b/.gitignore index 41c1184e..1bcfb586 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ build/nuget.exe build/packages/ lib/ output/ +# Explicit marker for Android ICU cross-compile output (also under output/) +output/android-icu/ bin/ obj/ test-results/ diff --git a/scripts/build-icu-android.ps1 b/scripts/build-icu-android.ps1 new file mode 100644 index 00000000..9c6f9732 --- /dev/null +++ b/scripts/build-icu-android.ps1 @@ -0,0 +1,58 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Cross-compile ICU4C for Android (one-time setup, like brew install icu4c on macOS). + +.DESCRIPTION + Invokes scripts/build-icu-android.sh via Git Bash or WSL bash. + Output (gitignored): output/android-icu/{x86_64,arm64-v8a}/libicu*.so* + + Prerequisites: + - Android NDK (ANDROID_NDK_HOME or ANDROID_HOME/ndk/) + - Git Bash or WSL with: bash, curl, tar, make + +.EXAMPLE + .\scripts\build-icu-android.ps1 + .\scripts\build-icu-android.ps1 -Arch x86_64 + .\scripts\build-icu-android.ps1 -Arch x86_64,arm64-v8a +#> +[CmdletBinding()] +param( + [string] $Arch = 'x86_64', + [string] $IcuVersion = '72.1', + [int] $ApiLevel = 21, + [switch] $Clean +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$bashScript = Join-Path $PSScriptRoot 'build-icu-android.sh' + +function Find-Bash { + $candidates = @( + (Get-Command bash -ErrorAction SilentlyContinue)?.Source, + 'C:\Program Files\Git\bin\bash.exe', + 'C:\Program Files (x86)\Git\bin\bash.exe' + ) | Where-Object { $_ -and (Test-Path $_) } + return $candidates | Select-Object -First 1 +} + +$bash = Find-Bash +if (-not $bash) { + Write-Error @" +bash not found. Install Git for Windows or use WSL, then re-run this script. +Alternatively run: bash scripts/build-icu-android.sh --arch=$Arch +"@ +} + +$args = @( + $bashScript + "--arch=$Arch" + "--api=$ApiLevel" + "--icu-version=$IcuVersion" +) +if ($Clean) { $args += '--clean' } + +Write-Host "Running: $bash $($args -join ' ')" +& $bash @args +exit $LASTEXITCODE diff --git a/scripts/build-icu-android.sh b/scripts/build-icu-android.sh new file mode 100644 index 00000000..7a3af479 --- /dev/null +++ b/scripts/build-icu-android.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 SIL Global +# Cross-compile ICU4C for Android (icu-dotnet device tests). +# Adapted from https://github.com/patrickgold/icu4c-android (Apache 2.0). +# +# Prerequisites: bash, curl, tar, make, Android NDK (ANDROID_NDK_HOME or ANDROID_HOME/ndk/*) +# On Windows: run via Git Bash or WSL (scripts/build-icu-android.ps1). +# +# Output: output/android-icu/{x86_64,arm64-v8a}/libicu*.so* + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$REPO_ROOT/output/android-icu" +ICU_VERSION="${ICU_VERSION:-72.1}" +ICU_VERSION_UNDERSCORE="${ICU_VERSION//./_}" +ARCHS="${ARCHS:-x86_64}" +API_LEVEL="${API_LEVEL:-21}" +ICU_SRC_DIR="${ICU_SRC_DIR:-$OUTPUT_DIR/src/icu}" +HOST_BUILD_DIR="$OUTPUT_DIR/build/host" +DATA_PACKAGING="${DATA_PACKAGING:-shared}" + +usage() { + cat <&2; exit 1; } + +detect_ndk_host_tag() { + case "$(uname -s)" in + Linux) echo "linux-$(uname -m)" ;; + Darwin) echo "darwin-$(uname -m)" ;; + MINGW*|MSYS*|CYGWIN*) echo "windows-x86_64" ;; + *) die "Unsupported host OS for NDK toolchain: $(uname -s)" ;; + esac +} + +resolve_ndk_toolchain_tag() { + local ndk="$1" + local preferred host_os + preferred="$(detect_ndk_host_tag)" + if [[ -d "$ndk/toolchains/llvm/prebuilt/$preferred" ]]; then + echo "$preferred" + return + fi + host_os="$(uname -s)" + if [[ "$host_os" == Linux && -d "$ndk/toolchains/llvm/prebuilt/windows-x86_64" ]]; then + die "NDK at $ndk only has the Windows toolchain (windows-x86_64). WSL/Linux builds need the Linux NDK zip from https://developer.android.com/ndk/downloads — set ANDROID_NDK_HOME to that install." + fi + if [[ "$host_os" =~ ^(MINGW|MSYS|CYGWIN) && -d "$ndk/toolchains/llvm/prebuilt/linux-x86_64" ]]; then + die "NDK at $ndk only has the Linux toolchain. Git Bash/Windows builds need the Windows NDK from Android Studio sdkmanager." + fi + die "NDK toolchain not found under $ndk/toolchains/llvm/prebuilt/ (expected $preferred)" +} + +resolve_ndk() { + if [[ -n "${ANDROID_NDK_HOME:-}" && -d "$ANDROID_NDK_HOME" ]]; then + echo "$ANDROID_NDK_HOME" + return + fi + if [[ -n "${ANDROID_HOME:-}" && -d "$ANDROID_HOME/ndk" ]]; then + local latest + latest="$(ls -d "$ANDROID_HOME/ndk/"* 2>/dev/null | sort -V | tail -1)" + [[ -n "$latest" ]] && echo "$latest" && return + fi + if command -v ndk-build >/dev/null 2>&1; then + dirname "$(command -v ndk-build)" + return + fi + die "Android NDK not found. Set ANDROID_NDK_HOME or ANDROID_HOME." +} + +arch_to_target() { + case "$1" in + x86_64) echo "x86_64-linux-android" ;; + arm64-v8a|arm64) echo "aarch64-linux-android" ;; + x86) echo "i686-linux-android" ;; + armeabi-v7a|arm) echo "armv7a-linux-androideabi" ;; + *) die "Unsupported arch: $1" ;; + esac +} + +arch_to_abi_folder() { + case "$1" in + arm64) echo "arm64-v8a" ;; + arm) echo "armeabi-v7a" ;; + *) echo "$1" ;; + esac +} + +copy_ndk_cpp_shared() { + local ndk="$1" + local host_tag="$2" + local target="$3" + local install_dir="$4" + local cxx_lib="$ndk/toolchains/llvm/prebuilt/$host_tag/sysroot/usr/lib/$target/libc++_shared.so" + [[ -f "$cxx_lib" ]] || die "NDK libc++ not found: $cxx_lib" + cp -f "$cxx_lib" "$install_dir/" + log "Copied libc++_shared.so to $install_dir" +} + +install_android_apk_libs() { + local install_dir="$1" + local icu_major="${ICU_VERSION%%.*}" + for lib in icuuc icui18n icudata; do + local src="" + for candidate in \ + "$install_dir/lib${lib}.so.${ICU_VERSION}" \ + "$install_dir/lib${lib}.so.${icu_major}.1" \ + "$install_dir/lib${lib}.so.${icu_major}"; do + if [[ -f "$candidate" ]]; then + src="$candidate" + break + fi + done + [[ -n "$src" ]] || die "Missing lib${lib} for Android APK packaging in $install_dir" + cp -f "$src" "$install_dir/lib${lib}.so" + cp -f "$src" "$install_dir/lib${lib}.so.${icu_major}" + done + log "Installed Android APK libs (libicu{uc,i18n,data}.so and .so.${icu_major}) to $install_dir" +} + +install_icu_data_file() { + local icu_major="${ICU_VERSION%%.*}" + local dat_name="icudt${icu_major}l.dat" + local dat_src="" + for candidate in \ + "$HOST_BUILD_DIR/data/out/tmp/$dat_name" \ + "$ICU_SRC_DIR/source/data/in/$dat_name"; do + if [[ -f "$candidate" ]]; then + dat_src="$candidate" + break + fi + done + [[ -n "$dat_src" ]] || die "Missing ICU data file $dat_name (build host ICU first)" + cp -f "$dat_src" "$OUTPUT_DIR/$dat_name" + log "Installed $dat_name to $OUTPUT_DIR" +} + +download_icu_source() { + if [[ -f "$ICU_SRC_DIR/source/configure" ]]; then + log "ICU source already present at $ICU_SRC_DIR" + return + fi + mkdir -p "$(dirname "$ICU_SRC_DIR")" + local tarball="icu4c-${ICU_VERSION_UNDERSCORE}-src.tgz" + local url="https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION//./-}/$tarball" + local cache="$OUTPUT_DIR/src/$tarball" + log "Downloading ICU $ICU_VERSION from $url" + mkdir -p "$OUTPUT_DIR/src" + if [[ ! -f "$cache" ]]; then + curl -fsSL -o "$cache" "$url" + fi + tar -xzf "$cache" -C "$OUTPUT_DIR/src" + local extracted="$OUTPUT_DIR/src/icu" + if [[ "$extracted" != "$ICU_SRC_DIR" ]]; then + rm -rf "$ICU_SRC_DIR" + mv "$extracted" "$ICU_SRC_DIR" + fi +} + +build_host_icu() { + if [[ -f "$HOST_BUILD_DIR/icu_build/lib/libicuuc.so" || -f "$HOST_BUILD_DIR/icu_build/lib/libicuuc.a" ]]; then + log "Host ICU build already present" + return + fi + log "Building host ICU (required for cross-build tools)..." + mkdir -p "$HOST_BUILD_DIR" + pushd "$HOST_BUILD_DIR" >/dev/null + "$ICU_SRC_DIR/source/runConfigureICU" Linux \ + --enable-static=no \ + --enable-shared=yes \ + --enable-tests=no \ + --enable-samples=no \ + --enable-extras=no \ + --enable-draft=yes \ + --datadir="$HOST_BUILD_DIR/data" \ + --prefix="$HOST_BUILD_DIR/icu_build" + make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + popd >/dev/null +} + +build_android_arch() { + local arch="$1" + local abi_folder + abi_folder="$(arch_to_abi_folder "$arch")" + local target + target="$(arch_to_target "$arch")" + local ndk="$2" + local host_tag="$3" + local build_dir="$OUTPUT_DIR/build/android/$abi_folder" + local install_dir="$OUTPUT_DIR/$abi_folder" + + log "Cross-compiling ICU for $abi_folder ($target)..." + mkdir -p "$build_dir" "$install_dir" + pushd "$build_dir" >/dev/null + + local toolchain="$ndk/toolchains/llvm/prebuilt/$host_tag" + [[ -d "$toolchain" ]] || die "NDK toolchain not found: $toolchain" + + export PATH="$toolchain/bin:$PATH" + export CC="$toolchain/bin/${target}${API_LEVEL}-clang" + export CXX="$toolchain/bin/${target}${API_LEVEL}-clang++" + export AR="$toolchain/bin/llvm-ar" + export RANLIB="$toolchain/bin/llvm-ranlib" + export LDFLAGS="-Wl,--gc-sections -Wl,-z,max-page-size=16384" + + local configure_data_packaging="$DATA_PACKAGING" + if [[ "$configure_data_packaging" == "shared" ]]; then + configure_data_packaging="dll" + fi + + "$ICU_SRC_DIR/source/configure" \ + --with-cross-build="$HOST_BUILD_DIR" \ + --host="$target" \ + --prefix="$build_dir/icu_build" \ + --enable-static=no \ + --enable-shared=yes \ + --enable-tests=no \ + --enable-samples=no \ + --enable-extras=no \ + --enable-draft=yes \ + --with-data-packaging="$configure_data_packaging" + + make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + + log "Installing libraries to $install_dir" + cp -f "$build_dir/lib"/libicu*.so* "$install_dir/" 2>/dev/null || cp -f "$build_dir/icu_build/lib"/libicu*.so* "$install_dir/" + if [[ "$configure_data_packaging" == "dll" && -d "$build_dir/stubdata" ]]; then + cp -f "$build_dir/stubdata"/libicudata*.so* "$install_dir/" 2>/dev/null || true + fi + copy_ndk_cpp_shared "$ndk" "$host_tag" "$target" "$install_dir" + install_android_apk_libs "$install_dir" + + popd >/dev/null + log "Built $(ls -1 "$install_dir"/libicu*.so* 2>/dev/null | wc -l | tr -d ' ') libraries for $abi_folder" +} + +CLEAN=no +CLEAN_ARCHS="" +for arg in "$@"; do + case "$arg" in + --help|-h) usage; exit 0 ;; + --clean) CLEAN=yes ;; + --clean-arch=*) CLEAN_ARCHS="${arg#*=}" ;; + --arch=*) ARCHS="${arg#*=}" ;; + --api=*) API_LEVEL="${arg#*=}" ;; + --icu-version=*) ICU_VERSION="${arg#*=}"; ICU_VERSION_UNDERSCORE="${ICU_VERSION//./_}" ;; + *) die "Unknown option: $arg" ;; + esac +done + +if [[ "$CLEAN" == "yes" ]]; then + rm -rf "$OUTPUT_DIR/build" + log "Cleaned $OUTPUT_DIR/build" + exit 0 +fi + +if [[ -n "$CLEAN_ARCHS" ]]; then + IFS=',' read -ra CLEAN_ARCH_LIST <<< "$CLEAN_ARCHS" + for arch in "${CLEAN_ARCH_LIST[@]}"; do + arch="$(echo "$arch" | xargs)" + [[ -n "$arch" ]] || continue + abi_folder="$(arch_to_abi_folder "$arch")" + rm -rf "$OUTPUT_DIR/build/android/$abi_folder" + log "Cleaned $OUTPUT_DIR/build/android/$abi_folder" + done +fi + +for cmd in curl tar make; do + command -v "$cmd" >/dev/null || die "Required command not found: $cmd" +done + +NDK="$(resolve_ndk)" +HOST_TAG="$(resolve_ndk_toolchain_tag "$NDK")" +log "NDK: $NDK" +log "Host toolchain tag: $HOST_TAG" +log "ICU version: $ICU_VERSION" +log "Architectures: $ARCHS" + +download_icu_source +build_host_icu +install_icu_data_file + +IFS=',' read -ra ARCH_LIST <<< "$ARCHS" +for arch in "${ARCH_LIST[@]}"; do + arch="$(echo "$arch" | xargs)" + [[ -n "$arch" ]] || continue + build_android_arch "$arch" "$NDK" "$HOST_TAG" +done + +log "Done. Libraries are in $OUTPUT_DIR/{abi}/" +log "Rebuild the Android test app, then run: .\\scripts\\run-android-tests.ps1" diff --git a/scripts/ci-android-tests.sh b/scripts/ci-android-tests.sh new file mode 100755 index 00000000..36ded0dc --- /dev/null +++ b/scripts/ci-android-tests.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 SIL Global +# Run icu.net Android device tests (CI / Linux). Requires a booted emulator (adb devices). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT="$REPO_ROOT/source/icu.net.android.tests/icu.net.android.tests.csproj" + +export IcuDotNetIncludeAndroid=true + +echo "ANDROID_HOME=${ANDROID_HOME:-}" +echo "ANDROID_NDK_HOME=${ANDROID_NDK_HOME:-}" +echo "Connected devices:" +adb devices + +dotnet restore "$PROJECT" -p:IcuDotNetIncludeAndroid=true +dotnet test "$PROJECT" -f net10.0-android -c Release \ + -p:IcuDotNetIncludeAndroid=true \ + --no-restore \ + --logger "trx;LogFileName=test-results.trx" diff --git a/scripts/fix-ndk-symlinks.sh b/scripts/fix-ndk-symlinks.sh new file mode 100644 index 00000000..c3ec43a1 --- /dev/null +++ b/scripts/fix-ndk-symlinks.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Repair NDK symlinks broken by zip extractors that don't preserve links (e.g. Python zipfile). +set -euo pipefail + +NDK_BIN="${1:?Usage: fix-ndk-symlinks.sh }" + +cd "$NDK_BIN" +for f in *; do + [[ -f "$f" ]] || continue + size=$(stat -c%s "$f" 2>/dev/null || echo 999) + [[ "$size" -lt 64 ]] || continue + target=$(tr -d '\r\n' < "$f") + [[ -n "$target" ]] || continue + if [[ -e "$NDK_BIN/$target" ]]; then + rm -f "$f" + ln -s "$target" "$f" + fi +done + +"$NDK_BIN/x86_64-linux-android21-clang" --version | head -1 diff --git a/scripts/probe-android-icu.ps1 b/scripts/probe-android-icu.ps1 new file mode 100644 index 00000000..bd099e39 --- /dev/null +++ b/scripts/probe-android-icu.ps1 @@ -0,0 +1,132 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Probe an attached Android device/emulator for system ICU libraries (no app rebuild). + +.DESCRIPTION + Reports API level, ABI, and whether platform ICU files exist in typical system paths. + Useful to confirm that dlopen of libicuuc.so is not available to apps on modern Android. + +.EXAMPLE + .\scripts\probe-android-icu.ps1 +#> +[CmdletBinding()] +param( + [string] $PackageName = 'com.sil.icu.android.tests' +) + +$ErrorActionPreference = 'Stop' + +function Find-Adb { + if ($env:ANDROID_HOME) { + $adb = Join-Path $env:ANDROID_HOME 'platform-tools\adb.exe' + if (Test-Path $adb) { return $adb } + } + $adbOnPath = Get-Command adb -ErrorAction SilentlyContinue + if ($adbOnPath) { return $adbOnPath.Source } + return $null +} + +function Invoke-AdbShell([string] $Command) { + & $adb shell $Command 2>&1 | ForEach-Object { "$_" } +} + +function Test-RemotePath([string] $Path) { + $result = Invoke-AdbShell "test -e `"$Path`" && echo exists || echo missing" + return ($result -match 'exists') +} + +function Get-RemoteListing([string] $Path) { + if (-not (Test-RemotePath $Path)) { + return @("(path not found)") + } + $lines = Invoke-AdbShell "ls -la `"$Path`" 2>/dev/null" + if (-not $lines) { return @("(empty)") } + return $lines +} + +function Find-RemoteIcuLibs([string[]] $Dirs) { + $found = @() + foreach ($dir in $Dirs) { + if (-not (Test-RemotePath $dir)) { continue } + $matches = Invoke-AdbShell "ls `"$dir`" 2>/dev/null | grep -E 'libicu(uc|i18n|data|)\.so' || true" + foreach ($line in $matches) { + if ($line -and $line -notmatch '^\s*$') { + $found += "$dir/$line" + } + } + } + return $found +} + +$adb = Find-Adb +if (-not $adb) { + Write-Error "adb not found. Set ANDROID_HOME or add platform-tools to PATH." +} + +$devices = & $adb devices 2>&1 | Select-Object -Skip 1 | Where-Object { $_ -match '\t' -and $_ -notmatch 'offline' } +if (-not $devices) { + Write-Error "No Android device/emulator connected. Run: adb devices" +} + +Write-Host "=== Android ICU probe ===" -ForegroundColor Cyan +Write-Host "adb: $adb" +Write-Host "device: $($devices[0])" +Write-Host "" + +$sdk = (Invoke-AdbShell 'getprop ro.build.version.sdk' | Select-Object -First 1).Trim() +$release = (Invoke-AdbShell 'getprop ro.build.version.release' | Select-Object -First 1).Trim() +$abi = (Invoke-AdbShell 'getprop ro.product.cpu.abi' | Select-Object -First 1).Trim() +$abilist = (Invoke-AdbShell 'getprop ro.product.cpu.abilist' | Select-Object -First 1).Trim() + +Write-Host "--- Device ---" +Write-Host "API level: $sdk (Android $release)" +Write-Host "Primary ABI: $abi" +Write-Host "ABI list: $abilist" +Write-Host "" + +Write-Host "--- ICU data (/system/usr/icu) ---" +Get-RemoteListing '/system/usr/icu' | ForEach-Object { Write-Host " $_" } +Write-Host "" + +$libDirs = @( + '/system/lib64', + '/system/lib', + '/apex/com.android.i18n/lib64', + '/apex/com.android.i18n/lib', + '/apex/com.android.runtime/lib64', + '/apex/com.android.runtime/lib', + '/apex/com.android.art/lib64', + '/apex/com.android.art/lib' +) + +Write-Host "--- ICU-related .so in system/apex lib dirs ---" +$icuLibs = Find-RemoteIcuLibs $libDirs +if ($icuLibs.Count -eq 0) { + Write-Host " (none found in searched paths)" +} else { + $icuLibs | ForEach-Object { Write-Host " $_" } +} +Write-Host "" + +Write-Host "--- App native lib dir ($PackageName) ---" +$appLibDir = (Invoke-AdbShell "run-as $PackageName ls lib 2>/dev/null || echo" | Select-Object -First 1) +if ($appLibDir -match 'not debuggable|Permission denied|No such file') { + Write-Host " (app not installed, not debuggable, or run-as unavailable)" + $nativeDir = Invoke-AdbShell "pm path $PackageName 2>/dev/null" + if ($nativeDir) { + Write-Host " apk: $nativeDir" + } +} else { + $abiFolder = if ($abi -match '64') { 'x86_64', 'arm64-v8a' } else { 'x86', 'armeabi-v7a' } + foreach ($folder in $abiFolder) { + Write-Host " lib/$folder :" + Get-RemoteListing "lib/$folder" | ForEach-Object { Write-Host " $_" } + } +} + +Write-Host "" +Write-Host "--- Notes ---" -ForegroundColor Yellow +Write-Host "icu.net expects versioned libicuuc.so.N and libicui18n.so.N in the app native lib directory." +Write-Host "Since API 24, apps generally cannot dlopen platform libicuuc.so / libicui18n.so." +Write-Host "Bundled ICU (see scripts/build-icu-android.ps1) is the supported path for icu.net on Android." diff --git a/scripts/rebuild-device-test-trx.ps1 b/scripts/rebuild-device-test-trx.ps1 new file mode 100644 index 00000000..0f6112b1 --- /dev/null +++ b/scripts/rebuild-device-test-trx.ps1 @@ -0,0 +1,212 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Rebuild a valid TRX file from DeviceRunners tcp-test-events.jsonl. + +.DESCRIPTION + DeviceRunners 0.1.0-preview.12 can abort TRX generation when test failure output + contains XML-invalid control characters (e.g. UTF-16 debug text with NUL/ENQ). + The JSONL event log is always well-formed, so we regenerate TRX for dotnet test. +#> +[CmdletBinding()] +param( + [string] $ResultsDir = '', + + [string] $TrxFile = '' +) + +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($ResultsDir) -or [string]::IsNullOrWhiteSpace($TrxFile)) { + exit 0 +} + +function Test-ValidXmlChar([int]$codePoint) { + return ($codePoint -eq 0x9) -or ($codePoint -eq 0xA) -or ($codePoint -eq 0xD) -or + (($codePoint -ge 0x20) -and ($codePoint -le 0xD7FF)) -or + (($codePoint -ge 0xE000) -and ($codePoint -le 0xFFFD)) +} + +function Get-SanitizedXmlText([string]$text) { + if ([string]::IsNullOrEmpty($text)) { + return '' + } + + $sb = New-Object System.Text.StringBuilder + for ($i = 0; $i -lt $text.Length; $i++) { + $ch = $text[$i] + if ([char]::IsHighSurrogate($ch) -and ($i + 1) -lt $text.Length) { + $low = $text[$i + 1] + if ([char]::IsLowSurrogate($low)) { + $codePoint = [char]::ConvertToUtf32($ch, $low) + if (Test-ValidXmlChar $codePoint) { + [void]$sb.Append($ch) + [void]$sb.Append($low) + } + $i++ + continue + } + } + + if (Test-ValidXmlChar ([int][char]$ch)) { + [void]$sb.Append($ch) + } + } + + return $sb.ToString() +} + +function Get-TrxOutcome([string]$status) { + switch ($status) { + 'Passed' { 'Passed' } + 'Failed' { 'Failed' } + 'Skipped' { 'NotExecuted' } + 'NotExecuted' { 'NotExecuted' } + default { 'NotExecuted' } + } +} + +$eventsFile = Join-Path $ResultsDir 'tcp-test-events.jsonl' +if (-not (Test-Path $eventsFile)) { + exit 0 +} + +$begin = $null +$end = $null +$results = New-Object System.Collections.Generic.List[object] + +Get-Content -LiteralPath $eventsFile | ForEach-Object { + if ([string]::IsNullOrWhiteSpace($_)) { + return + } + + $event = $_ | ConvertFrom-Json + switch ($event.type) { + 'begin' { $begin = [datetimeoffset]::Parse($event.timestamp) } + 'end' { $end = [datetimeoffset]::Parse($event.timestamp) } + 'result' { $results.Add($event) } + } +} + +if ($results.Count -eq 0) { + Write-Warning "No test results found in $eventsFile" + exit 0 +} + +if (-not $begin) { + $begin = [datetimeoffset]::Parse($results[0].timestamp) +} +if (-not $end) { + $end = [datetimeoffset]::Parse($results[-1].timestamp) +} + +$passed = @($results | Where-Object { $_.status -eq 'Passed' }).Count +$failed = @($results | Where-Object { $_.status -eq 'Failed' }).Count +$skipped = @($results | Where-Object { $_.status -in @('Skipped', 'NotExecuted') }).Count +$total = $results.Count +$executed = $passed + $failed + +$trxDir = Split-Path -Parent $TrxFile +if ($trxDir) { + New-Item -ItemType Directory -Force -Path $trxDir | Out-Null +} + +$settings = New-Object System.Xml.XmlWriterSettings +$settings.Indent = $true +$settings.Encoding = New-Object System.Text.UTF8Encoding($false) +$settings.OmitXmlDeclaration = $false + +$writer = [System.Xml.XmlWriter]::Create($TrxFile, $settings) +try { + $ns = 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010' + $writer.WriteStartDocument() + $writer.WriteStartElement('TestRun', $ns) + $writer.WriteAttributeString('id', [guid]::NewGuid().ToString()) + $writer.WriteAttributeString('name', '') + $writer.WriteAttributeString('runUser', '') + + $timeFormat = 'yyyy-MM-ddTHH:mm:ss.fffffffK' + $writer.WriteStartElement('Times', $ns) + $writer.WriteAttributeString('creation', $begin.ToString($timeFormat)) + $writer.WriteAttributeString('queuing', $begin.ToString($timeFormat)) + $writer.WriteAttributeString('start', $begin.ToString($timeFormat)) + $writer.WriteAttributeString('finish', $end.ToString($timeFormat)) + $writer.WriteEndElement() + + $writer.WriteStartElement('Results', $ns) + foreach ($result in $results) { + $outcome = Get-TrxOutcome $result.status + $executionId = [guid]::NewGuid().ToString() + $testId = [guid]::NewGuid().ToString() + $startTime = [datetimeoffset]::Parse($result.timestamp) + $duration = [timespan]::Zero + if ($result.duration) { + [void][timespan]::TryParse($result.duration, [ref]$duration) + } + $endTime = $startTime.Add($duration) + + $writer.WriteStartElement('UnitTestResult', $ns) + $writer.WriteAttributeString('executionId', $executionId) + $writer.WriteAttributeString('testId', $testId) + $writer.WriteAttributeString('testName', (Get-SanitizedXmlText $result.displayName)) + $writer.WriteAttributeString('outcome', $outcome) + $writer.WriteAttributeString('duration', $result.duration) + $writer.WriteAttributeString('startTime', $startTime.ToString($timeFormat)) + $writer.WriteAttributeString('endTime', $endTime.ToString($timeFormat)) + $writer.WriteAttributeString('computerName', '') + + $writer.WriteStartElement('Output', $ns) + if ($result.output) { + $writer.WriteStartElement('StdOut', $ns) + $writer.WriteString((Get-SanitizedXmlText $result.output)) + $writer.WriteEndElement() + } + + if ($result.errorMessage -or $result.errorStackTrace) { + $writer.WriteStartElement('ErrorInfo', $ns) + if ($result.errorMessage) { + $writer.WriteStartElement('Message', $ns) + $writer.WriteString((Get-SanitizedXmlText $result.errorMessage)) + $writer.WriteEndElement() + } + if ($result.errorStackTrace) { + $writer.WriteStartElement('StackTrace', $ns) + $writer.WriteString((Get-SanitizedXmlText $result.errorStackTrace)) + $writer.WriteEndElement() + } + $writer.WriteEndElement() + } + $writer.WriteEndElement() + $writer.WriteEndElement() + } + $writer.WriteEndElement() + + $writer.WriteStartElement('ResultSummary', $ns) + $writer.WriteStartElement('Counters', $ns) + $writer.WriteAttributeString('total', $total) + $writer.WriteAttributeString('executed', $executed) + $writer.WriteAttributeString('passed', $passed) + $writer.WriteAttributeString('failed', $failed) + $writer.WriteAttributeString('error', '0') + $writer.WriteAttributeString('timeout', '0') + $writer.WriteAttributeString('aborted', '0') + $writer.WriteAttributeString('inconclusive', '0') + $writer.WriteAttributeString('passedButRunAborted', '0') + $writer.WriteAttributeString('notRunnable', '0') + $writer.WriteAttributeString('notExecuted', $skipped) + $writer.WriteAttributeString('disconnected', '0') + $writer.WriteAttributeString('warning', '0') + $writer.WriteAttributeString('completed', $executed) + $writer.WriteAttributeString('inProgress', '0') + $writer.WriteAttributeString('pending', '0') + $writer.WriteEndElement() + $writer.WriteEndElement() + + $writer.WriteEndElement() + $writer.WriteEndDocument() +} +finally { + $writer.Dispose() +} + +Write-Host "Rebuilt TRX from tcp-test-events.jsonl: $TrxFile (total=$total, passed=$passed, failed=$failed, skipped=$skipped)" diff --git a/scripts/run-android-tests.ps1 b/scripts/run-android-tests.ps1 new file mode 100644 index 00000000..3384d215 --- /dev/null +++ b/scripts/run-android-tests.ps1 @@ -0,0 +1,60 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Run icu.net Android device smoke tests via dotnet test. + +.DESCRIPTION + Prerequisites: + - .NET 10 SDK with MAUI Android workload: dotnet workload install maui-android + - Android SDK (ANDROID_HOME set) with an emulator running or USB device attached + - Verify device connectivity: adb devices + +.EXAMPLE + .\scripts\run-android-tests.ps1 + .\scripts\run-android-tests.ps1 -Configuration Debug +#> +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$project = Join-Path $repoRoot 'source\icu.net.android.tests\icu.net.android.tests.csproj' + +function Find-Adb { + if ($env:ANDROID_HOME) { + $adb = Join-Path $env:ANDROID_HOME 'platform-tools\adb.exe' + if (Test-Path $adb) { return $adb } + } + $adbOnPath = Get-Command adb -ErrorAction SilentlyContinue + if ($adbOnPath) { return $adbOnPath.Source } + return $null +} + +$adb = Find-Adb +if (-not $adb) { + Write-Error @" +adb not found. Install the Android SDK and set ANDROID_HOME, or add platform-tools to PATH. +Example: dotnet workload install maui-android +"@ +} + +$devices = & $adb devices 2>&1 | Select-Object -Skip 1 | Where-Object { $_ -match '\t' -and $_ -notmatch 'offline' } +if (-not $devices) { + Write-Error @" +No Android emulator or device detected. Start an emulator or connect a device, then run: + adb devices +"@ +} + +Write-Host "Using adb: $adb" +Write-Host "Connected devices:" +$devices | ForEach-Object { Write-Host " $_" } + +Write-Host "Running Android device tests ($Configuration)..." +$env:IcuDotNetIncludeAndroid = 'true' +dotnet restore $project -p:IcuDotNetIncludeAndroid=true +dotnet test $project -f net10.0-android -c $Configuration -p:IcuDotNetIncludeAndroid=true --no-restore --logger "trx;LogFileName=test-results.trx" +exit $LASTEXITCODE diff --git a/source/Directory.Build.props b/source/Directory.Build.props index d4799101..22284adc 100644 --- a/source/Directory.Build.props +++ b/source/Directory.Build.props @@ -19,6 +19,8 @@ See full changelog at https://github.com/sillsdev/icu-dotnet/blob/master/CHANGEL snupkg default true + + true true $(MSBuildThisFileDirectory)/icu.net.snk diff --git a/source/icu.net.android.tests/App.xaml b/source/icu.net.android.tests/App.xaml new file mode 100644 index 00000000..9f8af740 --- /dev/null +++ b/source/icu.net.android.tests/App.xaml @@ -0,0 +1,5 @@ + + + diff --git a/source/icu.net.android.tests/App.xaml.cs b/source/icu.net.android.tests/App.xaml.cs new file mode 100644 index 00000000..e64b976e --- /dev/null +++ b/source/icu.net.android.tests/App.xaml.cs @@ -0,0 +1,16 @@ +using DeviceRunners.VisualRunners.Maui; + +namespace icu.net.android.tests; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new VisualRunnerAppShell()); + } +} diff --git a/source/icu.net.android.tests/DeviceRunners.Testing.Overrides.targets b/source/icu.net.android.tests/DeviceRunners.Testing.Overrides.targets new file mode 100644 index 00000000..f99a9f00 --- /dev/null +++ b/source/icu.net.android.tests/DeviceRunners.Testing.Overrides.targets @@ -0,0 +1,19 @@ + + + + <_DeviceRunnersTrxRebuildScript>$(MSBuildThisFileDirectory)..\..\scripts\rebuild-device-test-trx.ps1 + powershell -NoProfile -ExecutionPolicy Bypass -File "$(_DeviceRunnersTrxRebuildScript)" -ResultsDir "$(_DeviceRunnersResultsDir)" -TrxFile "$(_DeviceRunnersTrxFile)" + pwsh -NoProfile -File "$(_DeviceRunnersTrxRebuildScript)" -ResultsDir "$(_DeviceRunnersResultsDir)" -TrxFile "$(_DeviceRunnersTrxFile)" + + + + + diff --git a/source/icu.net.android.tests/MauiProgram.cs b/source/icu.net.android.tests/MauiProgram.cs new file mode 100644 index 00000000..e5bab819 --- /dev/null +++ b/source/icu.net.android.tests/MauiProgram.cs @@ -0,0 +1,29 @@ +using DeviceRunners.VisualRunners; +using DeviceRunners.VisualRunners.NUnit; +using Icu.Tests; +using Microsoft.Extensions.Logging; + +namespace icu.net.android.tests; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseVisualTestRunner(conf => conf + .AddCliConfiguration() + .AddConsoleResultChannel() + .AddTestAssembly(typeof(MauiProgram).Assembly) + .AddTestAssembly(typeof(SetUpFixture).Assembly) + .AddNUnit()); + +#if DEBUG + builder.Logging.AddDebug(); +#else + builder.Logging.AddConsole(); +#endif + + return builder.Build(); + } +} diff --git a/source/icu.net.android.tests/Platforms/Android/AndroidManifest.xml b/source/icu.net.android.tests/Platforms/Android/AndroidManifest.xml new file mode 100644 index 00000000..e9937ad7 --- /dev/null +++ b/source/icu.net.android.tests/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/source/icu.net.android.tests/Platforms/Android/MainActivity.cs b/source/icu.net.android.tests/Platforms/Android/MainActivity.cs new file mode 100644 index 00000000..08999abe --- /dev/null +++ b/source/icu.net.android.tests/Platforms/Android/MainActivity.cs @@ -0,0 +1,15 @@ +using Android.App; +using Android.Content.PM; +using Android.OS; + +namespace icu.net.android.tests; + +[Activity( + Name = "com.sil.icu.android.tests.MainActivity", + Theme = "@style/Maui.SplashTheme", + MainLauncher = true, + LaunchMode = LaunchMode.SingleTop, + ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +public class MainActivity : MauiAppCompatActivity +{ +} diff --git a/source/icu.net.android.tests/Platforms/Android/MainApplication.cs b/source/icu.net.android.tests/Platforms/Android/MainApplication.cs new file mode 100644 index 00000000..77abd03b --- /dev/null +++ b/source/icu.net.android.tests/Platforms/Android/MainApplication.cs @@ -0,0 +1,15 @@ +using Android.App; +using Android.Runtime; + +namespace icu.net.android.tests; + +[Application] +public class MainApplication : MauiApplication +{ + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/source/icu.net.android.tests/Platforms/Android/Resources/values/colors.xml b/source/icu.net.android.tests/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 00000000..c04d7492 --- /dev/null +++ b/source/icu.net.android.tests/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,6 @@ + + + #512BD4 + #2B0B98 + #2B0B98 + \ No newline at end of file diff --git a/source/icu.net.android.tests/Resources/AppIcon/appicon.svg b/source/icu.net.android.tests/Resources/AppIcon/appicon.svg new file mode 100644 index 00000000..9d63b651 --- /dev/null +++ b/source/icu.net.android.tests/Resources/AppIcon/appicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/source/icu.net.android.tests/Resources/AppIcon/appiconfg.svg b/source/icu.net.android.tests/Resources/AppIcon/appiconfg.svg new file mode 100644 index 00000000..21dfb25f --- /dev/null +++ b/source/icu.net.android.tests/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/source/icu.net.android.tests/Resources/Splash/splash.svg b/source/icu.net.android.tests/Resources/Splash/splash.svg new file mode 100644 index 00000000..21dfb25f --- /dev/null +++ b/source/icu.net.android.tests/Resources/Splash/splash.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/source/icu.net.android.tests/Tests/AndroidSmokeTests.cs b/source/icu.net.android.tests/Tests/AndroidSmokeTests.cs new file mode 100644 index 00000000..173f9119 --- /dev/null +++ b/source/icu.net.android.tests/Tests/AndroidSmokeTests.cs @@ -0,0 +1,22 @@ +// Copyright (c) 2013-2026 SIL Global +// This software is licensed under the MIT license (http://opensource.org/licenses/MIT) +using Microsoft.Maui.Devices; +using NUnit.Framework; + +namespace icu.net.android.tests.Tests; + +[TestFixture] +public class AndroidSmokeTests +{ + [Test] + public void IsRunningOnAndroid() + { + Assert.That(OperatingSystem.IsAndroid(), Is.True); + } + + [Test] + public void DeviceInfo_IsAndroid() + { + Assert.That(DeviceInfo.Platform, Is.EqualTo(DevicePlatform.Android)); + } +} diff --git a/source/icu.net.android.tests/Tests/IcuLoadDiagnostics.cs b/source/icu.net.android.tests/Tests/IcuLoadDiagnostics.cs new file mode 100644 index 00000000..d6aa13ce --- /dev/null +++ b/source/icu.net.android.tests/Tests/IcuLoadDiagnostics.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2013-2026 SIL Global +// This software is licensed under the MIT license (http://opensource.org/licenses/MIT) +using System.Text; +using Icu; +using NUnit.Framework; + +namespace icu.net.android.tests.Tests; + +[TestFixture] +public class IcuLoadDiagnostics +{ + [Test] + public void ReportIcuLoadEnvironment() + { + var log = new StringBuilder(); + log.AppendLine("=== ICU load diagnostics ==="); + +#if __ANDROID__ + var context = Android.App.Application.Context; + var nativeDir = context?.ApplicationInfo?.NativeLibraryDir; + log.AppendLine($"NativeLibraryDir: {nativeDir ?? "(null)"}"); + if (!string.IsNullOrEmpty(nativeDir) && Directory.Exists(nativeDir)) + { + foreach (var file in Directory.GetFiles(nativeDir, "libicu*").OrderBy(f => f)) + log.AppendLine($" {Path.GetFileName(file)}"); + } +#else + log.AppendLine("Not running on Android (__ANDROID__ not defined)."); +#endif + + Wrapper.Verbose = true; + + var initResult = Wrapper.Init(); + log.AppendLine($"Wrapper.Init(): {initResult}"); + log.AppendLine($"Wrapper.IcuVersion: {Wrapper.IcuVersion}"); + log.AppendLine($"Wrapper.UnicodeVersion: {Wrapper.UnicodeVersion}"); + + TestContext.WriteLine(log.ToString()); + + Assert.That(initResult, Is.EqualTo(ErrorCode.ZERO_ERROR)); + } +} diff --git a/source/icu.net.android.tests/icu.net.android.tests.csproj b/source/icu.net.android.tests/icu.net.android.tests.csproj new file mode 100644 index 00000000..9022b877 --- /dev/null +++ b/source/icu.net.android.tests/icu.net.android.tests.csproj @@ -0,0 +1,95 @@ + + + + + net10.0-android + true + Exe + icu.net.android.tests + true + true + enable + enable + false + true + false + + icu.net Android Tests + com.sil.icu.android.tests + 1.0 + 1 + + 21.0 + false + + + false + + + $(MSBuildThisFileDirectory)..\..\output\android-icu + + + trx;LogFileName=test-results.trx + $(MSBuildProjectDirectory)/test-results/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/icu.net.sln b/source/icu.net.sln index 694cd91d..573820b8 100644 --- a/source/icu.net.sln +++ b/source/icu.net.sln @@ -19,24 +19,60 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestHelper", "TestHelper\TestHelper.csproj", "{F195ADCE-9129-446E-85E0-8EEAD01ED08D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "icu.net.android.tests", "icu.net.android.tests\icu.net.android.tests.csproj", "{907295A7-F11F-4206-9A58-F3AB50A5D8F5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {28A0072F-AE0B-449A-8494-B53F09756273}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {28A0072F-AE0B-449A-8494-B53F09756273}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28A0072F-AE0B-449A-8494-B53F09756273}.Debug|x64.ActiveCfg = Debug|Any CPU + {28A0072F-AE0B-449A-8494-B53F09756273}.Debug|x64.Build.0 = Debug|Any CPU + {28A0072F-AE0B-449A-8494-B53F09756273}.Debug|x86.ActiveCfg = Debug|Any CPU + {28A0072F-AE0B-449A-8494-B53F09756273}.Debug|x86.Build.0 = Debug|Any CPU {28A0072F-AE0B-449A-8494-B53F09756273}.Release|Any CPU.ActiveCfg = Release|Any CPU {28A0072F-AE0B-449A-8494-B53F09756273}.Release|Any CPU.Build.0 = Release|Any CPU + {28A0072F-AE0B-449A-8494-B53F09756273}.Release|x64.ActiveCfg = Release|Any CPU + {28A0072F-AE0B-449A-8494-B53F09756273}.Release|x64.Build.0 = Release|Any CPU + {28A0072F-AE0B-449A-8494-B53F09756273}.Release|x86.ActiveCfg = Release|Any CPU + {28A0072F-AE0B-449A-8494-B53F09756273}.Release|x86.Build.0 = Release|Any CPU {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Debug|x64.Build.0 = Debug|Any CPU + {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Debug|x86.Build.0 = Debug|Any CPU {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Release|Any CPU.ActiveCfg = Release|Any CPU {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Release|Any CPU.Build.0 = Release|Any CPU + {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Release|x64.ActiveCfg = Release|Any CPU + {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Release|x64.Build.0 = Release|Any CPU + {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Release|x86.ActiveCfg = Release|Any CPU + {9D7CFEF4-55F1-4C31-9B7B-EB6520AE4EDB}.Release|x86.Build.0 = Release|Any CPU {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Debug|x64.ActiveCfg = Debug|Any CPU + {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Debug|x64.Build.0 = Debug|Any CPU + {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Debug|x86.ActiveCfg = Debug|Any CPU + {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Debug|x86.Build.0 = Debug|Any CPU {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Release|Any CPU.ActiveCfg = Release|Any CPU {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Release|Any CPU.Build.0 = Release|Any CPU + {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Release|x64.ActiveCfg = Release|Any CPU + {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Release|x64.Build.0 = Release|Any CPU + {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Release|x86.ActiveCfg = Release|Any CPU + {F195ADCE-9129-446E-85E0-8EEAD01ED08D}.Release|x86.Build.0 = Release|Any CPU + {907295A7-F11F-4206-9A58-F3AB50A5D8F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {907295A7-F11F-4206-9A58-F3AB50A5D8F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {907295A7-F11F-4206-9A58-F3AB50A5D8F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {907295A7-F11F-4206-9A58-F3AB50A5D8F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {907295A7-F11F-4206-9A58-F3AB50A5D8F5}.Release|x64.ActiveCfg = Release|Any CPU + {907295A7-F11F-4206-9A58-F3AB50A5D8F5}.Release|x86.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/source/icu.net.tests/BreakIteratorTests.Chinese.cs b/source/icu.net.tests/BreakIteratorTests.Chinese.cs index 78ae95ab..9faaa2a4 100644 --- a/source/icu.net.tests/BreakIteratorTests.Chinese.cs +++ b/source/icu.net.tests/BreakIteratorTests.Chinese.cs @@ -3,6 +3,9 @@ using System; using System.Linq; using NUnit.Framework; +#if __ANDROID__ +using NUnit.Framework.Legacy; +#endif namespace Icu.Tests { diff --git a/source/icu.net.tests/icu.net.tests.csproj b/source/icu.net.tests/icu.net.tests.csproj index 9493e75c..003037b9 100644 --- a/source/icu.net.tests/icu.net.tests.csproj +++ b/source/icu.net.tests/icu.net.tests.csproj @@ -6,11 +6,16 @@ `-p:TargetFramework=net8.0` or `-p:TargetFramework=net10.0` since 4.6.1 isn't supported --> net461;net8.0;net10.0 + $(TargetFrameworks);net10.0-android Icu.Tests icu.net.tests false - + + 21.0 + true + + @@ -27,8 +32,13 @@ + + + + + \ No newline at end of file diff --git a/source/icu.net/Android/AndroidIcuBootstrap.cs b/source/icu.net/Android/AndroidIcuBootstrap.cs new file mode 100644 index 00000000..b4931f6c --- /dev/null +++ b/source/icu.net/Android/AndroidIcuBootstrap.cs @@ -0,0 +1,269 @@ +// Copyright (c) 2013-2026 SIL Global +// This software is licensed under the MIT license (http://opensource.org/licenses/MIT) +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Android.Content; + +namespace Icu +{ + internal static class AndroidIcuBootstrap + { + private const int IcuMajorVersion = 72; + private const int RTLD_NOW = 2; + private const int RTLD_GLOBAL = 0x00100; + private static readonly object DataSetupLock = new(); + // JavaSystem.LoadLibrary succeeds without a real dlopen handle; NativeMethods.AndroidDlsym recognizes this sentinel. + private static readonly IntPtr JavaLoadedLibrary = (IntPtr)1; + private static string? _writableNativeLibDir; + private static bool _configured; + + internal static void EnsureConfigured() + { + if (_configured) + return; + + var context = Android.App.Application.Context; + var nativeDir = context?.ApplicationInfo?.NativeLibraryDir; + if (string.IsNullOrEmpty(nativeDir) || context == null) + return; + + var loadDir = EnsureWritableNativeLibs(nativeDir, context); + NativeMethods.PreferredDirectory = loadDir; + NativeMethods.AndroidBundledIcuMajorVersion = IcuMajorVersion; + NativeMethods.AndroidLoadNativeLibrary = LoadBundledNativeLibrary; + EnsureIcuDataFile(context); + Wrapper.DataDirectory = GetIcuDataDirectory(context); + _configured = true; + } + + private static string GetIcuDataDirectory(Context context) => + Path.Combine(context.FilesDir!.AbsolutePath, "icu"); + + private static void EnsureIcuDataFile(Context context) + { + var datName = $"icudt{IcuMajorVersion}l.dat"; + var dataDir = GetIcuDataDirectory(context); + + lock (DataSetupLock) + { + Directory.CreateDirectory(dataDir); + var datPath = Path.Combine(dataDir, datName); + if (!File.Exists(datPath)) + { + var tempPath = datPath + ".tmp"; + using (var input = context.Assets!.Open(datName)) + using (var output = File.Create(tempPath)) + input.CopyTo(output); + File.Move(tempPath, datPath, overwrite: true); + } + } + } + + private static IntPtr LoadBundledNativeLibrary(string libraryFileName) + { + var nativeDir = _writableNativeLibDir + ?? Android.App.Application.Context?.ApplicationInfo?.NativeLibraryDir; + if (!string.IsNullOrEmpty(nativeDir)) + { + foreach (var candidate in GetNativeLibraryCandidates(nativeDir, libraryFileName)) + { + var dlopenHandle = Dlopen(candidate, RTLD_NOW | RTLD_GLOBAL); + if (dlopenHandle != IntPtr.Zero) + return dlopenHandle; + + try + { + var handle = NativeLibrary.Load(candidate); + if (handle != IntPtr.Zero) + return handle; + } + catch (DllNotFoundException) + { + } + } + } + + // Fallback when libs are not materialized on disk (APK zip path). + var apkPath = GetNativeLibraryApkPath(); + var abi = GetAbiFolder(); + if (apkPath != null && abi != null) + { + foreach (var fileName in GetLibraryFileNameVariants(libraryFileName)) + { + var zipPath = $"{apkPath}!/lib/{abi}/{fileName}"; + try + { + var handle = NativeLibrary.Load(zipPath); + if (handle != IntPtr.Zero) + return handle; + } + catch (DllNotFoundException) + { + } + + var dlopenHandle = Dlopen(zipPath, RTLD_NOW | RTLD_GLOBAL); + if (dlopenHandle != IntPtr.Zero) + return dlopenHandle; + } + } + + // Last resort when dlopen/NativeLibrary cannot open the .so directly. + if (TryLoadWithJavaLibrary(libraryFileName)) + return JavaLoadedLibrary; + + return IntPtr.Zero; + } + + private static IEnumerable GetNativeLibraryCandidates(string nativeDir, string libraryFileName) + { + foreach (var fileName in GetLibraryFileNameVariants(libraryFileName)) + { + var path = Path.Combine(nativeDir, fileName); + if (File.Exists(path)) + yield return path; + } + } + + private static IEnumerable GetLibraryFileNameVariants(string libraryFileName) + { + yield return libraryFileName; + + // APK libs are renamed to libicu*.so but ELF NEEDED entries use libicu*.so.72. + if (libraryFileName.EndsWith($".so.{IcuMajorVersion}", StringComparison.Ordinal)) + { + var unversioned = libraryFileName[..^($".{IcuMajorVersion}".Length)]; + if (!string.Equals(unversioned, libraryFileName, StringComparison.Ordinal)) + yield return unversioned; + } + else if (libraryFileName.EndsWith(".so", StringComparison.Ordinal) && + libraryFileName.StartsWith("libicu", StringComparison.Ordinal)) + { + yield return $"{libraryFileName}.{IcuMajorVersion}"; + } + } + + private static string EnsureWritableNativeLibs(string readOnlyNativeDir, Context context) + { + if (_writableNativeLibDir != null) + return _writableNativeLibDir; + + lock (DataSetupLock) + { + if (_writableNativeLibDir != null) + return _writableNativeLibDir; + + var destDir = Path.Combine(context.FilesDir!.AbsolutePath, "native-libs"); + Directory.CreateDirectory(destDir); + + var libs = new List { "libc++_shared.so" }; + foreach (var lib in new[] { "icudata", "icuuc", "icui18n" }) + { + libs.Add($"lib{lib}.so"); + libs.Add($"lib{lib}.so.{IcuMajorVersion}"); + } + + foreach (var lib in libs.Distinct()) + { + var src = Path.Combine(readOnlyNativeDir, lib); + if (!File.Exists(src)) + continue; + + var dst = Path.Combine(destDir, lib); + if (!File.Exists(dst)) + File.Copy(src, dst); + } + + foreach (var lib in new[] { "icudata", "icuuc", "icui18n" }) + { + var unversioned = Path.Combine(destDir, $"lib{lib}.so"); + var versioned = Path.Combine(destDir, $"lib{lib}.so.{IcuMajorVersion}"); + if (!File.Exists(unversioned) || File.Exists(versioned)) + continue; + + try + { + File.CreateSymbolicLink(versioned, unversioned); + } + catch (IOException) + { + File.Copy(unversioned, versioned, overwrite: false); + } + } + + _writableNativeLibDir = destDir; + return destDir; + } + } + + private static bool TryLoadWithJavaLibrary(string libraryFileName) + { + if (!libraryFileName.StartsWith("lib", StringComparison.Ordinal) || + !libraryFileName.EndsWith(".so", StringComparison.Ordinal)) + return false; + + var shortName = libraryFileName.Substring(3, libraryFileName.Length - 6); + try + { + Java.Lang.JavaSystem.LoadLibrary(shortName); + return true; + } + catch (Java.Lang.UnsatisfiedLinkError) + { + return false; + } + } + + private static string? GetNativeLibraryApkPath() + { + var appInfo = Android.App.Application.Context?.ApplicationInfo; + if (appInfo == null) + return null; + + var abi = GetAbiFolder(); + if (!string.IsNullOrEmpty(abi) && appInfo.SplitSourceDirs != null) + { + foreach (var split in appInfo.SplitSourceDirs) + { + if (split.Contains(abi, StringComparison.Ordinal)) + return split; + } + } + + return appInfo.SourceDir; + } + + private static string? GetAbiFolder() + { + var nativeDir = Android.App.Application.Context?.ApplicationInfo?.NativeLibraryDir; + if (!string.IsNullOrEmpty(nativeDir)) + return Path.GetFileName(nativeDir.TrimEnd('/')); + + return Android.OS.Build.SupportedAbis?.FirstOrDefault() switch + { + "x86_64" => "x86_64", + "arm64-v8a" => "arm64-v8a", + "armeabi-v7a" => "armeabi-v7a", + "x86" => "x86", + _ => null + }; + } + + private static readonly string SystemLibcPath = File.Exists("/system/lib64/libc.so") + ? "/system/lib64/libc.so" + : "/system/lib/libc.so"; + + [DllImport("/system/lib64/libc.so", EntryPoint = "dlopen", BestFitMapping = false)] + private static extern IntPtr SystemLib64Dlopen(string file, int mode); + + [DllImport("/system/lib/libc.so", EntryPoint = "dlopen", BestFitMapping = false)] + private static extern IntPtr SystemLibDlopen(string file, int mode); + + private static IntPtr Dlopen(string path, int mode) => + SystemLibcPath.Contains("lib64", StringComparison.Ordinal) + ? SystemLib64Dlopen(path, mode) + : SystemLibDlopen(path, mode); + } +} diff --git a/source/icu.net/IcuWrapper.cs b/source/icu.net/IcuWrapper.cs index 00ffc68e..6d544487 100644 --- a/source/icu.net/IcuWrapper.cs +++ b/source/icu.net/IcuWrapper.cs @@ -107,6 +107,9 @@ public static void SetPreferredIcu4cDirectory(string directory) [PublicAPI] public static ErrorCode Init() { +#if __ANDROID__ + AndroidIcuBootstrap.EnsureConfigured(); +#endif NativeMethods.u_init(out var errorCode); return errorCode; } diff --git a/source/icu.net/NativeMethods/NativeMethods.Android.cs b/source/icu.net/NativeMethods/NativeMethods.Android.cs new file mode 100644 index 00000000..5f96f8e3 --- /dev/null +++ b/source/icu.net/NativeMethods/NativeMethods.Android.cs @@ -0,0 +1,284 @@ +// Copyright (c) 2013-2026 SIL Global +// This software is licensed under the MIT license (http://opensource.org/licenses/MIT) +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +// ReSharper disable once CheckNamespace +namespace Icu +{ + internal static partial class NativeMethods + { + private static bool _androidUsesUnversionedNativeLibs; + private static bool _androidDependenciesLoaded; + + /// + /// When Android APKs bundle ICU as libicuuc.so (no version suffix), set the major ICU version before Init(). + /// + internal static int? AndroidBundledIcuMajorVersion { get; set; } + + internal static Func AndroidLoadNativeLibrary { get; set; } + + private static IntPtr AndroidSystemLibc; + + private static bool TryCheckAndroidDirectoryForIcuBinaries(string directory, string libraryName) + { + // Android loads native libs from the APK without materializing them as regular files, + // so File.Exists/EnumerateFiles on NativeLibraryDir often returns nothing. + if (AndroidBundledIcuMajorVersion is int bundledVersion && + !string.IsNullOrEmpty(PreferredDirectory) && + string.Equals(directory, PreferredDirectory, StringComparison.Ordinal) && + libraryName == "icuuc") + { + Trace.WriteLineIf(Verbose, + $"icu.net: using Android bundled ICU {bundledVersion} from '{directory}'"); + _androidUsesUnversionedNativeLibs = true; + IcuVersion = bundledVersion; + _IcuPath = directory; + AddDirectoryToSearchPath(directory); + return true; + } + + if (!Directory.Exists(directory)) + return false; + + var filePattern = GetLibraryFilePattern(libraryName); + var files = Directory.EnumerateFiles(directory, filePattern).ToList(); + var unversioned = Path.Combine(directory, $"lib{libraryName}.so"); + if (File.Exists(unversioned) && !files.Contains(unversioned)) + files.Insert(0, unversioned); + + if (files.Count <= 0) + return false; + + files.Sort((x, y) => + { + var vx = TryParseIcuLibraryMajorVersion(x, libraryName, out var nx) ? nx : -1; + var vy = TryParseIcuLibraryMajorVersion(y, libraryName, out var ny) ? ny : -1; + var cmp = vy.CompareTo(vx); + if (cmp != 0) + return cmp; + return string.Compare(Path.GetFileName(y), Path.GetFileName(x), StringComparison.Ordinal); + }); + + foreach (var filePath in files) + { + if (!TryParseIcuLibraryMajorVersion(filePath, libraryName, out var icuVersion)) + { + if (Path.GetFileName(filePath) == $"lib{libraryName}.so" && + AndroidBundledIcuMajorVersion is int androidVersion) + { + icuVersion = androidVersion; + _androidUsesUnversionedNativeLibs = true; + } + else + { + Trace.WriteLineIf(Verbose, + $"icu.net: could not parse ICU version from '{filePath}'. Skipping."); + continue; + } + } + Trace.WriteLineIf(Verbose, $"icu.net: Extracted version '{icuVersion}' from '{filePath}'"); + if (icuVersion < MinIcuVersion || icuVersion > MaxIcuVersion) + { + Trace.WriteLineIf(Verbose, + $"icu.net: version {icuVersion} from '{filePath}' is outside [{MinIcuVersion}, {MaxIcuVersion}]. Skipping."); + continue; + } + Trace.TraceInformation("Setting IcuVersion to {0} (found in {1})", + icuVersion, directory); + IcuVersion = icuVersion; + _IcuPath = directory; + AddDirectoryToSearchPath(directory); + return true; + } + + return false; + } + + private static bool TryLocateAndroidIcuLibrary(string libraryName) + { + var arch = IsRunning64Bit ? "x64" : "x86"; + var androidArch = IsRunning64Bit ? "x86_64" : "x86"; + + if (CheckDirectoryForIcuBinaries( + Path.Combine(DirectoryOfThisAssembly, "lib", $"android-{arch}"), + libraryName)) + return true; + + if (CheckDirectoryForIcuBinaries( + Path.Combine(DirectoryOfThisAssembly, "lib", $"android-{androidArch}"), + libraryName)) + return true; + + if (CheckDirectoryForIcuBinaries( + Path.Combine(DirectoryOfThisAssembly, "runtimes", $"android-{androidArch}", "native"), + libraryName)) + return true; + + return CheckDirectoryForIcuBinaries( + Path.Combine(DirectoryOfThisAssembly, "runtimes", "android", "native"), + libraryName); + } + + private static void EnsureAndroidDependenciesLoaded() + { + if (_androidDependenciesLoaded || string.IsNullOrEmpty(_IcuPath)) + return; + + foreach (var lib in new[] { "libc++_shared.so", "libicudata.so", "libicuuc.so", "libicui18n.so" }) + { + if (AndroidLoadNativeLibrary != null) + { + var depHandle = AndroidLoadNativeLibrary(lib); + if (depHandle != IntPtr.Zero) + Trace.WriteLineIf(Verbose, $"icu.net: preloaded {lib}"); + else + Trace.TraceWarning($"icu.net: failed to preload {lib}"); + continue; + } + + var path = Path.Combine(_IcuPath, lib); + try + { + NativeLibrary.Load(path); + Trace.WriteLineIf(Verbose, $"icu.net: preloaded {lib}"); + } + catch (DllNotFoundException ex) + { + Trace.TraceWarning($"icu.net: failed to preload {lib}: {ex.Message}"); + } + } + + _androidDependenciesLoaded = true; + } + + private static IntPtr GetAndroidIcuLibHandle(string basename, int icuVersion) + { + Trace.WriteLineIf(Verbose, $"icu.net: Get ICU Lib handle for {basename}, version {icuVersion}"); + if (icuVersion < MinIcuVersion) + return IntPtr.Zero; + + var libName = _androidUsesUnversionedNativeLibs + ? $"lib{basename}.so" + : $"lib{basename}.so.{icuVersion}"; + var libPath = string.IsNullOrEmpty(_IcuPath) ? libName : Path.Combine(_IcuPath, libName); + + string exceptionErrorMessage = null; + IntPtr handle; + string loadMethod; + if (AndroidLoadNativeLibrary != null) + { + loadMethod = "AndroidLoadNativeLibrary"; + handle = AndroidLoadNativeLibrary(libName); + } + else + { + loadMethod = "NativeLibrary.Load"; + try + { + handle = NativeLibrary.Load(libPath); + } + catch (DllNotFoundException ex) + { + handle = IntPtr.Zero; + exceptionErrorMessage = ex.Message; + } + } + + if (handle != IntPtr.Zero) + { + IcuVersion = icuVersion; + return handle; + } + + var lastError = Marshal.GetLastWin32Error(); + if (!string.IsNullOrEmpty(exceptionErrorMessage)) + exceptionErrorMessage = $" ({exceptionErrorMessage})"; + var errorMsg = $"{lastError}{exceptionErrorMessage}"; + Trace.WriteLineIf(lastError != 0, $"Unable to load [{libPath}]. Error: {errorMsg}"); + Trace.TraceWarning($"{loadMethod} of {libPath} failed with error {errorMsg}"); + return IntPtr.Zero; + } + + private static IntPtr EnsureAndroidSystemLibc() + { + if (AndroidSystemLibc != IntPtr.Zero) + return AndroidSystemLibc; + + try + { + var libcPath = File.Exists("/system/lib64/libc.so") ? "/system/lib64/libc.so" : "/system/lib/libc.so"; + AndroidSystemLibc = NativeLibrary.Load(libcPath); + } + catch (DllNotFoundException) + { + AndroidSystemLibc = IntPtr.Zero; + } + + return AndroidSystemLibc; + } + + private static IntPtr AndroidDlsymFromLib(IntPtr libcHandle, IntPtr libraryHandle, string symbol) + { + if (!NativeLibrary.TryGetExport(libcHandle, "dlsym", out var dlsymPtr)) + return IntPtr.Zero; + + var dlsym = Marshal.GetDelegateForFunctionPointer(dlsymPtr); + return dlsym(libraryHandle, symbol); + } + + // JavaSystem.LoadLibrary succeeds without a real dlopen handle; IntPtr(1) marks that case. + private static IntPtr AndroidDlsym(IntPtr handle, string symbol) + { + if (handle == (IntPtr)1) + handle = IntPtr.Zero; + + var libc = EnsureAndroidSystemLibc(); + if (libc == IntPtr.Zero) + return IntPtr.Zero; + + var ptr = AndroidDlsymFromLib(libc, handle, symbol); + if (ptr != IntPtr.Zero) + return ptr; + if (handle != IntPtr.Zero) + return AndroidDlsymFromLib(libc, IntPtr.Zero, symbol); + + return IntPtr.Zero; + } + + private static T GetAndroidMethod(IntPtr handle, string methodName, bool missingInMinimal = false) + where T : class + { + IntPtr methodPointer; + + var versionedMethodName = $"{methodName}_{IcuVersion}"; + methodPointer = AndroidDlsym(handle, versionedMethodName); + if (methodPointer == IntPtr.Zero) + methodPointer = AndroidDlsym(IntPtr.Zero, versionedMethodName); + + if (methodPointer == IntPtr.Zero) + { + methodPointer = AndroidDlsym(handle, methodName); + if (methodPointer == IntPtr.Zero) + methodPointer = AndroidDlsym(IntPtr.Zero, methodName); + } + + if (methodPointer != IntPtr.Zero) + return Marshal.GetDelegateForFunctionPointer(methodPointer); + + if (missingInMinimal) + { + throw new MissingMemberException( + "Do you have the full version of ICU installed? " + + $"The method '{methodName}' is not included in the minimal version of ICU."); + } + return default(T); + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate IntPtr DlsymDelegate(IntPtr handle, string symbol); + } +} diff --git a/source/icu.net/NativeMethods/NativeMethods.cs b/source/icu.net/NativeMethods/NativeMethods.cs index 5584e37f..f05ea9b4 100644 --- a/source/icu.net/NativeMethods/NativeMethods.cs +++ b/source/icu.net/NativeMethods/NativeMethods.cs @@ -215,6 +215,9 @@ private static void AddDirectoryToSearchPath(string directory) private static bool CheckDirectoryForIcuBinaries(string directory, string libraryName) { Trace.WriteLineIf(Verbose, $"icu.net: checking '{directory}' for ICU binaries"); +#if __ANDROID__ + return TryCheckAndroidDirectoryForIcuBinaries(directory, libraryName); +#else if (!Directory.Exists(directory)) { Trace.WriteLineIf(Verbose, $"icu.net: directory '{directory}' doesn't exist"); @@ -226,23 +229,25 @@ private static bool CheckDirectoryForIcuBinaries(string directory, string librar Trace.WriteLineIf(Verbose, $"icu.net: {files.Count} files in '{directory}' match the pattern '{filePattern}'"); if (files.Count > 0) { - var libNameLen = libraryName.Length; files.Sort((x, y) => { - var vx = int.TryParse(ExtractVersionString(x, libNameLen), out var nx) ? nx : -1; - var vy = int.TryParse(ExtractVersionString(y, libNameLen), out var ny) ? ny : -1; - return vy.CompareTo(vx); + var vx = TryParseIcuLibraryMajorVersion(x, libraryName, out var nx) ? nx : -1; + var vy = TryParseIcuLibraryMajorVersion(y, libraryName, out var ny) ? ny : -1; + var cmp = vy.CompareTo(vx); + if (cmp != 0) + return cmp; + // Prefer libicuuc.so.72.1 over libicuuc.so.72 when major versions tie. + return string.Compare(Path.GetFileName(y), Path.GetFileName(x), StringComparison.Ordinal); }); foreach (var filePath in files) { - var version = ExtractVersionString(filePath, libNameLen); - Trace.WriteLineIf(Verbose, $"icu.net: Extracted version '{version}' from '{filePath}'"); - if (!int.TryParse(version, out var icuVersion)) + if (!TryParseIcuLibraryMajorVersion(filePath, libraryName, out var icuVersion)) { Trace.WriteLineIf(Verbose, - $"icu.net: version '{version}' from '{filePath}' is not parseable. Skipping."); + $"icu.net: could not parse ICU version from '{filePath}'. Skipping."); continue; } + Trace.WriteLineIf(Verbose, $"icu.net: Extracted version '{icuVersion}' from '{filePath}'"); if (icuVersion < MinIcuVersion || icuVersion > MaxIcuVersion) { Trace.WriteLineIf(Verbose, @@ -261,6 +266,7 @@ private static bool CheckDirectoryForIcuBinaries(string directory, string librar Trace.WriteLineIf(Verbose && files.Count <= 0, "icu.net: No files matching pattern. Returning false."); return false; +#endif } private static string GetLibraryFilePattern(string libraryName) @@ -274,11 +280,36 @@ private static string GetLibraryFilePattern(string libraryName) private static string ExtractVersionString(string filePath, int libNameLen) { - return IsWindows - ? Path.GetFileNameWithoutExtension(filePath).Substring(libNameLen) // strip icuuc - : IsMac - ? Path.GetFileNameWithoutExtension(filePath).Substring(libNameLen + 4) // strip libicuuc. - : Path.GetFileName(filePath).Substring(libNameLen + 7); // strip libicuuc.so. + if (IsWindows) + { + var name = Path.GetFileNameWithoutExtension(filePath); + return name.Length > libNameLen ? name.Substring(libNameLen) : string.Empty; + } + + if (IsMac) + { + var name = Path.GetFileNameWithoutExtension(filePath); + var prefixLen = libNameLen + 4; + return name.Length > prefixLen ? name.Substring(prefixLen) : string.Empty; + } + + var fileName = Path.GetFileName(filePath); + var linuxPrefixLen = libNameLen + 7; + return fileName.Length > linuxPrefixLen ? fileName.Substring(linuxPrefixLen) : string.Empty; + } + + private static bool TryParseIcuLibraryMajorVersion(string filePath, string libraryName, out int majorVersion) + { + majorVersion = -1; + var versionString = ExtractVersionString(filePath, libraryName.Length); + if (string.IsNullOrEmpty(versionString)) + return false; + + var dot = versionString.IndexOf('.'); + if (dot >= 0) + versionString = versionString.Substring(0, dot); + + return int.TryParse(versionString, out majorVersion); } private static bool LocateIcuLibrary(string libraryName) @@ -299,6 +330,11 @@ private static bool LocateIcuLibrary(string libraryName) libraryName)) return true; +#if __ANDROID__ + if (TryLocateAndroidIcuLibrary(libraryName)) + return true; +#endif + // Next look in lib/{x86,x64} subdirectory if (CheckDirectoryForIcuBinaries( Path.Combine(DirectoryOfThisAssembly, "lib", arch), @@ -360,6 +396,10 @@ private static IntPtr LoadIcuLibrary(string libraryName) if (IcuVersion <= 0) LocateIcuLibrary(libraryName); +#if __ANDROID__ + EnsureAndroidDependenciesLoaded(); +#endif + var handle = GetIcuLibHandle(libraryName, IcuVersion > 0 ? IcuVersion : MaxIcuVersion); if (handle == IntPtr.Zero) { @@ -374,6 +414,17 @@ private static IntPtr GetIcuLibHandle(string basename, int icuVersion) { while (true) { +#if __ANDROID__ + if (icuVersion < MinIcuVersion) + return IntPtr.Zero; + + var androidHandle = GetAndroidIcuLibHandle(basename, icuVersion); + if (androidHandle != IntPtr.Zero) + return androidHandle; + if (_androidUsesUnversionedNativeLibs) + return IntPtr.Zero; + icuVersion -= 1; +#else Trace.WriteLineIf(Verbose, $"icu.net: Get ICU Lib handle for {basename}, version {icuVersion}"); if (icuVersion < MinIcuVersion) return IntPtr.Zero; @@ -445,6 +496,7 @@ private static IntPtr GetIcuLibHandle(string basename, int icuVersion) Trace.WriteLineIf(lastError != 0, $"Unable to load [{libPath}]. Error: {errorMsg}"); Trace.TraceWarning($"{loadMethod} of {libPath} failed with error {errorMsg}"); icuVersion -= 1; +#endif } } @@ -553,6 +605,9 @@ private static void ResetIcuVersionInfo() // This method is thread-safe and idempotent private static T GetMethod(IntPtr handle, string methodName, bool missingInMinimal = false) where T : class { +#if __ANDROID__ + return GetAndroidMethod(handle, methodName, missingInMinimal); +#else IntPtr methodPointer; var versionedMethodName = $"{methodName}_{IcuVersion}"; @@ -608,6 +663,7 @@ private static T GetMethod(IntPtr handle, string methodName, bool missingInMi $"The method '{methodName}' is not included in the minimal version of ICU."); } return default(T); +#endif } #endregion @@ -876,8 +932,11 @@ internal delegate int u_strToUpperDelegate(IntPtr dest, int destCapacity, string internal static void u_init(out ErrorCode errorCode) { IsInitialized = true; + var handle = IcuCommonLibHandle; + if (Methods.u_init == null) + Methods.u_init = GetMethod(handle, "u_init"); if (Methods.u_init == null) - Methods.u_init = GetMethod(IcuCommonLibHandle, "u_init"); + throw new MissingMethodException($"ICU entry point u_init_{IcuVersion} was not found."); Methods.u_init(out errorCode); } diff --git a/source/icu.net/Platform.cs b/source/icu.net/Platform.cs index d922d8a9..1a309ff7 100644 --- a/source/icu.net/Platform.cs +++ b/source/icu.net/Platform.cs @@ -36,6 +36,10 @@ public static OperatingSystemType OperatingSystem get { #if NET || NETSTANDARD +#if NET5_0_OR_GREATER + if (System.OperatingSystem.IsAndroid()) + return OperatingSystemType.Unix; +#endif if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return OperatingSystemType.Windows; else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) diff --git a/source/icu.net/icu.net.csproj b/source/icu.net/icu.net.csproj index 9db1b2ce..a28b2ad6 100644 --- a/source/icu.net/icu.net.csproj +++ b/source/icu.net/icu.net.csproj @@ -4,7 +4,25 @@ icu.net icu.net is a C# Wrapper around ICU4C README.md + + + $(TargetFrameworks);net10.0-android + + + + 21.0 + + + + + + + + + + +