From 44e4203b502cb02a21723015b810cf8f209359ed Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 10:26:55 +0100 Subject: [PATCH 01/12] test(android): reproduce hosted native sources on Windows --- .github/workflows/android_windows_sources.yml | 42 +++++ scripts/check_android_windows_sources.py | 164 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 .github/workflows/android_windows_sources.yml create mode 100644 scripts/check_android_windows_sources.py diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml new file mode 100644 index 0000000..b7b85f5 --- /dev/null +++ b/.github/workflows/android_windows_sources.yml @@ -0,0 +1,42 @@ +name: Android native sources on Windows + +on: + pull_request: + paths: + - zstandard_android/** + - zstandard_native/** + - scripts/check_android_windows_sources.py + - .github/workflows/android_windows_sources.yml + +permissions: + contents: read + +jobs: + reproduce-source-resolution: + name: Reproduce Android Pub-cache source lookup on Windows + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: + group: vypdev + labels: [self-hosted, Windows, X64] + timeout-minutes: 20 + env: + PYTHONDONTWRITEBYTECODE: '1' + REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Reproduce the published failure and build its explicit-root control + shell: powershell + run: python scripts/check_android_windows_sources.py --baseline-version 1.5.3 --report-directory "$env:REPRODUCTION_REPORT" + - name: Retain Windows reproduction and emulator capability evidence + if: always() + uses: actions/upload-artifact@v5 + with: + name: android-windows-source-reproduction-${{ github.run_attempt }} + path: ${{ env.REPRODUCTION_REPORT }} + if-no-files-found: error + retention-days: 14 diff --git a/scripts/check_android_windows_sources.py b/scripts/check_android_windows_sources.py new file mode 100644 index 0000000..cd182ff --- /dev/null +++ b/scripts/check_android_windows_sources.py @@ -0,0 +1,164 @@ +"""Reproduce Android source discovery with a real Windows-hosted NDK build.""" + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import platform +import shutil +import struct +import subprocess +import tarfile +import tempfile +import urllib.request + + +def run(command, report, *, expected_success=True, timeout=300): + result = subprocess.run([str(part) for part in command], capture_output=True, + text=True, errors='replace', timeout=timeout) + output = result.stdout + result.stderr + report.write_text(output, encoding='utf-8') + print(output[-8000:], flush=True) + if expected_success and result.returncode: + raise RuntimeError(f'{command[0]} failed; see {report.name}') + return result.returncode, output + + +def sdk_tools(report): + roots = [Path(value) for name in ['ANDROID_SDK_ROOT', 'ANDROID_HOME'] + if (value := os.environ.get(name))] + if os.environ.get('LOCALAPPDATA'): + roots.append(Path(os.environ['LOCALAPPDATA']) / 'Android/Sdk') + roots += [Path.home() / 'Library/Android/sdk', Path.home() / 'Android/Sdk'] + sdk = next((root for root in roots if any((root / child).is_dir() + for child in ['cmdline-tools', 'platform-tools', 'ndk'])), None) + information = {'host': platform.system(), 'architecture': platform.machine(), + 'sdk': str(sdk) if sdk else None, + 'free_temporary_bytes': shutil.disk_usage(tempfile.gettempdir()).free} + executable = '.exe' if os.name == 'nt' else '' + ndks = sorted((sdk / 'ndk').iterdir(), reverse=True) if sdk and (sdk / 'ndk').is_dir() else [] + preferred = next((p for p in ndks if p.name == '27.0.12077973'), None) + ndk = preferred or next((p for p in ndks if + (p / 'build/cmake/android.toolchain.cmake').is_file()), None) + information['ndk'] = str(ndk) if ndk else None + candidates = [] + if sdk: + candidates.append(sdk / f'cmake/3.22.1/bin/cmake{executable}') + if (sdk / 'cmake').is_dir(): + candidates += [p / f'bin/cmake{executable}' + for p in sorted((sdk / 'cmake').iterdir(), reverse=True)] + if shutil.which('cmake'): + candidates.append(Path(shutil.which('cmake'))) + if os.name == 'nt': + for variable in ['ProgramFiles', 'ProgramFiles(x86)']: + if os.environ.get(variable): + candidates.append(Path(os.environ[variable]) / 'CMake/bin/cmake.exe') + if os.environ.get('ProgramFiles(x86)'): + vswhere = Path(os.environ['ProgramFiles(x86)']) / 'Microsoft Visual Studio/Installer/vswhere.exe' + if vswhere.is_file(): + installations = json.loads(subprocess.check_output( + [str(vswhere), '-products', '*', '-format', 'json'], text=True)) + candidates += [Path(item['installationPath']) / + 'Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe' + for item in installations] + cmake = next((p for p in candidates if p.is_file()), None) + information['cmake'] = str(cmake) if cmake else None + ninja_candidates = [cmake.parent / ('ninja' + executable)] if cmake else [] + if cmake and os.name == 'nt': + ninja_candidates.append(cmake.parent.parent.parent / 'Ninja/ninja.exe') + if shutil.which('ninja'): + ninja_candidates.append(Path(shutil.which('ninja'))) + ninja = next((p for p in ninja_candidates if p.is_file()), None) + information['ninja'] = str(ninja) if ninja else None + emulator = sdk / ('emulator/emulator' + executable) if sdk else None + information['emulator_available'] = bool(emulator and emulator.is_file()) + if information['emulator_available']: + result, _ = run([emulator, '-accel-check'], report / 'emulator-acceleration.txt', + expected_success=False, timeout=30) + information['emulator_acceleration_check_exit'] = result + (report / 'toolchain.json').write_text(json.dumps(information, indent=2), encoding='utf-8') + print(json.dumps(information, indent=2), flush=True) + if not all([sdk, ndk, cmake, ninja]): + raise RuntimeError('An Android SDK, NDK, CMake and Ninja are required; see toolchain.json.') + run([cmake, '--version'], report / 'cmake-version.txt') + return ndk, cmake, ninja + + +def published_package(package, version, destination): + def get(url): + with urllib.request.urlopen(url, timeout=60) as response: + return response.read() + + metadata = json.loads(get(f'https://pub.dev/api/packages/{package}/versions/{version}')) + assert metadata['version'] == version and metadata['pubspec']['name'] == package + archive = get(metadata['archive_url']) + digest = hashlib.sha256(archive).hexdigest() + assert digest == metadata['archive_sha256'] + destination.mkdir(parents=True) + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar: + for member in tar: + relative = Path(member.name) + target = destination / relative + if relative.is_absolute() or '..' in relative.parts or not target.resolve().is_relative_to(destination.resolve()): + raise ValueError('Unsafe package archive member') + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + elif member.isfile(): + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(tar.extractfile(member).read()) + else: + raise ValueError('Expected only regular package files and directories') + return {'package': package, 'version': version, 'archive_sha256': digest} + + +def reproduce(version, report): + ndk, cmake, ninja = sdk_tools(report) + with tempfile.TemporaryDirectory(prefix='zstandard android windows ') as temporary: + stage = Path(temporary) + cache = stage / 'Pub cache/hosted/pub.dev' + plugin = cache / f'zstandard_android-{version}' + native = cache / f'zstandard_native-{version}' + packages = [published_package('zstandard_android', version, plugin), + published_package('zstandard_native', version, native)] + consumer = stage / 'separate consumer' + configuration = consumer / '.dart_tool/package_config.json' + configuration.parent.mkdir(parents=True) + configuration.write_text(json.dumps({'configVersion': 2, 'packages': [ + {'name': 'zstandard_android', 'rootUri': plugin.as_uri() + '/', 'packageUri': 'lib/'}, + {'name': 'zstandard_native', 'rootUri': native.as_uri() + '/', 'packageUri': 'lib/'} + ]}, indent=2), encoding='utf-8') + common = [cmake, '-G', 'Ninja', '-S', plugin / 'zstd_build', + f'-DCMAKE_MAKE_PROGRAM={ninja}', + f'-DCMAKE_TOOLCHAIN_FILE={ndk / "build/cmake/android.toolchain.cmake"}', + '-DANDROID_ABI=arm64-v8a', '-DANDROID_PLATFORM=android-21', + '-DCMAKE_BUILD_TYPE=RelWithDebInfo'] + result, output = run(common + ['-B', stage / 'baseline'], + report / 'baseline-configure.txt', expected_success=False) + if result == 0 or 'zstd source not found' not in output: + raise RuntimeError('The expected source-discovery failure was not reproduced.') + control = stage / 'explicit-root-control' + run(common + ['-B', control, f'-DZSTD_ROOT={native / "src/zstd"}'], + report / 'control-configure.txt') + run([cmake, '--build', control, '--parallel', '2'], report / 'control-build.txt') + library = control / 'libzstandard_android.so' + data = library.read_bytes() + assert data[:4] == b'\x7fELF' and struct.unpack_from(' Date: Thu, 29 Oct 2026 10:30:28 +0100 Subject: [PATCH 02/12] fix(ci): evaluate runner context inside the Windows step --- .github/workflows/android_windows_sources.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml index b7b85f5..20743f7 100644 --- a/.github/workflows/android_windows_sources.yml +++ b/.github/workflows/android_windows_sources.yml @@ -21,7 +21,6 @@ jobs: timeout-minutes: 20 env: PYTHONDONTWRITEBYTECODE: '1' - REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@v6 with: @@ -31,12 +30,14 @@ jobs: python-version: '3.13' - name: Reproduce the published failure and build its explicit-root control shell: powershell + env: + REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} run: python scripts/check_android_windows_sources.py --baseline-version 1.5.3 --report-directory "$env:REPRODUCTION_REPORT" - name: Retain Windows reproduction and emulator capability evidence if: always() uses: actions/upload-artifact@v5 with: name: android-windows-source-reproduction-${{ github.run_attempt }} - path: ${{ env.REPRODUCTION_REPORT }} + path: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} if-no-files-found: error retention-days: 14 From b2c3b02905b95f9e7e0930c12a5dd307e53be7bf Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 10:35:28 +0100 Subject: [PATCH 03/12] fix(ci): use job-local portable Python on Windows --- .github/workflows/android_windows_sources.yml | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml index 20743f7..c89d494 100644 --- a/.github/workflows/android_windows_sources.yml +++ b/.github/workflows/android_windows_sources.yml @@ -25,9 +25,31 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: actions/setup-python@v6 - with: - python-version: '3.13' + - name: Prepare verified job-local Python without a Windows installer + shell: powershell + env: + REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} + run: | + $ErrorActionPreference = 'Stop' + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + New-Item -ItemType Directory -Path $env:REPRODUCTION_REPORT -Force | Out-Null + $utf8 = New-Object System.Text.UTF8Encoding($false) + $version = '3.13.17' + $expected = '9e35319bffedab8b36ad44d8f377cb4f1733bcec257a0b6d04d94f01486ddb38' + $url = "https://www.python.org/ftp/python/$version/python-$version-embed-amd64.zip" + [IO.File]::WriteAllText((Join-Path $env:REPRODUCTION_REPORT 'python-bootstrap.txt'), "$url`nSHA256 $expected`n", $utf8) + $directory = Join-Path $env:RUNNER_TEMP "zstandard-python-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + New-Item -ItemType Directory -Path $directory | Out-Null + $archive = Join-Path $directory 'python.zip' + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $archive + if ((Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() -ne $expected) { + throw 'The portable Python archive does not match the official release checksum.' + } + $runtime = Join-Path $directory 'runtime' + Expand-Archive -LiteralPath $archive -DestinationPath $runtime + & (Join-Path $runtime 'python.exe') --version + if ($LASTEXITCODE -ne 0) { throw 'The job-local Python runtime could not execute.' } + [IO.File]::AppendAllText($env:GITHUB_PATH, "$runtime`n", $utf8) - name: Reproduce the published failure and build its explicit-root control shell: powershell env: From 0aa4da439b1c5af6e79bb88fc6390a6d339c2baf Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 10:43:30 +0100 Subject: [PATCH 04/12] ci(android): provision a private Windows SDK for reproduction --- .github/workflows/android_windows_sources.yml | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml index c89d494..e5a62f6 100644 --- a/.github/workflows/android_windows_sources.yml +++ b/.github/workflows/android_windows_sources.yml @@ -18,7 +18,7 @@ jobs: runs-on: group: vypdev labels: [self-hosted, Windows, X64] - timeout-minutes: 20 + timeout-minutes: 30 env: PYTHONDONTWRITEBYTECODE: '1' steps: @@ -50,6 +50,46 @@ jobs: & (Join-Path $runtime 'python.exe') --version if ($LASTEXITCODE -ne 0) { throw 'The job-local Python runtime could not execute.' } [IO.File]::AppendAllText($env:GITHUB_PATH, "$runtime`n", $utf8) + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + - name: Prepare a private Windows Android SDK + shell: powershell + env: + REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} + run: | + $ErrorActionPreference = 'Stop' + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $utf8 = New-Object System.Text.UTF8Encoding($false) + $sdk = Join-Path $env:RUNNER_TEMP ("zstandard-android-sdk-{0}-{1}" -f $env:GITHUB_RUN_ID, $env:GITHUB_RUN_ATTEMPT) + New-Item -ItemType Directory -Path $sdk | Out-Null + $archive = Join-Path $sdk 'command-line-tools.zip' + $url = 'https://dl.google.com/android/repository/commandlinetools-win-15859902_latest.zip' + $expected = '90ae805d20434428bffcb699c290860f19bb5f66a67e6b330067e3de801fb04a' + [IO.File]::WriteAllText((Join-Path $env:REPRODUCTION_REPORT 'android-sdk-bootstrap.txt'), "$url`nSHA256 $expected`n", $utf8) + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $archive + if ((Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() -ne $expected) { + throw 'The Android command-line tools do not match the official checksum.' + } + $extracted = Join-Path $sdk 'extracted-tools' + Expand-Archive -LiteralPath $archive -DestinationPath $extracted + New-Item -ItemType Directory -Path (Join-Path $sdk 'cmdline-tools') | Out-Null + $tools = Join-Path $sdk 'cmdline-tools/reviewed' + Move-Item -LiteralPath (Join-Path $extracted 'cmdline-tools') -Destination $tools + $env:ANDROID_HOME = $sdk + $env:ANDROID_SDK_ROOT = $sdk + $env:ANDROID_USER_HOME = Join-Path $sdk 'user-state' + New-Item -ItemType Directory -Path $env:ANDROID_USER_HOME | Out-Null + $manager = Join-Path $tools 'bin/sdkmanager.bat' + (1..100 | ForEach-Object { 'y' }) | & $manager "--sdk_root=$sdk" --licenses + if ($LASTEXITCODE -ne 0) { throw 'The private Android SDK licenses could not be prepared.' } + & $manager "--sdk_root=$sdk" --install 'ndk;27.0.12077973' 'cmake;3.22.1' platform-tools emulator + if ($LASTEXITCODE -ne 0) { throw 'The private Windows Android SDK could not be provisioned.' } + foreach ($name in @('ANDROID_HOME', 'ANDROID_SDK_ROOT', 'ANDROID_USER_HOME')) { + $value = [Environment]::GetEnvironmentVariable($name) + [IO.File]::AppendAllText($env:GITHUB_ENV, "$name=$value`n", $utf8) + } - name: Reproduce the published failure and build its explicit-root control shell: powershell env: From 153da6a16a10953ab780da05a3cce99b1e222bfd Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 11:11:12 +0100 Subject: [PATCH 05/12] ci(android): bound and report Windows tools preparation --- .github/workflows/android_windows_sources.yml | 23 ++--- scripts/download_android_windows_tools.py | 83 +++++++++++++++++++ 2 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 scripts/download_android_windows_tools.py diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml index e5a62f6..67b75f3 100644 --- a/.github/workflows/android_windows_sources.yml +++ b/.github/workflows/android_windows_sources.yml @@ -6,6 +6,7 @@ on: - zstandard_android/** - zstandard_native/** - scripts/check_android_windows_sources.py + - scripts/download_android_windows_tools.py - .github/workflows/android_windows_sources.yml permissions: @@ -31,6 +32,7 @@ jobs: REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} run: | $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 New-Item -ItemType Directory -Path $env:REPRODUCTION_REPORT -Force | Out-Null $utf8 = New-Object System.Text.UTF8Encoding($false) @@ -60,32 +62,23 @@ jobs: REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} run: | $ErrorActionPreference = 'Stop' - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $utf8 = New-Object System.Text.UTF8Encoding($false) $sdk = Join-Path $env:RUNNER_TEMP ("zstandard-android-sdk-{0}-{1}" -f $env:GITHUB_RUN_ID, $env:GITHUB_RUN_ATTEMPT) - New-Item -ItemType Directory -Path $sdk | Out-Null - $archive = Join-Path $sdk 'command-line-tools.zip' - $url = 'https://dl.google.com/android/repository/commandlinetools-win-15859902_latest.zip' - $expected = '90ae805d20434428bffcb699c290860f19bb5f66a67e6b330067e3de801fb04a' - [IO.File]::WriteAllText((Join-Path $env:REPRODUCTION_REPORT 'android-sdk-bootstrap.txt'), "$url`nSHA256 $expected`n", $utf8) - Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $archive - if ((Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() -ne $expected) { - throw 'The Android command-line tools do not match the official checksum.' - } - $extracted = Join-Path $sdk 'extracted-tools' - Expand-Archive -LiteralPath $archive -DestinationPath $extracted - New-Item -ItemType Directory -Path (Join-Path $sdk 'cmdline-tools') | Out-Null - $tools = Join-Path $sdk 'cmdline-tools/reviewed' - Move-Item -LiteralPath (Join-Path $extracted 'cmdline-tools') -Destination $tools + python scripts/download_android_windows_tools.py --sdk-root "$sdk" --report-directory "$env:REPRODUCTION_REPORT" + if ($LASTEXITCODE -ne 0) { throw 'The reviewed command-line tools could not be prepared.' } + $tools = Join-Path $sdk 'cmdline-tools/latest' $env:ANDROID_HOME = $sdk $env:ANDROID_SDK_ROOT = $sdk $env:ANDROID_USER_HOME = Join-Path $sdk 'user-state' New-Item -ItemType Directory -Path $env:ANDROID_USER_HOME | Out-Null $manager = Join-Path $tools 'bin/sdkmanager.bat' + Write-Host ("[{0:o}] Preparing private Android SDK licenses" -f (Get-Date)) (1..100 | ForEach-Object { 'y' }) | & $manager "--sdk_root=$sdk" --licenses if ($LASTEXITCODE -ne 0) { throw 'The private Android SDK licenses could not be prepared.' } + Write-Host ("[{0:o}] Installing NDK 27, CMake 3.22.1 and emulator tools" -f (Get-Date)) & $manager "--sdk_root=$sdk" --install 'ndk;27.0.12077973' 'cmake;3.22.1' platform-tools emulator if ($LASTEXITCODE -ne 0) { throw 'The private Windows Android SDK could not be provisioned.' } + Write-Host ("[{0:o}] Private Android SDK provisioning finished" -f (Get-Date)) foreach ($name in @('ANDROID_HOME', 'ANDROID_SDK_ROOT', 'ANDROID_USER_HOME')) { $value = [Environment]::GetEnvironmentVariable($name) [IO.File]::AppendAllText($env:GITHUB_ENV, "$name=$value`n", $utf8) diff --git a/scripts/download_android_windows_tools.py b/scripts/download_android_windows_tools.py new file mode 100644 index 0000000..9a5ddc3 --- /dev/null +++ b/scripts/download_android_windows_tools.py @@ -0,0 +1,83 @@ +"""Download the reviewed Android tools into a fresh job-private Windows SDK.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import tempfile +import time +import urllib.request +import zipfile + +URL = 'https://dl.google.com/android/repository/commandlinetools-win-15859902_latest.zip' +SHA256 = '90ae805d20434428bffcb699c290860f19bb5f66a67e6b330067e3de801fb04a' + + +def prepare(sdk, report): + temporary = Path(os.environ.get('RUNNER_TEMP', tempfile.gettempdir())).resolve() + sdk = sdk.resolve() + if sdk.parent != temporary or sdk.exists(): + raise ValueError('Expected a fresh SDK directory directly inside RUNNER_TEMP.') + sdk.mkdir() + report.mkdir(parents=True, exist_ok=True) + started = time.monotonic() + receipt = {'url': URL, 'expected_sha256': SHA256, 'sdk': str(sdk), 'stage': 'download'} + + def note(message): + print(message, flush=True) + with (report / 'android-tools-progress.txt').open('a', encoding='utf-8') as stream: + stream.write(message + '\n') + (report / 'android-tools-bootstrap.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8') + + try: + archive = sdk / 'command-line-tools.zip' + digest = hashlib.sha256() + downloaded = 0 + note('Downloading reviewed Android command-line tools with a five-minute total limit.') + with urllib.request.urlopen(URL, timeout=30) as response, archive.open('wb') as output: + while block := response.read(1024 * 1024): + if time.monotonic() - started > 300: + raise TimeoutError('Android tools download exceeded five minutes.') + digest.update(block) + output.write(block) + downloaded += len(block) + if downloaded % (8 * 1024 * 1024) == 0: + receipt['downloaded_bytes'] = downloaded + note(f'Downloaded {downloaded // (1024 * 1024)} MiB in {time.monotonic() - started:.1f}s.') + receipt.update({'downloaded_bytes': downloaded, 'sha256': digest.hexdigest()}) + if digest.hexdigest() != SHA256: + raise ValueError('Android tools checksum mismatch.') + receipt['stage'] = 'extract' + note(f'Checksum verified after {time.monotonic() - started:.1f}s; extracting.') + extracted = sdk / 'archive-root' + with zipfile.ZipFile(archive) as package: + for index, member in enumerate(package.infolist(), 1): + destination = (extracted / member.filename).resolve() + if not destination.is_relative_to(extracted.resolve()): + raise ValueError('Unsafe Android tools archive member.') + package.extract(member, extracted) + if index % 32 == 0: + note(f'Extracted {index} files in {time.monotonic() - started:.1f}s.') + tools = sdk / 'cmdline-tools/latest' + tools.parent.mkdir() + shutil.move(str(extracted / 'cmdline-tools'), str(tools)) + if not (tools / 'bin/sdkmanager.bat').is_file(): + raise ValueError('The reviewed archive did not contain sdkmanager.bat.') + extracted.rmdir() + archive.unlink() + receipt.update({'stage': 'ready', 'duration_seconds': time.monotonic() - started}) + note('Reviewed Android command-line tools are ready.') + except Exception as error: + receipt.update({'stage': 'failed', 'error': str(error), 'duration_seconds': time.monotonic() - started}) + note('Android command-line tools preparation failed: ' + str(error)) + raise + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sdk-root', type=Path, required=True) + parser.add_argument('--report-directory', type=Path, required=True) + arguments = parser.parse_args() + prepare(arguments.sdk_root, arguments.report_directory) From e6ffa06aaa3718a16d87bf882e0897b92871480d Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 11:23:42 +0100 Subject: [PATCH 06/12] fix(ci): provide explicit SDK manager input on Windows --- .github/workflows/android_windows_sources.yml | 7 +-- scripts/download_android_windows_tools.py | 46 ++++++++++++++++++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml index 67b75f3..3b5a22f 100644 --- a/.github/workflows/android_windows_sources.yml +++ b/.github/workflows/android_windows_sources.yml @@ -71,12 +71,7 @@ jobs: $env:ANDROID_SDK_ROOT = $sdk $env:ANDROID_USER_HOME = Join-Path $sdk 'user-state' New-Item -ItemType Directory -Path $env:ANDROID_USER_HOME | Out-Null - $manager = Join-Path $tools 'bin/sdkmanager.bat' - Write-Host ("[{0:o}] Preparing private Android SDK licenses" -f (Get-Date)) - (1..100 | ForEach-Object { 'y' }) | & $manager "--sdk_root=$sdk" --licenses - if ($LASTEXITCODE -ne 0) { throw 'The private Android SDK licenses could not be prepared.' } - Write-Host ("[{0:o}] Installing NDK 27, CMake 3.22.1 and emulator tools" -f (Get-Date)) - & $manager "--sdk_root=$sdk" --install 'ndk;27.0.12077973' 'cmake;3.22.1' platform-tools emulator + python scripts/download_android_windows_tools.py --sdk-root "$sdk" --report-directory "$env:REPRODUCTION_REPORT" --install-packages if ($LASTEXITCODE -ne 0) { throw 'The private Windows Android SDK could not be provisioned.' } Write-Host ("[{0:o}] Private Android SDK provisioning finished" -f (Get-Date)) foreach ($name in @('ANDROID_HOME', 'ANDROID_SDK_ROOT', 'ANDROID_USER_HOME')) { diff --git a/scripts/download_android_windows_tools.py b/scripts/download_android_windows_tools.py index 9a5ddc3..5e5be02 100644 --- a/scripts/download_android_windows_tools.py +++ b/scripts/download_android_windows_tools.py @@ -6,6 +6,7 @@ import os from pathlib import Path import shutil +import subprocess import tempfile import time import urllib.request @@ -75,9 +76,52 @@ def note(message): raise +def install_packages(sdk, report): + sdk = sdk.resolve() + if sdk.parent != Path(os.environ['RUNNER_TEMP']).resolve(): + raise ValueError('Package installation must remain inside the job-private SDK.') + tools = sdk / 'cmdline-tools/latest' + java = Path(os.environ['JAVA_HOME']) / 'bin/java.exe' + classpath = tools / 'lib/sdkmanager-classpath.jar' + if not java.is_file() or not classpath.is_file(): + raise ValueError('The selected JDK and reviewed SDK manager are required.') + # This is the Java invocation in the reviewed vendor sdkmanager.bat. Calling + # it directly gives Java explicit UTF-8 stdin without a PowerShell/batch pipe. + command = [str(java), '-Dfile.encoding=UTF-8', + f'-Dcom.android.sdklib.toolsdir={tools}', '-classpath', str(classpath), + 'com.android.sdklib.tool.sdkmanager.SdkManagerCli', f'--sdk_root={sdk}'] + requested = ['ndk;27.0.12077973', 'cmake;3.22.1', 'platform-tools', 'emulator'] + for phase, arguments, timeout in [('licenses', ['--licenses'], 180), + ('install', ['--install', *requested], 900)]: + print(f'Running SDK manager {phase} with explicit stdin (timeout {timeout}s).', flush=True) + receipt = {'stage': phase, 'requested_packages': requested, 'explicit_stdin': True} + (report / 'android-packages.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8') + result = subprocess.run(command + arguments, input='y\n' * 256, + capture_output=True, text=True, encoding='utf-8', + errors='replace', timeout=timeout) + output = result.stdout + result.stderr + (report / f'android-{phase}.txt').write_text(output, encoding='utf-8') + print(output[-1800:], flush=True) + if result.returncode: + raise RuntimeError(f'Android SDK manager {phase} failed with exit {result.returncode}.') + required = ['ndk/27.0.12077973/build/cmake/android.toolchain.cmake', + 'cmake/3.22.1/bin/cmake.exe', 'cmake/3.22.1/bin/ninja.exe', + 'platform-tools/adb.exe', 'emulator/emulator.exe'] + missing = [relative for relative in required if not (sdk / relative).is_file()] + if missing: + raise RuntimeError('SDK manager did not install required files: ' + ', '.join(missing)) + receipt.update({'stage': 'ready', 'verified_files': required}) + (report / 'android-packages.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8') + print('Private Android SDK packages and executables verified.', flush=True) + + if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--sdk-root', type=Path, required=True) parser.add_argument('--report-directory', type=Path, required=True) + parser.add_argument('--install-packages', action='store_true') arguments = parser.parse_args() - prepare(arguments.sdk_root, arguments.report_directory) + if arguments.install_packages: + install_packages(arguments.sdk_root, arguments.report_directory) + else: + prepare(arguments.sdk_root, arguments.report_directory) From 2f3e50fd5244c2cda25a93e938022ab82ca32628 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 11:34:01 +0100 Subject: [PATCH 07/12] test(android): normalize the Windows CMake control path --- scripts/check_android_windows_sources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check_android_windows_sources.py b/scripts/check_android_windows_sources.py index cd182ff..5438226 100644 --- a/scripts/check_android_windows_sources.py +++ b/scripts/check_android_windows_sources.py @@ -139,7 +139,7 @@ def reproduce(version, report): if result == 0 or 'zstd source not found' not in output: raise RuntimeError('The expected source-discovery failure was not reproduced.') control = stage / 'explicit-root-control' - run(common + ['-B', control, f'-DZSTD_ROOT={native / "src/zstd"}'], + run(common + ['-B', control, f'-DZSTD_ROOT={(native / "src/zstd").as_posix()}'], report / 'control-configure.txt') run([cmake, '--build', control, '--parallel', '2'], report / 'control-build.txt') library = control / 'libzstandard_android.so' From 1066d657ca11b71540f3d0c8297f598e0e3f0ec7 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 12:06:27 +0100 Subject: [PATCH 08/12] fix(android): resolve native sources from the consuming package configuration --- .github/workflows/android_windows_sources.yml | 52 ++++- scripts/check_android_native_sources.py | 148 ++++++++++++++ scripts/check_android_windows_sources.py | 63 +++++- scripts/download_android_windows_tools.py | 7 +- scripts/fixtures/android_native_smoke.c | 38 ++++ scripts/run_android_windows_emulator.py | 180 ++++++++++++++++++ zstandard_android/android/build.gradle | 6 + .../android/native_sources.gradle | 57 ++++++ zstandard_android/zstd_build/CMakeLists.txt | 56 ++---- 9 files changed, 556 insertions(+), 51 deletions(-) create mode 100644 scripts/check_android_native_sources.py create mode 100644 scripts/fixtures/android_native_smoke.c create mode 100644 scripts/run_android_windows_emulator.py create mode 100644 zstandard_android/android/native_sources.gradle diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml index 3b5a22f..cc581ea 100644 --- a/.github/workflows/android_windows_sources.yml +++ b/.github/workflows/android_windows_sources.yml @@ -6,20 +6,38 @@ on: - zstandard_android/** - zstandard_native/** - scripts/check_android_windows_sources.py + - scripts/check_android_native_sources.py - scripts/download_android_windows_tools.py + - scripts/run_android_windows_emulator.py + - scripts/fixtures/android_native_smoke.c - .github/workflows/android_windows_sources.yml + push: + branches: [develop] + paths: + - zstandard_android/** + - zstandard_native/** + - scripts/check_android_windows_sources.py + - scripts/check_android_native_sources.py + - scripts/download_android_windows_tools.py + - scripts/run_android_windows_emulator.py + - scripts/fixtures/android_native_smoke.c + - .github/workflows/android_windows_sources.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true permissions: contents: read jobs: reproduce-source-resolution: - name: Reproduce Android Pub-cache source lookup on Windows - if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + name: Android Pub-cache regression on Windows + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: group: vypdev labels: [self-hosted, Windows, X64] - timeout-minutes: 30 + timeout-minutes: 45 env: PYTHONDONTWRITEBYTECODE: '1' steps: @@ -56,6 +74,18 @@ jobs: with: distribution: temurin java-version: '17' + - name: Isolate Gradle state + shell: powershell + run: | + $directory = Join-Path $env:RUNNER_TEMP "zstandard-gradle-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + New-Item -ItemType Directory -Path $directory | Out-Null + $utf8 = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText((Join-Path $directory 'gradle.properties'), "org.gradle.daemon=false`norg.gradle.workers.max=2`n", $utf8) + [IO.File]::AppendAllText($env:GITHUB_ENV, "GRADLE_USER_HOME=$directory`n", $utf8) + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.14.2' + cache-disabled: true - name: Prepare a private Windows Android SDK shell: powershell env: @@ -71,18 +101,28 @@ jobs: $env:ANDROID_SDK_ROOT = $sdk $env:ANDROID_USER_HOME = Join-Path $sdk 'user-state' New-Item -ItemType Directory -Path $env:ANDROID_USER_HOME | Out-Null - python scripts/download_android_windows_tools.py --sdk-root "$sdk" --report-directory "$env:REPRODUCTION_REPORT" --install-packages + python scripts/download_android_windows_tools.py --sdk-root "$sdk" --report-directory "$env:REPRODUCTION_REPORT" --install-packages --headless if ($LASTEXITCODE -ne 0) { throw 'The private Windows Android SDK could not be provisioned.' } Write-Host ("[{0:o}] Private Android SDK provisioning finished" -f (Get-Date)) foreach ($name in @('ANDROID_HOME', 'ANDROID_SDK_ROOT', 'ANDROID_USER_HOME')) { $value = [Environment]::GetEnvironmentVariable($name) [IO.File]::AppendAllText($env:GITHUB_ENV, "$name=$value`n", $utf8) } - - name: Reproduce the published failure and build its explicit-root control + - name: Reproduce the published failure and validate the candidate resolver shell: powershell env: REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} - run: python scripts/check_android_windows_sources.py --baseline-version 1.5.3 --report-directory "$env:REPRODUCTION_REPORT" + run: | + $ErrorActionPreference = 'Stop' + python scripts/check_android_windows_sources.py --baseline-version 1.5.3 --gradle (Get-Command gradle.bat).Source --report-directory "$env:REPRODUCTION_REPORT" + if ($LASTEXITCODE -ne 0) { throw 'Android source resolver regression failed.' } + - name: Run the candidate native library on a software Android emulator + shell: powershell + env: + REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} + run: | + python scripts/run_android_windows_emulator.py --sdk-root "$env:ANDROID_HOME" --report-directory "$env:REPRODUCTION_REPORT" + if ($LASTEXITCODE -ne 0) { throw 'Headless Android native execution failed.' } - name: Retain Windows reproduction and emulator capability evidence if: always() uses: actions/upload-artifact@v5 diff --git a/scripts/check_android_native_sources.py b/scripts/check_android_native_sources.py new file mode 100644 index 0000000..b27ab52 --- /dev/null +++ b/scripts/check_android_native_sources.py @@ -0,0 +1,148 @@ +"""Exercise the production Gradle resolver with real, host-native file URIs.""" + +import argparse +import json +import os +from pathlib import Path +import subprocess +import tempfile +from urllib.parse import quote + + +def verify(gradle, plugin, native, stage, report): + cases = [] + + def sources(directory): + root = directory / 'src/zstd' + for part in ['common', 'compress', 'decompress']: + (root / part).mkdir(parents=True, exist_ok=True) + (root / 'zstd.h').touch() + return root + + def configuration(consumer, entries): + path = consumer / '.dart_tool/package_config.json' + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({'configVersion': 2, 'packages': entries}), encoding='utf-8') + return path + + def entry(uri): + return {'name': 'zstandard_native', 'rootUri': uri, 'packageUri': 'lib/'} + + def case(name, consumer, expected=None, error=None, explicit=None, plugin_directory=plugin): + consumer.mkdir(parents=True, exist_ok=True) + cases.append({'name': name, 'consumer': str(consumer), 'plugin': str(plugin_directory), + 'expected': str(expected) if expected else None, + 'error': error, 'explicit': str(explicit) if explicit is not None else None}) + + selected = native / 'src/zstd' + decoy = sources(stage / 'Pub cache/hosted/pub.dev/zstandard_native-other-version') + repository = stage / 'repository' + sibling = sources(repository / 'zstandard_native') + repository_plugin = repository / 'zstandard_android/android' + repository_plugin.mkdir(parents=True) + + absolute = stage / 'separate consumer é/absolute' + configuration(absolute, [entry(native.as_uri() + '/')]) + case('absolute file URI, exact selected version, spaces and Unicode', absolute / 'android', selected) + + relative = stage / 'separate consumer é/relative' + config = relative / '.dart_tool/package_config.json' + uri = quote(os.path.relpath(native, config.parent).replace(os.sep, '/'), safe='/') + '/' + configuration(relative, [entry(uri)]) + case('relative URI is relative to package_config, not Gradle or plugin', relative / 'android', selected) + + workspace = stage / 'workspace with spaces' + configuration(workspace, [entry(native.as_uri() + '/')]) + case('workspace ancestor configuration', workspace / 'apps/mobile/android', selected) + + case('explicit override precedes configured dependency', absolute / 'android', decoy, explicit=decoy) + explicit_relative = os.path.relpath(decoy, absolute / 'android') + case('relative explicit override uses consumer directory', absolute / 'android', decoy, + explicit=explicit_relative) + case('invalid explicit override fails without fallback', absolute / 'android', + error='explicit ZSTD_ROOT', explicit=stage / 'missing') + case('direct repository fallback without package configuration', repository / 'consumer/android', + sibling, plugin_directory=repository_plugin) + + bad = [ + ('missing native dependency', [], 'Expected exactly one'), + ('duplicate native dependencies', [entry(native.as_uri()), entry(decoy.parent.parent.as_uri())], 'Expected exactly one'), + ('empty URI', [entry('')], 'Expected exactly one'), + ('non-string URI', [entry(42)], 'Expected exactly one'), + ('non-file URI', [entry('https://example.invalid/native')], 'Cannot resolve'), + ('malformed URI', [entry('file:///%invalid')], 'Cannot resolve'), + ('missing selected sources', [entry((stage / 'missing package').as_uri())], 'zstd source not found'), + ] + for index, (name, entries, message) in enumerate(bad): + consumer = stage / f'invalid cases/{index}' + configuration(consumer, entries) + case(name + ' cannot select a cached or repository fallback', consumer / 'android', + error=message, plugin_directory=repository_plugin) + + malformed = stage / 'malformed JSON' + config = configuration(malformed, []) + config.write_text('{bad JSON', encoding='utf-8') + case('malformed JSON reports pub get guidance', malformed / 'android', error='Cannot read') + invalid_shape = stage / 'invalid JSON shape' + config = configuration(invalid_shape, []) + config.write_text('{"packages": {}}', encoding='utf-8') + case('invalid package configuration shape', invalid_shape / 'android', error='Invalid package configuration') + + project = stage / 'resolver verification' + project.mkdir() + (project / 'settings.gradle').write_text("rootProject.name = 'native-source-verification'\n", encoding='utf-8') + (project / 'cases.json').write_text(json.dumps(cases), encoding='utf-8') + (project / 'build.gradle').write_text('''import groovy.json.JsonSlurper +import groovy.json.JsonOutput +apply from: new File(new URI(RESOLVER_URI)) +tasks.register('verifySources') { + doLast { + def cases = new JsonSlurper().parse(file('cases.json'), 'UTF-8') + def results = [] + cases.each { item -> + File resolved = null + Exception failure = null + try { + resolved = resolveZstandardNativeRoot(new File(item.consumer), new File(item.plugin), item.explicit) + } catch (Exception error) { + failure = error + } + if (item.error) { + assert failure != null : "${item.name}: unexpectedly succeeded" + assert failure.message.contains(item.error) : "${item.name}: ${failure.message}" + } else { + assert failure == null : "${item.name}: ${failure}" + assert resolved == new File(item.expected).canonicalFile : "${item.name}: selected ${resolved}" + } + println "PASS: ${item.name}" + results.add([name: item.name, passed: true, root: resolved?.path]) + } + file('results.json').text = JsonOutput.prettyPrint(JsonOutput.toJson(results)) + } +} +'''.replace('RESOLVER_URI', json.dumps((plugin / 'android/native_sources.gradle').as_uri())), encoding='utf-8') + command = [str(gradle), '--no-daemon', '--offline', '--console=plain', '-p', str(project), 'verifySources'] + result = subprocess.run(command, capture_output=True, text=True, errors='replace', timeout=180) + output = result.stdout + result.stderr + (report / 'gradle-resolver.txt').write_text(output, encoding='utf-8') + print(output[-10000:], flush=True) + if result.returncode: + raise RuntimeError('Production Gradle resolver regression checks failed.') + results = json.loads((project / 'results.json').read_text(encoding='utf-8')) + (report / 'gradle-resolver.json').write_text(json.dumps(results, indent=2), encoding='utf-8') + resolved = Path(results[0]['root']) + (report / 'selected-native-root.txt').write_text(resolved.as_posix(), encoding='utf-8') + return resolved + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--gradle', required=True, type=Path) + parser.add_argument('--plugin', type=Path, default=Path(__file__).resolve().parents[1] / 'zstandard_android') + parser.add_argument('--native', type=Path, default=Path(__file__).resolve().parents[1] / 'zstandard_native') + parser.add_argument('--report-directory', required=True, type=Path) + arguments = parser.parse_args() + arguments.report_directory.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='zstandard resolver é ') as temporary: + verify(arguments.gradle, arguments.plugin.resolve(), arguments.native.resolve(), + Path(temporary).resolve(), arguments.report_directory) diff --git a/scripts/check_android_windows_sources.py b/scripts/check_android_windows_sources.py index 5438226..866f4fd 100644 --- a/scripts/check_android_windows_sources.py +++ b/scripts/check_android_windows_sources.py @@ -10,10 +10,15 @@ import shutil import struct import subprocess +import sys import tarfile import tempfile import urllib.request +# The reviewed embedded Windows runtime deliberately omits the script directory. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from check_android_native_sources import verify + def run(command, report, *, expected_success=True, timeout=300): result = subprocess.run([str(part) for part in command], capture_output=True, @@ -113,10 +118,10 @@ def get(url): return {'package': package, 'version': version, 'archive_sha256': digest} -def reproduce(version, report): +def reproduce(version, report, gradle=None): ndk, cmake, ninja = sdk_tools(report) with tempfile.TemporaryDirectory(prefix='zstandard android windows ') as temporary: - stage = Path(temporary) + stage = Path(temporary).resolve() cache = stage / 'Pub cache/hosted/pub.dev' plugin = cache / f'zstandard_android-{version}' native = cache / f'zstandard_native-{version}' @@ -153,12 +158,64 @@ def reproduce(version, report): 'control_library_sha256': hashlib.sha256(data).hexdigest()} (report / 'reproduction.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8') print('Reproduced the published package failure; explicit-root control built a real Android ARM64 library.', flush=True) + if gradle: + repository = Path(__file__).resolve().parents[1] + candidate = stage / 'candidate Pub cache/hosted/pub.dev' + candidate_plugin = candidate / 'zstandard_android-candidate' + candidate_native = candidate / 'zstandard_native-candidate é' + ignored = shutil.ignore_patterns('build', '.dart_tool', '.gradle', '.cxx', '.git', + 'example', 'example_legacy', 'pubspec_overrides.yaml') + shutil.copytree(repository / 'zstandard_android', candidate_plugin, ignore=ignored) + shutil.copytree(repository / 'zstandard_native', candidate_native, ignore=ignored) + selected = verify(gradle, candidate_plugin, candidate_native, stage / 'candidate cases', report) + # Use the production resolver's output, never an inferred cached version. + assert Path((report / 'selected-native-root.txt').read_text(encoding='utf-8')) == selected + build = stage / 'candidate build' + candidate_common = [cmake, '-G', 'Ninja', '-S', candidate_plugin / 'zstd_build', + f'-DCMAKE_MAKE_PROGRAM={ninja}', + f'-DCMAKE_TOOLCHAIN_FILE={ndk / "build/cmake/android.toolchain.cmake"}', + f'-DZSTD_ROOT={selected.as_posix()}', + '-DANDROID_ABI=arm64-v8a', '-DANDROID_PLATFORM=android-21', + '-DCMAKE_BUILD_TYPE=RelWithDebInfo'] + run(candidate_common + ['-B', build], report / 'candidate-configure.txt') + run([cmake, '--build', build, '--parallel', '2'], report / 'candidate-build.txt') + data = (build / 'libzstandard_android.so').read_bytes() + assert data[:4] == b'\x7fELF' and struct.unpack_from(' +#include +#include +#include "zstd.h" + +static int round_trip(size_t length, int level) { + unsigned char *input = malloc(length ? length : 1); + unsigned char *output = malloc(length ? length : 1); + size_t capacity = ZSTD_compressBound(length); + void *frame = malloc(capacity); + if (!input || !output || !frame) return 1; + for (size_t i = 0; i < length; ++i) input[i] = (unsigned char)(i % 251); + size_t compressed = ZSTD_compress(frame, capacity, input, length, level); + int failed = ZSTD_isError(compressed) || compressed == 0; + if (!failed) { + size_t restored = ZSTD_decompress(output, length, frame, compressed); + failed = ZSTD_isError(restored) || restored != length || memcmp(input, output, length); + } + free(frame); + free(output); + free(input); + if (failed) fprintf(stderr, "Round trip failed: length=%zu level=%d\n", length, level); + return failed; +} + +int main(void) { + if (ZSTD_versionNumber() != ZSTD_VERSION_NUMBER) return 1; + if (round_trip(0, 3) || round_trip(100000, 3)) return 1; + const int levels[] = {1, 3, 10, 22}; + for (unsigned i = 0; i < sizeof(levels) / sizeof(levels[0]); ++i) { + if (round_trip(128, levels[i])) return 1; + } + char output[128]; + const unsigned char invalid[] = {1, 2, 3, 4, 5}; + if (!ZSTD_isError(ZSTD_decompress(output, sizeof(output), invalid, sizeof(invalid)))) return 1; + puts("PASS: Android shared library version, empty/large round trips, levels 1/3/10/22 and corrupt input"); + return 0; +} diff --git a/scripts/run_android_windows_emulator.py b/scripts/run_android_windows_emulator.py new file mode 100644 index 0000000..e316b43 --- /dev/null +++ b/scripts/run_android_windows_emulator.py @@ -0,0 +1,180 @@ +"""Run the candidate Android ELF smoke test in an isolated Windows AVD.""" + +import argparse +from contextlib import contextmanager +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import time + + +@contextmanager +def emulator_lease(): + import msvcrt + path = Path(tempfile.gettempdir()) / 'zstandard-windows-android-emulator.lock' + with path.open('a+b') as lease: + if path.stat().st_size == 0: + lease.write(b'0') + lease.flush() + deadline = time.monotonic() + 600 + while True: + lease.seek(0) + try: + msvcrt.locking(lease.fileno(), msvcrt.LK_NBLCK, 1) + break + except OSError: + if time.monotonic() > deadline: + raise TimeoutError('Timed out waiting for this host\'s Android test lease.') + print('Waiting for the Windows Android emulator lease.', flush=True) + time.sleep(15) + try: + yield + finally: + lease.seek(0) + msvcrt.locking(lease.fileno(), msvcrt.LK_UNLCK, 1) + + +def available_port_pair(): + for port in range(5600, 5680, 2): + try: + with socket.socket() as console, socket.socket() as adb: + console.bind(('127.0.0.1', port)) + adb.bind(('127.0.0.1', port + 1)) + return port + except OSError: + continue + raise RuntimeError('No unused emulator port pair is available.') + + +def execute(sdk, report): + if os.name != 'nt': + raise RuntimeError('This launcher is for the Windows self-hosted runner.') + with emulator_lease(), tempfile.TemporaryDirectory(prefix='zstandard-avd-', dir=os.environ['RUNNER_TEMP']) as temporary: + state = Path(temporary) + avd_home = state / 'avd' + avd_home.mkdir() + name = 'zstandard-' + state.name + environment = {**os.environ, 'ANDROID_HOME': str(sdk), 'ANDROID_SDK_ROOT': str(sdk), + 'ANDROID_AVD_HOME': str(avd_home), 'ANDROID_USER_HOME': str(state / 'user')} + Path(environment['ANDROID_USER_HOME']).mkdir() + with socket.socket() as reservation: + reservation.bind(('127.0.0.1', 0)) + adb_port = reservation.getsockname()[1] + environment['ANDROID_ADB_SERVER_PORT'] = str(adb_port) + environment['ADB_SERVER_SOCKET'] = f'tcp:127.0.0.1:{adb_port}' + port = available_port_pair() + device = f'emulator-{port}' + receipt = {'software_emulation': True, 'avd': name, 'device': device, + 'private_adb_port': adb_port, 'stage': 'create-avd', 'passed': False} + (report / 'headless-execution.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8') + + def command(arguments, filename, *, timeout=30, check=True, stdin=None): + result = subprocess.run([str(arg) for arg in arguments], env=environment, + input=stdin, capture_output=True, text=True, + encoding='utf-8', errors='replace', timeout=timeout) + output = result.stdout + result.stderr + (report / filename).write_text(output, encoding='utf-8') + if check and result.returncode: + raise RuntimeError(f'{filename}: exit {result.returncode}: {output[-1800:]}') + return result.returncode, output.strip() + + tools = sdk / 'cmdline-tools/latest' + java = Path(os.environ['JAVA_HOME']) / 'bin/java.exe' + command([java, '-Dfile.encoding=UTF-8', f'-Dcom.android.sdklib.toolsdir={tools}', + '-classpath', tools / 'lib/avdmanager-classpath.jar', + 'com.android.sdklib.tool.AvdManagerCli', 'create', 'avd', '--name', name, + '--package', 'system-images;android-30;aosp_atd;x86_64', '--device', 'pixel_2'], + 'avd-create.txt', timeout=120, stdin='no\n') + configuration = avd_home / f'{name}.avd/config.ini' + if not configuration.is_file(): + raise RuntimeError('AVD manager did not create the private AVD configuration.') + with configuration.open('a', encoding='utf-8') as stream: + stream.write('\nhw.cpu.ncore=2\nhw.gps=no\n') + adb = sdk / 'platform-tools/adb.exe' + emulator = sdk / 'emulator/emulator.exe' + process = None + owned_device = False + try: + command([adb, '-P', adb_port, 'start-server'], 'adb-start.txt') + receipt['stage'] = 'boot' + with (report / 'emulator.txt').open('w', encoding='utf-8') as log: + process = subprocess.Popen([str(emulator), '-avd', name, '-port', str(port), + '-no-window', '-gpu', 'swiftshader_indirect', + '-feature', '-Vulkan', '-feature', '-GnssGrpcV1', + '-no-snapshot', '-no-snapshot-save', '-noaudio', + '-no-boot-anim', '-camera-back', 'none', '-accel', 'off'], + env=environment, stdout=log, stderr=subprocess.STDOUT) + started = time.monotonic() + while time.monotonic() - started < 900: + if process.poll() is not None: + raise RuntimeError('The software Android emulator exited before boot completed.') + _, boot = command([adb, '-P', adb_port, '-s', device, 'shell', 'getprop', 'sys.boot_completed'], + 'boot-state.txt', check=False) + if boot == '1': + _, registered = command([adb, '-P', adb_port, '-s', device, 'emu', 'avd', 'name'], 'avd-name.txt') + if registered.splitlines()[0] != name: + raise RuntimeError('Emulator port is owned by a different AVD; refusing to use it.') + owned_device = True + break + print(f'Waiting for software Android boot: {time.monotonic() - started:.0f}s.', flush=True) + time.sleep(10) + if not owned_device: + raise TimeoutError('The software Android emulator did not boot within 15 minutes.') + receipt['boot_seconds'] = time.monotonic() - started + receipt['stage'] = 'native-execution' + remote = '/data/local/tmp/' + name + command([adb, '-P', adb_port, '-s', device, 'shell', 'mkdir', remote], 'device-mkdir.txt') + try: + for filename in ['zstandard_android_smoke', 'libzstandard_android.so']: + command([adb, '-P', adb_port, '-s', device, 'push', report / 'runtime' / filename, + remote + '/' + filename], 'device-push-' + filename + '.txt') + command([adb, '-P', adb_port, '-s', device, 'shell', 'chmod', '700', remote + '/zstandard_android_smoke'], + 'device-chmod.txt') + _, output = command([adb, '-P', adb_port, '-s', device, 'shell', + f'LD_LIBRARY_PATH={remote} {remote}/zstandard_android_smoke'], + 'native-runtime.txt', timeout=300) + if not output.startswith('PASS: Android shared library'): + raise RuntimeError('Native Android smoke test did not report success.') + print(output, flush=True) + receipt.update({'stage': 'complete', 'passed': True}) + finally: + command([adb, '-P', adb_port, '-s', device, 'shell', 'rm', '-rf', remote], + 'device-cleanup.txt', check=False) + except BaseException as error: + receipt['error'] = str(error) + if (report / 'emulator.txt').is_file(): + print((report / 'emulator.txt').read_text(encoding='utf-8', errors='replace')[-12000:], flush=True) + raise + finally: + cleanup_errors = [] + + def cleanup(arguments, filename): + try: + command(arguments, filename, check=False) + except Exception as error: + cleanup_errors.append(str(error)) + + if owned_device: + cleanup([adb, '-P', adb_port, '-s', device, 'emu', 'kill'], 'emulator-stop.txt') + if process is not None: + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + cleanup(['taskkill', '/PID', process.pid, '/T', '/F'], 'owned-process-stop.txt') + # Only this job's private daemon, never the host's default ADB server. + cleanup([adb, '-P', adb_port, 'kill-server'], 'adb-stop.txt') + receipt['cleanup_errors'] = cleanup_errors + (report / 'headless-execution.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8') + if cleanup_errors and receipt['passed']: + raise RuntimeError('Windows Android cleanup failed: ' + '; '.join(cleanup_errors)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sdk-root', type=Path, required=True) + parser.add_argument('--report-directory', type=Path, required=True) + arguments = parser.parse_args() + execute(arguments.sdk_root.resolve(), arguments.report_directory.resolve()) diff --git a/zstandard_android/android/build.gradle b/zstandard_android/android/build.gradle index 240b1ab..d970b69 100644 --- a/zstandard_android/android/build.gradle +++ b/zstandard_android/android/build.gradle @@ -12,6 +12,11 @@ rootProject.allprojects { apply plugin: "com.android.library" +apply from: new File(project.projectDir, 'native_sources.gradle') +def explicitNativeRoot = project.findProperty('ZSTD_ROOT') ?: System.getenv('ZSTD_ROOT') +def nativeRoot = resolveZstandardNativeRoot(rootProject.projectDir, project.projectDir, explicitNativeRoot?.toString()) +def cmakeNativeRoot = nativeRoot.path.replace(File.separatorChar, '/' as char) + android { if (project.android.hasProperty("namespace")) { namespace = "dev.vyp.zstandard_android" @@ -56,6 +61,7 @@ android { cmake { cppFlags "-fmax-page-size=16384" arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + arguments "-DZSTD_ROOT=${cmakeNativeRoot}" } } } diff --git a/zstandard_android/android/native_sources.gradle b/zstandard_android/android/native_sources.gradle new file mode 100644 index 0000000..89d8701 --- /dev/null +++ b/zstandard_android/android/native_sources.gradle @@ -0,0 +1,57 @@ +import groovy.json.JsonSlurper +import java.nio.file.Paths + +// Resolve from the consumer, not from this plugin's possibly hosted location. +ext.resolveZstandardNativeRoot = { File consumerDirectory, File pluginDirectory, String explicitRoot = null -> + def validate = { File candidate, String origin -> + File root = candidate.canonicalFile + if (!new File(root, 'zstd.h').isFile() || + !['common', 'compress', 'decompress'].every { new File(root, it).isDirectory() }) { + throw new GradleException("zstd source not found at ${root} (${origin}). Run flutter pub get in the consuming application or workspace.") + } + return root + } + + if (explicitRoot) { + File supplied = new File(explicitRoot) + if (!supplied.isAbsolute()) { + supplied = new File(consumerDirectory, explicitRoot) + } + return validate(supplied, 'explicit ZSTD_ROOT') + } + + for (File directory = consumerDirectory.canonicalFile; directory != null; directory = directory.parentFile) { + File configuration = new File(directory, '.dart_tool/package_config.json') + if (!configuration.isFile()) { + continue + } + def data + try { + data = new JsonSlurper().parse(configuration, 'UTF-8') + } catch (Exception error) { + throw new GradleException("Cannot read ${configuration}. Run flutter pub get in the consuming application or workspace.", error) + } + if (!(data instanceof Map) || !(data.packages instanceof List)) { + throw new GradleException("Invalid package configuration: ${configuration}.") + } + def selected = data.packages.findAll { it instanceof Map && it.name == 'zstandard_native' } + if (selected.size() != 1 || !(selected[0].rootUri instanceof String) || !selected[0].rootUri) { + throw new GradleException("Expected exactly one zstandard_native dependency in ${configuration}. Run flutter pub get.") + } + try { + URI uri = configuration.toURI().resolve(new URI(selected[0].rootUri)) + if (uri.scheme != 'file') { + throw new IllegalArgumentException('Expected a local file URI.') + } + File nativePackage = Paths.get(uri).toFile() + return validate(new File(nativePackage, 'src/zstd'), "selected by ${configuration}") + } catch (GradleException error) { + throw error + } catch (Exception error) { + throw new GradleException("Cannot resolve the zstandard_native file URI in ${configuration}.", error) + } + } + + // Direct repository builds can run before a consumer's pub configuration exists. + return validate(new File(pluginDirectory, '../../zstandard_native/src/zstd'), 'repository fallback') +} diff --git a/zstandard_android/zstd_build/CMakeLists.txt b/zstandard_android/zstd_build/CMakeLists.txt index 37505c1..b8d44db 100644 --- a/zstandard_android/zstd_build/CMakeLists.txt +++ b/zstandard_android/zstd_build/CMakeLists.txt @@ -5,47 +5,23 @@ cmake_minimum_required(VERSION 3.10) project(zstandard_android_library VERSION 0.0.1 LANGUAGES C) -# Resolve the parent containing the plugin (repo root or pub-cache hosted dir) -# for consistent paths. -get_filename_component(_PLUGIN_PARENT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) -# 1. Prefer repo local zstandard_native/src/zstd. -set(_LOCAL_ZSTD "${_PLUGIN_PARENT}/zstandard_native/src/zstd") -if(EXISTS "${_LOCAL_ZSTD}/common") - set(ZSTD_ROOT "${_LOCAL_ZSTD}") - message(STATUS "Using local zstandard_native: ${ZSTD_ROOT}") -else() - # 2. Resolve the exact dependency selected by pub. Never choose the first - # matching cache directory, which can silently compile a stale version. - set(_SEARCH_PKG "${CMAKE_CURRENT_SOURCE_DIR}") - foreach(_PKG_IGNORE RANGE 10) - get_filename_component(_PARENT "${_SEARCH_PKG}/.." ABSOLUTE) - if(NOT _PARENT OR _PARENT STREQUAL _SEARCH_PKG) - break() - endif() - set(_PKG_CONFIG "${_SEARCH_PKG}/../.dart_tool/package_config.json") - if(EXISTS "${_PKG_CONFIG}") - file(READ "${_PKG_CONFIG}" PACKAGE_CONFIG_CONTENT) - string(REGEX MATCH "\"name\": \"zstandard_native\"[^}]*\"rootUri\": \"([^\"]+)\"" _ "${PACKAGE_CONFIG_CONTENT}") - set(NATIVE_ROOT_URI "${CMAKE_MATCH_1}") - if(NATIVE_ROOT_URI) - string(REGEX REPLACE "^file://" "" NATIVE_ROOT "${NATIVE_ROOT_URI}") - if(WIN32) - string(REGEX REPLACE "^/" "" NATIVE_ROOT "${NATIVE_ROOT}") - endif() - string(REPLACE "%20" " " NATIVE_ROOT "${NATIVE_ROOT}") - set(ZSTD_CANDIDATE "${NATIVE_ROOT}/src/zstd") - if(EXISTS "${ZSTD_CANDIDATE}/common") - set(ZSTD_ROOT "${ZSTD_CANDIDATE}") - message(STATUS "Using zstandard_native from package_config: ${ZSTD_ROOT}") - break() - endif() - endif() - endif() - set(_SEARCH_PKG "${_PARENT}") - endforeach() - if(NOT ZSTD_ROOT OR NOT EXISTS "${ZSTD_ROOT}/common") - message(FATAL_ERROR "zstd source not found. Set ZSTD_ROOT or run flutter pub get so package_config.json resolves zstandard_native.") +# Gradle supplies the exact dependency selected by the consuming application. +# Direct CMake callers can provide the same override explicitly. +if(DEFINED ZSTD_ROOT AND NOT "${ZSTD_ROOT}" STREQUAL "") + if(CMAKE_HOST_WIN32) + file(TO_CMAKE_PATH "${ZSTD_ROOT}" ZSTD_ROOT) endif() + get_filename_component(ZSTD_ROOT "${ZSTD_ROOT}" ABSOLUTE) + message(STATUS "Using explicit zstandard_native source: ${ZSTD_ROOT}") +else() + get_filename_component(_PLUGIN_PARENT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) + set(ZSTD_ROOT "${_PLUGIN_PARENT}/zstandard_native/src/zstd") + message(STATUS "Using repository zstandard_native source: ${ZSTD_ROOT}") +endif() + +if(NOT EXISTS "${ZSTD_ROOT}/zstd.h" OR NOT IS_DIRECTORY "${ZSTD_ROOT}/common" + OR NOT IS_DIRECTORY "${ZSTD_ROOT}/compress" OR NOT IS_DIRECTORY "${ZSTD_ROOT}/decompress") + message(FATAL_ERROR "zstd source not found at ${ZSTD_ROOT}. Set ZSTD_ROOT or run flutter pub get in the consuming application or workspace.") endif() add_definitions(-DZSTD_ENABLE_ASM_X86_64_BMI2=0) From 0263be3ce4272fbff74ee73203445cf52a5d99e5 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 12:13:07 +0100 Subject: [PATCH 09/12] test(android): finish Windows emulator setup and document source selection --- docs/development/building.md | 3 ++- scripts/check_android_native_sources.py | 9 ++++++++- scripts/download_android_windows_tools.py | 2 +- scripts/run_android_windows_emulator.py | 2 +- zstandard_android/README.md | 14 ++++++++++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/development/building.md b/docs/development/building.md index 3f4aca0..4a17038 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -58,10 +58,11 @@ If you are developing or modifying a platform package’s native code: ### Android -- The plugin builds the native library via `zstandard_android/zstd_build/CMakeLists.txt`, which compiles sources from `zstandard_native/src/zstd/` (resolved from the repo or pub cache). +- The plugin builds the native library via `zstandard_android/zstd_build/CMakeLists.txt`. Gradle reads the consuming app or workspace's `.dart_tool/package_config.json`, resolves the exact `zstandard_native` file URI and passes its `src/zstd` path to CMake. The plugin and native package can both live in a versioned Pub cache outside the consuming app, including Windows paths with spaces. - The Android plugin uses Built-in Kotlin with AGP 9 and newer. The Android examples exercise this path with Flutter 3.47+. - Building the Android app (e.g. `flutter build apk` or running from Android Studio) triggers the native build via Gradle/CMake. - Ensure the NDK is installed and that `zstandard_native` is available (e.g. `flutter pub get` so the canonical `zstandard_native/src/zstd/` is resolved). +- For an explicit source override, set the Gradle property `ZSTD_ROOT` (or its environment variable) to the directory containing `zstd.h`; direct CMake callers use `-DZSTD_ROOT=...`. Overrides take precedence. With no consumer package configuration, a direct repository build can use the sibling native package. A missing or invalid configured dependency fails rather than selecting a different cached package. ### iOS / macOS diff --git a/scripts/check_android_native_sources.py b/scripts/check_android_native_sources.py index b27ab52..a0171da 100644 --- a/scripts/check_android_native_sources.py +++ b/scripts/check_android_native_sources.py @@ -4,6 +4,7 @@ import json import os from pathlib import Path +import shutil import subprocess import tempfile from urllib.parse import quote @@ -11,6 +12,12 @@ def verify(gradle, plugin, native, stage, report): cases = [] + cache = stage / 'Pub cache/hosted/pub.dev' + selected_package = cache / 'zstandard_native-9.8.7 é' + # Both versions coexist in the same fixture cache. The source bytes come + # from the candidate; these fixture version names are never published. + shutil.copytree(native / 'src', selected_package / 'src') + native = selected_package def sources(directory): root = directory / 'src/zstd' @@ -35,7 +42,7 @@ def case(name, consumer, expected=None, error=None, explicit=None, plugin_direct 'error': error, 'explicit': str(explicit) if explicit is not None else None}) selected = native / 'src/zstd' - decoy = sources(stage / 'Pub cache/hosted/pub.dev/zstandard_native-other-version') + decoy = sources(cache / 'zstandard_native-9.8.8') repository = stage / 'repository' sibling = sources(repository / 'zstandard_native') repository_plugin = repository / 'zstandard_android/android' diff --git a/scripts/download_android_windows_tools.py b/scripts/download_android_windows_tools.py index 29a609a..4016498 100644 --- a/scripts/download_android_windows_tools.py +++ b/scripts/download_android_windows_tools.py @@ -88,7 +88,7 @@ def install_packages(sdk, report, headless=False): # This is the Java invocation in the reviewed vendor sdkmanager.bat. Calling # it directly gives Java explicit UTF-8 stdin without a PowerShell/batch pipe. command = [str(java), '-Dfile.encoding=UTF-8', - f'-Dcom.android.sdklib.toolsdir={tools}', '-classpath', str(classpath), + f'-Dcom.android.sdkmanager.toolsdir={tools}', '-classpath', str(classpath), 'com.android.sdklib.tool.sdkmanager.SdkManagerCli', f'--sdk_root={sdk}'] requested = ['ndk;27.0.12077973', 'cmake;3.22.1', 'platform-tools', 'emulator'] if headless: diff --git a/scripts/run_android_windows_emulator.py b/scripts/run_android_windows_emulator.py index e316b43..6794d8f 100644 --- a/scripts/run_android_windows_emulator.py +++ b/scripts/run_android_windows_emulator.py @@ -83,7 +83,7 @@ def command(arguments, filename, *, timeout=30, check=True, stdin=None): tools = sdk / 'cmdline-tools/latest' java = Path(os.environ['JAVA_HOME']) / 'bin/java.exe' - command([java, '-Dfile.encoding=UTF-8', f'-Dcom.android.sdklib.toolsdir={tools}', + command([java, '-Dfile.encoding=UTF-8', f'-Dcom.android.sdkmanager.toolsdir={tools}', '-classpath', tools / 'lib/avdmanager-classpath.jar', 'com.android.sdklib.tool.AvdManagerCli', 'create', 'avd', '--name', name, '--package', 'system-images;android-30;aosp_atd;x86_64', '--device', 'pixel_2'], diff --git a/zstandard_android/README.md b/zstandard_android/README.md index 47dab52..0bf6f2b 100644 --- a/zstandard_android/README.md +++ b/zstandard_android/README.md @@ -52,6 +52,20 @@ send Dart-owned bytes to a worker isolate; native pointers never cross an isolate boundary. Decompression uses zstd's streaming API and enforces the configured output limit while producing bytes. +Gradle locates `zstandard_native` through the consuming app's +`.dart_tool/package_config.json`, including an enclosing Dart workspace. It +resolves the selected dependency's `rootUri` with the host's file-URI parser +and passes its `src/zstd` directory explicitly to CMake. This supports a +versioned Pub cache outside the app, Windows drive paths, spaces and Unicode, +without choosing another cached version. Run `flutter pub get` before building. + +An explicit Gradle property `-PZSTD_ROOT=...` or `ZSTD_ROOT` environment variable +can select a source directory containing `zstd.h`, `common`, `compress` and +`decompress`. Relative overrides use the consuming Android project directory. +Direct CMake builds use `-DZSTD_ROOT=...`. Repository-local fallback is available +only when no consumer package configuration or explicit override is present; +an invalid selected dependency fails with a diagnostic. + The plugin does not pin an Android Gradle Plugin version. The consuming app owns that choice. The repository contains two Android consumers for CI: From cf4604d621259c73e2cb0202a49eaea30481fe4b Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 12:37:26 +0100 Subject: [PATCH 10/12] test(android): expose Windows guest boot diagnostics and use discoverable ports --- scripts/run_android_windows_emulator.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/run_android_windows_emulator.py b/scripts/run_android_windows_emulator.py index 6794d8f..916b132 100644 --- a/scripts/run_android_windows_emulator.py +++ b/scripts/run_android_windows_emulator.py @@ -38,7 +38,8 @@ def emulator_lease(): def available_port_pair(): - for port in range(5600, 5680, 2): + # Stay inside ADB's documented default discovery range (5555..5585). + for port in range(5554, 5586, 2): try: with socket.socket() as console, socket.socket() as adb: console.bind(('127.0.0.1', port)) @@ -103,6 +104,7 @@ def command(arguments, filename, *, timeout=30, check=True, stdin=None): with (report / 'emulator.txt').open('w', encoding='utf-8') as log: process = subprocess.Popen([str(emulator), '-avd', name, '-port', str(port), '-no-window', '-gpu', 'swiftshader_indirect', + '-show-kernel', '-no-metrics', '-feature', '-Vulkan', '-feature', '-GnssGrpcV1', '-no-snapshot', '-no-snapshot-save', '-noaudio', '-no-boot-anim', '-camera-back', 'none', '-accel', 'off'], @@ -111,15 +113,16 @@ def command(arguments, filename, *, timeout=30, check=True, stdin=None): while time.monotonic() - started < 900: if process.poll() is not None: raise RuntimeError('The software Android emulator exited before boot completed.') - _, boot = command([adb, '-P', adb_port, '-s', device, 'shell', 'getprop', 'sys.boot_completed'], - 'boot-state.txt', check=False) + result, boot = command([adb, '-P', adb_port, '-s', device, 'shell', 'getprop', 'sys.boot_completed'], + 'boot-state.txt', check=False) if boot == '1': _, registered = command([adb, '-P', adb_port, '-s', device, 'emu', 'avd', 'name'], 'avd-name.txt') if registered.splitlines()[0] != name: raise RuntimeError('Emulator port is owned by a different AVD; refusing to use it.') owned_device = True break - print(f'Waiting for software Android boot: {time.monotonic() - started:.0f}s.', flush=True) + print(f'Waiting for software Android boot: {time.monotonic() - started:.0f}s; ' + f'ADB exit={result}, state={boot[:160]!r}.', flush=True) time.sleep(10) if not owned_device: raise TimeoutError('The software Android emulator did not boot within 15 minutes.') From 65932ba964b9a347e9514a611a72ef9b96844945 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 12:38:57 +0100 Subject: [PATCH 11/12] test(android): stream guest boot progress for bounded Windows diagnostics --- .github/workflows/android_windows_sources.yml | 1 + scripts/run_android_windows_emulator.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml index cc581ea..35e176f 100644 --- a/.github/workflows/android_windows_sources.yml +++ b/.github/workflows/android_windows_sources.yml @@ -40,6 +40,7 @@ jobs: timeout-minutes: 45 env: PYTHONDONTWRITEBYTECODE: '1' + PYTHONUTF8: '1' steps: - uses: actions/checkout@v6 with: diff --git a/scripts/run_android_windows_emulator.py b/scripts/run_android_windows_emulator.py index 916b132..87b6f5b 100644 --- a/scripts/run_android_windows_emulator.py +++ b/scripts/run_android_windows_emulator.py @@ -110,6 +110,7 @@ def command(arguments, filename, *, timeout=30, check=True, stdin=None): '-no-boot-anim', '-camera-back', 'none', '-accel', 'off'], env=environment, stdout=log, stderr=subprocess.STDOUT) started = time.monotonic() + last_diagnostic = '' while time.monotonic() - started < 900: if process.poll() is not None: raise RuntimeError('The software Android emulator exited before boot completed.') @@ -123,6 +124,12 @@ def command(arguments, filename, *, timeout=30, check=True, stdin=None): break print(f'Waiting for software Android boot: {time.monotonic() - started:.0f}s; ' f'ADB exit={result}, state={boot[:160]!r}.', flush=True) + with (report / 'emulator.txt').open('rb') as snapshot: + snapshot.seek(max(0, snapshot.seek(0, 2) - 800)) + diagnostic = snapshot.read().decode('utf-8', errors='replace').strip() + if diagnostic and diagnostic != last_diagnostic: + print('Guest boot diagnostic: ' + diagnostic, flush=True) + last_diagnostic = diagnostic time.sleep(10) if not owned_device: raise TimeoutError('The software Android emulator did not boot within 15 minutes.') From 6b1c45bd1d07ac3795d035745501bb2fd7e8659e Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Thu, 29 Oct 2026 12:53:38 +0100 Subject: [PATCH 12/12] test(android): keep validated Windows build coverage and document emulator prerequisites --- .github/workflows/android_windows_sources.yml | 18 +- docs/development/ci-cd.md | 6 + docs/development/self-hosted-ci.md | 28 +++ scripts/check_android_native_sources.py | 3 +- scripts/check_android_windows_sources.py | 25 +-- scripts/download_android_windows_tools.py | 12 +- scripts/fixtures/android_native_smoke.c | 38 ---- scripts/run_android_windows_emulator.py | 190 ------------------ 8 files changed, 45 insertions(+), 275 deletions(-) delete mode 100644 scripts/fixtures/android_native_smoke.c delete mode 100644 scripts/run_android_windows_emulator.py diff --git a/.github/workflows/android_windows_sources.yml b/.github/workflows/android_windows_sources.yml index 35e176f..483c011 100644 --- a/.github/workflows/android_windows_sources.yml +++ b/.github/workflows/android_windows_sources.yml @@ -8,8 +8,6 @@ on: - scripts/check_android_windows_sources.py - scripts/check_android_native_sources.py - scripts/download_android_windows_tools.py - - scripts/run_android_windows_emulator.py - - scripts/fixtures/android_native_smoke.c - .github/workflows/android_windows_sources.yml push: branches: [develop] @@ -19,8 +17,6 @@ on: - scripts/check_android_windows_sources.py - scripts/check_android_native_sources.py - scripts/download_android_windows_tools.py - - scripts/run_android_windows_emulator.py - - scripts/fixtures/android_native_smoke.c - .github/workflows/android_windows_sources.yml concurrency: @@ -37,7 +33,7 @@ jobs: runs-on: group: vypdev labels: [self-hosted, Windows, X64] - timeout-minutes: 45 + timeout-minutes: 30 env: PYTHONDONTWRITEBYTECODE: '1' PYTHONUTF8: '1' @@ -97,12 +93,11 @@ jobs: $sdk = Join-Path $env:RUNNER_TEMP ("zstandard-android-sdk-{0}-{1}" -f $env:GITHUB_RUN_ID, $env:GITHUB_RUN_ATTEMPT) python scripts/download_android_windows_tools.py --sdk-root "$sdk" --report-directory "$env:REPRODUCTION_REPORT" if ($LASTEXITCODE -ne 0) { throw 'The reviewed command-line tools could not be prepared.' } - $tools = Join-Path $sdk 'cmdline-tools/latest' $env:ANDROID_HOME = $sdk $env:ANDROID_SDK_ROOT = $sdk $env:ANDROID_USER_HOME = Join-Path $sdk 'user-state' New-Item -ItemType Directory -Path $env:ANDROID_USER_HOME | Out-Null - python scripts/download_android_windows_tools.py --sdk-root "$sdk" --report-directory "$env:REPRODUCTION_REPORT" --install-packages --headless + python scripts/download_android_windows_tools.py --sdk-root "$sdk" --report-directory "$env:REPRODUCTION_REPORT" --install-packages if ($LASTEXITCODE -ne 0) { throw 'The private Windows Android SDK could not be provisioned.' } Write-Host ("[{0:o}] Private Android SDK provisioning finished" -f (Get-Date)) foreach ($name in @('ANDROID_HOME', 'ANDROID_SDK_ROOT', 'ANDROID_USER_HOME')) { @@ -117,14 +112,7 @@ jobs: $ErrorActionPreference = 'Stop' python scripts/check_android_windows_sources.py --baseline-version 1.5.3 --gradle (Get-Command gradle.bat).Source --report-directory "$env:REPRODUCTION_REPORT" if ($LASTEXITCODE -ne 0) { throw 'Android source resolver regression failed.' } - - name: Run the candidate native library on a software Android emulator - shell: powershell - env: - REPRODUCTION_REPORT: ${{ runner.temp }}/zstandard-android-windows-${{ github.run_id }}-${{ github.run_attempt }} - run: | - python scripts/run_android_windows_emulator.py --sdk-root "$env:ANDROID_HOME" --report-directory "$env:REPRODUCTION_REPORT" - if ($LASTEXITCODE -ne 0) { throw 'Headless Android native execution failed.' } - - name: Retain Windows reproduction and emulator capability evidence + - name: Retain Windows source resolution and toolchain evidence if: always() uses: actions/upload-artifact@v5 with: diff --git a/docs/development/ci-cd.md b/docs/development/ci-cd.md index be1b76d..1e33511 100644 --- a/docs/development/ci-cd.md +++ b/docs/development/ci-cd.md @@ -26,6 +26,12 @@ They provide Android emulator/instrumentation, Linux desktop, ChromeDriver, Windows desktop/native, and both SwiftPM and CocoaPods Apple integration. They also run publish dry-runs and coverage where applicable. +`android_windows_sources.yml` separately verifies Android source discovery on +a Windows host with both packages staged in a versioned cache outside the +consumer and builds ARM64 native code. Android execution coverage remains in +the Linux emulator matrix. See the [Windows Android runner +contract](self-hosted-ci.md#android-builds-hosted-on-windows). + Actions in workflows and composite actions must use version tags such as `@v1`, `@v2` or `@v3`. Commit SHA references are not permitted. The package preparation contract checks this policy when workflow or action files change. diff --git a/docs/development/self-hosted-ci.md b/docs/development/self-hosted-ci.md index fe12ecd..aa86b0d 100644 --- a/docs/development/self-hosted-ci.md +++ b/docs/development/self-hosted-ci.md @@ -94,6 +94,34 @@ Windows setup resolves CMake and CTest from the job's current `PATH`, a standalo The workflow builds `zstandard_windows/example` in Debug mode, runs the generated native test target (including a zstd compression/decompression round trip), runs the Windows package tests with the built DLL on `PATH`, and launches the example for Flutter integration tests. The same sequence is repeated with the current `zstandard_native` package staged outside the checkout and selected through `package_config.json`. A separate federated-example job builds `zstandard/example` and runs its integration suite. +### Android builds hosted on Windows + +`android_windows_sources.yml` adds one targeted job for relevant pull requests +and pushes to `develop`. It reproduces the original published Android source +lookup failure, then stages both candidate packages outside the checkout and +outside a separate consuming app. Production Gradle resolver checks cover two +cached native versions, absolute and relative file URIs, Windows drive paths, +spaces, Unicode, workspace ancestors, overrides and invalid dependencies. +NDK 27.0.12077973 with CMake 3.22.1 builds the corrected ARM64/API 21 library. + +Linux retains the complete Flutter integration and JNI instrumentation matrix. +The Windows job covers host-specific source discovery and compilation. It does +not claim Android runtime coverage. A Windows emulator requires separate +infrastructure validation: the current runner reported unavailable hardware +virtualization, and API 30 ATD software-emulator trials did not reach a stable +boot. Enabling Windows runtime checks requires a reproducible successful boot +and test execution first. + +Portable Python and Android command-line tools use reviewed archive checksums. +The job requires HTTPS access to Python, Google SDK repositories, Gradle +distributions and pub.dev archives. +Java 17 and Gradle 8.14.2 are selected per job. The Android SDK, Gradle state and +test fixtures live in private job directories, including on cancellation. Only +the NDK and CMake packages are provisioned; no system image is downloaded for +the compilation check. The job does not change machine-wide SDK, virtualization +or PATH settings. Missing tools, failed resolution and failed compilation are +explicit errors; diagnostic artifacts are retained for 14 days. + ## Web job requirements Flutter 3.47.2 does not support running `package:integration_test` directly with `flutter test -d chrome`. The workflow therefore uses the supported WebDriver path: diff --git a/scripts/check_android_native_sources.py b/scripts/check_android_native_sources.py index a0171da..84760f9 100644 --- a/scripts/check_android_native_sources.py +++ b/scripts/check_android_native_sources.py @@ -150,6 +150,7 @@ def results = [] parser.add_argument('--report-directory', required=True, type=Path) arguments = parser.parse_args() arguments.report_directory.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix='zstandard resolver é ') as temporary: + with tempfile.TemporaryDirectory(prefix='zstandard resolver é ', + dir=os.environ.get('RUNNER_TEMP')) as temporary: verify(arguments.gradle, arguments.plugin.resolve(), arguments.native.resolve(), Path(temporary).resolve(), arguments.report_directory) diff --git a/scripts/check_android_windows_sources.py b/scripts/check_android_windows_sources.py index 866f4fd..8e42b27 100644 --- a/scripts/check_android_windows_sources.py +++ b/scripts/check_android_windows_sources.py @@ -120,7 +120,8 @@ def get(url): def reproduce(version, report, gradle=None): ndk, cmake, ninja = sdk_tools(report) - with tempfile.TemporaryDirectory(prefix='zstandard android windows ') as temporary: + with tempfile.TemporaryDirectory(prefix='zstandard android windows ', + dir=os.environ.get('RUNNER_TEMP')) as temporary: stage = Path(temporary).resolve() cache = stage / 'Pub cache/hosted/pub.dev' plugin = cache / f'zstandard_android-{version}' @@ -187,28 +188,6 @@ def reproduce(version, report, gradle=None): 'android_api': 21, 'library_sha256': hashlib.sha256(data).hexdigest() }, indent=2), encoding='utf-8') print('Candidate resolver and real Android ARM64 library build passed.', flush=True) - smoke_project = stage / 'native runtime project' - smoke_project.mkdir() - (smoke_project / 'CMakeLists.txt').write_text( - 'cmake_minimum_required(VERSION 3.10)\nproject(android_smoke C)\n' - f'add_subdirectory("{candidate_plugin.as_posix()}/zstd_build" native)\n' - f'add_executable(zstandard_android_smoke "{repository.as_posix()}/scripts/fixtures/android_native_smoke.c")\n' - 'target_link_libraries(zstandard_android_smoke PRIVATE zstandard_android)\n', encoding='utf-8') - smoke_build = stage / 'native runtime build' - run([cmake, '-G', 'Ninja', '-S', smoke_project, '-B', smoke_build, - f'-DCMAKE_MAKE_PROGRAM={ninja}', - f'-DCMAKE_TOOLCHAIN_FILE={ndk / "build/cmake/android.toolchain.cmake"}', - f'-DZSTD_ROOT={selected.as_posix()}', '-DANDROID_ABI=x86_64', - '-DANDROID_PLATFORM=android-21', '-DCMAKE_BUILD_TYPE=RelWithDebInfo'], - report / 'runtime-configure.txt') - run([cmake, '--build', smoke_build, '--parallel', '2'], report / 'runtime-build.txt') - runtime = report / 'runtime' - runtime.mkdir(exist_ok=True) - for source in [smoke_build / 'zstandard_android_smoke', - smoke_build / 'native/libzstandard_android.so']: - data = source.read_bytes() - assert data[:4] == b'\x7fELF' and struct.unpack_from(' -#include -#include -#include "zstd.h" - -static int round_trip(size_t length, int level) { - unsigned char *input = malloc(length ? length : 1); - unsigned char *output = malloc(length ? length : 1); - size_t capacity = ZSTD_compressBound(length); - void *frame = malloc(capacity); - if (!input || !output || !frame) return 1; - for (size_t i = 0; i < length; ++i) input[i] = (unsigned char)(i % 251); - size_t compressed = ZSTD_compress(frame, capacity, input, length, level); - int failed = ZSTD_isError(compressed) || compressed == 0; - if (!failed) { - size_t restored = ZSTD_decompress(output, length, frame, compressed); - failed = ZSTD_isError(restored) || restored != length || memcmp(input, output, length); - } - free(frame); - free(output); - free(input); - if (failed) fprintf(stderr, "Round trip failed: length=%zu level=%d\n", length, level); - return failed; -} - -int main(void) { - if (ZSTD_versionNumber() != ZSTD_VERSION_NUMBER) return 1; - if (round_trip(0, 3) || round_trip(100000, 3)) return 1; - const int levels[] = {1, 3, 10, 22}; - for (unsigned i = 0; i < sizeof(levels) / sizeof(levels[0]); ++i) { - if (round_trip(128, levels[i])) return 1; - } - char output[128]; - const unsigned char invalid[] = {1, 2, 3, 4, 5}; - if (!ZSTD_isError(ZSTD_decompress(output, sizeof(output), invalid, sizeof(invalid)))) return 1; - puts("PASS: Android shared library version, empty/large round trips, levels 1/3/10/22 and corrupt input"); - return 0; -} diff --git a/scripts/run_android_windows_emulator.py b/scripts/run_android_windows_emulator.py deleted file mode 100644 index 87b6f5b..0000000 --- a/scripts/run_android_windows_emulator.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Run the candidate Android ELF smoke test in an isolated Windows AVD.""" - -import argparse -from contextlib import contextmanager -import json -import os -from pathlib import Path -import socket -import subprocess -import tempfile -import time - - -@contextmanager -def emulator_lease(): - import msvcrt - path = Path(tempfile.gettempdir()) / 'zstandard-windows-android-emulator.lock' - with path.open('a+b') as lease: - if path.stat().st_size == 0: - lease.write(b'0') - lease.flush() - deadline = time.monotonic() + 600 - while True: - lease.seek(0) - try: - msvcrt.locking(lease.fileno(), msvcrt.LK_NBLCK, 1) - break - except OSError: - if time.monotonic() > deadline: - raise TimeoutError('Timed out waiting for this host\'s Android test lease.') - print('Waiting for the Windows Android emulator lease.', flush=True) - time.sleep(15) - try: - yield - finally: - lease.seek(0) - msvcrt.locking(lease.fileno(), msvcrt.LK_UNLCK, 1) - - -def available_port_pair(): - # Stay inside ADB's documented default discovery range (5555..5585). - for port in range(5554, 5586, 2): - try: - with socket.socket() as console, socket.socket() as adb: - console.bind(('127.0.0.1', port)) - adb.bind(('127.0.0.1', port + 1)) - return port - except OSError: - continue - raise RuntimeError('No unused emulator port pair is available.') - - -def execute(sdk, report): - if os.name != 'nt': - raise RuntimeError('This launcher is for the Windows self-hosted runner.') - with emulator_lease(), tempfile.TemporaryDirectory(prefix='zstandard-avd-', dir=os.environ['RUNNER_TEMP']) as temporary: - state = Path(temporary) - avd_home = state / 'avd' - avd_home.mkdir() - name = 'zstandard-' + state.name - environment = {**os.environ, 'ANDROID_HOME': str(sdk), 'ANDROID_SDK_ROOT': str(sdk), - 'ANDROID_AVD_HOME': str(avd_home), 'ANDROID_USER_HOME': str(state / 'user')} - Path(environment['ANDROID_USER_HOME']).mkdir() - with socket.socket() as reservation: - reservation.bind(('127.0.0.1', 0)) - adb_port = reservation.getsockname()[1] - environment['ANDROID_ADB_SERVER_PORT'] = str(adb_port) - environment['ADB_SERVER_SOCKET'] = f'tcp:127.0.0.1:{adb_port}' - port = available_port_pair() - device = f'emulator-{port}' - receipt = {'software_emulation': True, 'avd': name, 'device': device, - 'private_adb_port': adb_port, 'stage': 'create-avd', 'passed': False} - (report / 'headless-execution.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8') - - def command(arguments, filename, *, timeout=30, check=True, stdin=None): - result = subprocess.run([str(arg) for arg in arguments], env=environment, - input=stdin, capture_output=True, text=True, - encoding='utf-8', errors='replace', timeout=timeout) - output = result.stdout + result.stderr - (report / filename).write_text(output, encoding='utf-8') - if check and result.returncode: - raise RuntimeError(f'{filename}: exit {result.returncode}: {output[-1800:]}') - return result.returncode, output.strip() - - tools = sdk / 'cmdline-tools/latest' - java = Path(os.environ['JAVA_HOME']) / 'bin/java.exe' - command([java, '-Dfile.encoding=UTF-8', f'-Dcom.android.sdkmanager.toolsdir={tools}', - '-classpath', tools / 'lib/avdmanager-classpath.jar', - 'com.android.sdklib.tool.AvdManagerCli', 'create', 'avd', '--name', name, - '--package', 'system-images;android-30;aosp_atd;x86_64', '--device', 'pixel_2'], - 'avd-create.txt', timeout=120, stdin='no\n') - configuration = avd_home / f'{name}.avd/config.ini' - if not configuration.is_file(): - raise RuntimeError('AVD manager did not create the private AVD configuration.') - with configuration.open('a', encoding='utf-8') as stream: - stream.write('\nhw.cpu.ncore=2\nhw.gps=no\n') - adb = sdk / 'platform-tools/adb.exe' - emulator = sdk / 'emulator/emulator.exe' - process = None - owned_device = False - try: - command([adb, '-P', adb_port, 'start-server'], 'adb-start.txt') - receipt['stage'] = 'boot' - with (report / 'emulator.txt').open('w', encoding='utf-8') as log: - process = subprocess.Popen([str(emulator), '-avd', name, '-port', str(port), - '-no-window', '-gpu', 'swiftshader_indirect', - '-show-kernel', '-no-metrics', - '-feature', '-Vulkan', '-feature', '-GnssGrpcV1', - '-no-snapshot', '-no-snapshot-save', '-noaudio', - '-no-boot-anim', '-camera-back', 'none', '-accel', 'off'], - env=environment, stdout=log, stderr=subprocess.STDOUT) - started = time.monotonic() - last_diagnostic = '' - while time.monotonic() - started < 900: - if process.poll() is not None: - raise RuntimeError('The software Android emulator exited before boot completed.') - result, boot = command([adb, '-P', adb_port, '-s', device, 'shell', 'getprop', 'sys.boot_completed'], - 'boot-state.txt', check=False) - if boot == '1': - _, registered = command([adb, '-P', adb_port, '-s', device, 'emu', 'avd', 'name'], 'avd-name.txt') - if registered.splitlines()[0] != name: - raise RuntimeError('Emulator port is owned by a different AVD; refusing to use it.') - owned_device = True - break - print(f'Waiting for software Android boot: {time.monotonic() - started:.0f}s; ' - f'ADB exit={result}, state={boot[:160]!r}.', flush=True) - with (report / 'emulator.txt').open('rb') as snapshot: - snapshot.seek(max(0, snapshot.seek(0, 2) - 800)) - diagnostic = snapshot.read().decode('utf-8', errors='replace').strip() - if diagnostic and diagnostic != last_diagnostic: - print('Guest boot diagnostic: ' + diagnostic, flush=True) - last_diagnostic = diagnostic - time.sleep(10) - if not owned_device: - raise TimeoutError('The software Android emulator did not boot within 15 minutes.') - receipt['boot_seconds'] = time.monotonic() - started - receipt['stage'] = 'native-execution' - remote = '/data/local/tmp/' + name - command([adb, '-P', adb_port, '-s', device, 'shell', 'mkdir', remote], 'device-mkdir.txt') - try: - for filename in ['zstandard_android_smoke', 'libzstandard_android.so']: - command([adb, '-P', adb_port, '-s', device, 'push', report / 'runtime' / filename, - remote + '/' + filename], 'device-push-' + filename + '.txt') - command([adb, '-P', adb_port, '-s', device, 'shell', 'chmod', '700', remote + '/zstandard_android_smoke'], - 'device-chmod.txt') - _, output = command([adb, '-P', adb_port, '-s', device, 'shell', - f'LD_LIBRARY_PATH={remote} {remote}/zstandard_android_smoke'], - 'native-runtime.txt', timeout=300) - if not output.startswith('PASS: Android shared library'): - raise RuntimeError('Native Android smoke test did not report success.') - print(output, flush=True) - receipt.update({'stage': 'complete', 'passed': True}) - finally: - command([adb, '-P', adb_port, '-s', device, 'shell', 'rm', '-rf', remote], - 'device-cleanup.txt', check=False) - except BaseException as error: - receipt['error'] = str(error) - if (report / 'emulator.txt').is_file(): - print((report / 'emulator.txt').read_text(encoding='utf-8', errors='replace')[-12000:], flush=True) - raise - finally: - cleanup_errors = [] - - def cleanup(arguments, filename): - try: - command(arguments, filename, check=False) - except Exception as error: - cleanup_errors.append(str(error)) - - if owned_device: - cleanup([adb, '-P', adb_port, '-s', device, 'emu', 'kill'], 'emulator-stop.txt') - if process is not None: - try: - process.wait(timeout=15) - except subprocess.TimeoutExpired: - cleanup(['taskkill', '/PID', process.pid, '/T', '/F'], 'owned-process-stop.txt') - # Only this job's private daemon, never the host's default ADB server. - cleanup([adb, '-P', adb_port, 'kill-server'], 'adb-stop.txt') - receipt['cleanup_errors'] = cleanup_errors - (report / 'headless-execution.json').write_text(json.dumps(receipt, indent=2), encoding='utf-8') - if cleanup_errors and receipt['passed']: - raise RuntimeError('Windows Android cleanup failed: ' + '; '.join(cleanup_errors)) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--sdk-root', type=Path, required=True) - parser.add_argument('--report-directory', type=Path, required=True) - arguments = parser.parse_args() - execute(arguments.sdk_root.resolve(), arguments.report_directory.resolve())