From 639876cb5cf34f4feb316adba091c03771230ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Tue, 27 Oct 2026 23:48:28 +0700 Subject: [PATCH 01/11] perf(packaging): add isolated native size and install benchmark matrix --- .github/workflows/package-benchmark.yml | 232 ++++++++++++++++++++++++ scripts/measure_package.py | 112 ++++++++++++ scripts/measure_windows_install.ps1 | 86 +++++++++ tests/test_measure_package.py | 38 ++++ 4 files changed, 468 insertions(+) create mode 100644 .github/workflows/package-benchmark.yml create mode 100644 scripts/measure_package.py create mode 100644 scripts/measure_windows_install.ps1 create mode 100644 tests/test_measure_package.py diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml new file mode 100644 index 0000000..0479737 --- /dev/null +++ b/.github/workflows/package-benchmark.yml @@ -0,0 +1,232 @@ +name: Package benchmark + +on: + workflow_dispatch: + push: + branches: [codex/package-performance] + paths: + - '.github/workflows/package-benchmark.yml' + - 'scripts/measure_package.py' + - 'scripts/measure_windows_install.ps1' + +permissions: + contents: read + +concurrency: + group: package-benchmark-${{ github.ref }} + cancel-in-progress: true + +jobs: + android: + runs-on: ubuntu-latest + timeout-minutes: 55 + strategy: + fail-fast: false + matrix: + include: + - name: universal-modern + flags: '' + - name: universal-clean + flags: '--cleanup-app' + - name: arm64-modern + flags: '--arch arm64-v8a --cleanup-app' + - name: arm64-legacy + flags: '--arch arm64-v8a --cleanup-app --android-legacy-packaging' + steps: + - name: Check out benchmark source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.13' + cache: pip + - name: Set up Java + uses: actions/setup-java@d7793b545071e98d581d3bf084a51c3213318a07 + with: + distribution: temurin + java-version: '17' + - name: Build diagnostic APK from source configuration + shell: bash + env: + FLAGS: ${{ matrix.flags }} + VARIANT: ${{ matrix.name }} + run: | + set -euo pipefail + pip install -e ".[android-build]" + VERSION=$(python scripts/release_metadata.py --print-version) + BUILD=$(python -c 'from scripts.release_metadata import release_build_number; import sys; print(release_build_number(sys.argv[1]))' "$VERSION") + python scripts/release_metadata.py --runtime-output src/assets/release-version + python scripts/generate_public_runtime_config.py --output src/assets/diagnostics-config.json + read -r -a OPTIONS <<< "$FLAGS" + flet build apk --build-version "$VERSION" --build-number "$BUILD" "${OPTIONS[@]}" --yes --verbose || test -d build/flutter + flet-android-notifications-patch --project-root build/flutter + flet build apk --build-version "$VERSION" --build-number "$BUILD" "${OPTIONS[@]}" --yes --verbose + mkdir -p measurements + mapfile -t PACKAGES < <(find build/apk -maxdepth 1 -type f -name '*.apk') + test "${#PACKAGES[@]}" -eq 1 + APK="${PACKAGES[0]}" + ANALYZER="$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" + test "$("$ANALYZER" manifest version-name "$APK")" = "$VERSION" + test "$("$ANALYZER" manifest version-code "$APK")" = "$BUILD" + test "$("$ANALYZER" manifest application-id "$APK")" = com.uthelper.uthelper + "$ANALYZER" manifest print "$APK" > measurements/android-manifest.xml + for receiver in ScheduledNotificationReceiver ScheduledNotificationBootReceiver ActionBroadcastReceiver DeadlineAlarmReceiver RescheduleReceiver; do + grep -q "$receiver" measurements/android-manifest.xml + done + TOOLS=$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1) + "$TOOLS/apksigner" verify "$APK" + cp "$APK" "measurements/$VARIANT-diagnostic.apk" + python scripts/measure_package.py "$APK" --output "measurements/$VARIANT.json" + cp build/flutter/pubspec.yaml measurements/ + cp build/flutter/pubspec.lock measurements/ + printf '%s\n' "$GITHUB_SHA" > measurements/source-commit.txt + printf '%s\n' 'DEBUG-SIGNED EXPERIMENT ONLY. Not a production update; never replace a release asset.' > measurements/DIAGNOSTIC_ONLY.txt + - name: Save diagnostic measurements + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: package-android-${{ matrix.name }} + path: measurements/ + if-no-files-found: error + retention-days: 7 + + ios: + runs-on: macos-latest + timeout-minutes: 65 + strategy: + fail-fast: false + matrix: + include: + - name: baseline + flags: '' + - name: cleanup + flags: '--cleanup-app' + steps: + - name: Check out benchmark source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.13' + cache: pip + - name: Build and verify unsigned device IPA + shell: bash + env: + FLAGS: ${{ matrix.flags }} + VARIANT: ${{ matrix.name }} + run: | + set -euo pipefail + pip install -e . + VERSION=$(python scripts/release_metadata.py --print-version) + BUILD=$(python -c 'from scripts.release_metadata import release_build_number; import sys; print(release_build_number(sys.argv[1]))' "$VERSION") + python scripts/release_metadata.py --runtime-output src/assets/release-version + python scripts/generate_public_runtime_config.py --output src/assets/diagnostics-config.json + read -r -a OPTIONS <<< "$FLAGS" + flet build ipa --build-version "$VERSION" --build-number "$BUILD" "${OPTIONS[@]}" --yes --verbose + mkdir -p measurements + find build/ipa -mindepth 1 -maxdepth 1 -type d -name '*.xcarchive' > archives.txt + test "$(wc -l < archives.txt | tr -d ' ')" -eq 1 + ARCHIVE=$(sed -n '1p' archives.txt) + python scripts/package_unsigned_ipa.py --archive "$ARCHIVE" \ + --output "measurements/$VARIANT-diagnostic.ipa" --evidence "measurements/$VARIANT-verification.json" \ + --version "$VERSION" --build-number "$BUILD" --bundle-id com.uthelper.UTHelper \ + --commit-sha "$GITHUB_SHA" --workflow-run-id "$GITHUB_RUN_ID" + python scripts/measure_package.py "measurements/$VARIANT-diagnostic.ipa" --output "measurements/$VARIANT.json" + cp build/flutter/pubspec.lock measurements/ + printf '%s\n' "$GITHUB_SHA" > measurements/source-commit.txt + printf '%s\n' 'UNSIGNED EXPERIMENT ONLY. Device installation requires user signing. Not a production update.' > measurements/DIAGNOSTIC_ONLY.txt + - name: Save diagnostic measurements + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: package-ios-${{ matrix.name }} + path: measurements/ + if-no-files-found: error + retention-days: 7 + + windows: + runs-on: windows-latest + timeout-minutes: 65 + env: + PYTHONUTF8: '1' + PYTHONIOENCODING: utf-8 + WIX_EULA_ACCEPTED: ${{ vars.WIX_EULA_ACCEPTED }} + strategy: + fail-fast: false + matrix: + include: + - name: baseline + flags: '' + - name: cleanup + flags: '--cleanup-app' + steps: + - name: Check out benchmark source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.13' + cache: pip + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 + with: + dotnet-version: '8.0.x' + - name: Build baseline or cleaned Windows bundle + shell: pwsh + env: + FLAGS: ${{ matrix.flags }} + run: | + $ErrorActionPreference = 'Stop' + pip install -e ".[windows]" + if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed' } + $version = python scripts/release_metadata.py --print-version + $build = python -c 'from scripts.release_metadata import release_build_number; import sys; print(release_build_number(sys.argv[1]))' $version + python scripts/release_metadata.py --runtime-output src/assets/release-version + python scripts/generate_public_runtime_config.py --output src/assets/diagnostics-config.json + New-Item -ItemType Directory -Path build/support -Force | Out-Null + $template = 'build/support/template.zip' + Invoke-WebRequest -Uri 'https://github.com/flet-dev/flet/releases/download/v0.86.5/flet-build-template.zip' -OutFile $template -TimeoutSec 60 + if ((Get-FileHash $template -Algorithm SHA256).Hash.ToLowerInvariant() -ne '8f95dc20ef6d901d9b5ee59f00e33d19f1d2bc6be8d6d3b800c4aab3d7315b73') { throw 'Template hash changed' } + python scripts/prepare_flet_diagnostics_template.py --source $template --output build/support/template-diagnostics.zip + if ($LASTEXITCODE -ne 0) { throw 'Template validation failed' } + $options = @($env:FLAGS -split ' ' | Where-Object { $_ }) + flet build windows --build-version $version --build-number $build --template build/support/template-diagnostics.zip @options --yes --verbose + if ($LASTEXITCODE -ne 0) { throw 'Windows build failed' } + python scripts/verify_flutter_diagnostics.py --template build/support/template-diagnostics.zip --project-root build/flutter + if ($LASTEXITCODE -ne 0) { throw 'Flutter diagnostics verification failed' } + python scripts/prepare_windows_bundle.py build/windows + python scripts/verify_windows_bundle.py build/windows --expected-version $version + if ($LASTEXITCODE -ne 0) { throw 'Bundle verification failed' } + - name: Measure real install and uninstall for two CAB levels + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if ($env:WIX_EULA_ACCEPTED -ne 'wix7') { throw 'Owner-accepted WiX EULA is required' } + $version = python scripts/release_metadata.py --print-version + $bundle = (Resolve-Path build/windows).Path + New-Item -ItemType Directory -Path measurements -Force | Out-Null + foreach ($level in @('high', 'medium')) { + $folder = "build/benchmark-installer/$level" + New-Item -ItemType Directory -Path $folder -Force | Out-Null + # Change a private copy of authored WiX source, never the shipping installer. + (Get-Content packaging/windows/Package.wxs -Raw).Replace('CompressionLevel="high"', "CompressionLevel=`"$level`"") | Set-Content "$folder/Package.wxs" -Encoding utf8 + Copy-Item packaging/windows/UTHelper.Package.wixproj "$folder/Benchmark.wixproj" + $destination = (Resolve-Path $folder).Path + [IO.Path]::DirectorySeparatorChar + dotnet build "$folder/Benchmark.wixproj" -c Release '-p:AcceptEula=wix7' "-p:Version=$version" "-p:AppBundle=$bundle" "-p:OutputPath=$destination" + if ($LASTEXITCODE -ne 0) { throw "MSI $level build failed" } + ./scripts/measure_windows_install.ps1 -MsiPath "$folder/UTHelper-$version.msi" -OutputPath "measurements/$level.json" + if ($LASTEXITCODE -ne 0) { throw "MSI $level measurement failed" } + } + $env:GITHUB_SHA | Set-Content measurements/source-commit.txt + 'UNSIGNED EXPERIMENT ONLY. Installer samples used only on this disposable runner.' | Set-Content measurements/DIAGNOSTIC_ONLY.txt + - name: Save diagnostic measurements + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: package-windows-${{ matrix.name }} + path: measurements/ + if-no-files-found: error + retention-days: 7 diff --git a/scripts/measure_package.py b/scripts/measure_package.py new file mode 100644 index 0000000..9f169a2 --- /dev/null +++ b/scripts/measure_package.py @@ -0,0 +1,112 @@ +"""Inspect native packages and measure archive reads, never install or rewrite them.""" + +from __future__ import annotations + +import argparse +from collections import defaultdict +import hashlib +import io +import json +from pathlib import Path +import platform +import statistics +import time +import zipfile + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _entries(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]: + entries = archive.infolist() + names = [item.filename for item in entries] + if len(names) != len(set(names)): + raise ValueError("archive contains duplicate entry names") + if sum(item.file_size for item in entries) > 2 * 1024**3: + raise ValueError("archive exceeds the 2 GiB measurement budget") + return entries + + +def inspect_package(path: Path, *, read_repeats: int = 0) -> dict: + path = Path(path) + record = { + "schema_version": 1, + "artifact": path.name, + "bytes": path.stat().st_size, + "sha256": file_sha256(path), + "host": {"system": platform.system(), "machine": platform.machine()}, + "scope": "archive structure and sequential reads; not installation or frame timing", + } + groups = defaultdict(lambda: {"stored_bytes": 0, "expanded_bytes": 0, "files": 0}) + with zipfile.ZipFile(path) as archive: + entries = _entries(archive) + record["expanded_bytes"] = sum(item.file_size for item in entries) + record["entry_count"] = len(entries) + record["native_abis"] = sorted({ + item.filename.split("/")[1] for item in entries + if item.filename.startswith("lib/") and item.filename.endswith(".so") + }) + record["python_archives"] = [] + for item in entries: + parts = item.filename.split("/") + key = "/".join(parts[:2] if path.suffix == ".apk" else parts[:4]) + groups[key]["stored_bytes"] += item.compress_size + groups[key]["expanded_bytes"] += item.file_size + groups[key]["files"] += 1 + if parts[-1] in {"app.zip", "sitepackages.zip", "stdlib.zip"}: + if item.file_size > 256 * 1024**2: + raise ValueError("nested Python archive exceeds measurement budget") + with zipfile.ZipFile(io.BytesIO(archive.read(item))) as nested: + inner = _entries(nested) + record["python_archives"].append({ + "name": item.filename, + "stored_bytes": item.compress_size, + "expanded_bytes": sum(entry.file_size for entry in inner), + "entry_count": len(inner), + "cache_directory_bytes": sum( + entry.file_size for entry in inner + if "__pycache__" in entry.filename.split("/") + ), + }) + record["groups"] = dict(sorted(groups.items())) + record["largest_files"] = [ + {"name": item.filename, "stored_bytes": item.compress_size, + "expanded_bytes": item.file_size, "zip_method": item.compress_type} + for item in sorted(entries, key=lambda entry: entry.compress_size, reverse=True)[:20] + ] + if read_repeats: + elapsed = [] + for _ in range(read_repeats): + start = time.perf_counter() + for item in entries: + with archive.open(item) as stream: + while stream.read(1024 * 1024): + pass # ZipExtFile verifies CRC on complete reads. + elapsed.append(time.perf_counter() - start) + record["archive_read"] = { + "scope": "host sequential decompression with CRC; warm-cache, not OS install", + "seconds": elapsed, + "median_seconds": statistics.median(elapsed), + } + return record + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", type=Path) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--read-repeats", type=int, choices=range(0, 6), default=3) + args = parser.parse_args() + record = inspect_package(args.package, read_repeats=args.read_repeats) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(json.dumps({key: record[key] for key in ("artifact", "bytes", "expanded_bytes", "native_abis")})) + + +if __name__ == "__main__": + main() diff --git a/scripts/measure_windows_install.ps1 b/scripts/measure_windows_install.ps1 new file mode 100644 index 0000000..f5b92fc --- /dev/null +++ b/scripts/measure_windows_install.ps1 @@ -0,0 +1,86 @@ +param( + [Parameter(Mandatory = $true)][string]$MsiPath, + [Parameter(Mandatory = $true)][string]$OutputPath, + [ValidateRange(2, 5)][int]$Repeats = 3 +) + +# Real installation is confined to disposable hosted runners. Never replace a +# maintainer's installed app, register a benchmark product, or touch their profile. +$ErrorActionPreference = "Stop" +if ($env:GITHUB_ACTIONS -ne "true" -or $env:RUNNER_ENVIRONMENT -ne "github-hosted") { + throw "Run installation measurements only on a disposable GitHub-hosted Windows runner" +} +$msi = (Resolve-Path -LiteralPath $MsiPath).Path +$installDir = Join-Path $env:ProgramFiles "UTHelper" +if (Test-Path -LiteralPath $installDir) { throw "Existing UTHelper installation must not be overwritten" } +$installer = New-Object -ComObject WindowsInstaller.Installer +$database = $installer.OpenDatabase($msi, 0) +$view = $database.OpenView('SELECT `Value` FROM `Property` WHERE `Property`=''ProductCode''') +$view.Execute() +$productCode = $view.Fetch().StringData(1) +if ($productCode -notmatch '^\{[0-9A-Fa-f-]{36}\}$') { throw "Invalid MSI ProductCode" } +$root = Join-Path $env:RUNNER_TEMP ("uthelper-package-bench-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $root | Out-Null +$samples = [Collections.Generic.List[object]]::new() +$active = $false + +function Invoke-TimedMsi([string[]]$Arguments) { + $start = [Diagnostics.ProcessStartInfo]::new("msiexec.exe") + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + foreach ($argument in $Arguments) { [void]$start.ArgumentList.Add($argument) } + $start.Environment["APPDATA"] = Join-Path $root "AppData" + $start.Environment["LOCALAPPDATA"] = Join-Path $root "LocalAppData" + $watch = [Diagnostics.Stopwatch]::StartNew() + $process = [Diagnostics.Process]::Start($start) + try { + if (-not $process.WaitForExit(180000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "MSI operation timed out" + } + if ($process.ExitCode -ne 0) { throw "MSI operation failed: $($process.ExitCode)" } + return $watch.Elapsed.TotalSeconds + } finally { $process.Dispose() } +} + +try { + for ($round = 0; $round -lt $Repeats; $round++) { + # Mark before invocation so a partial MSI install is also cleaned up. + $active = $true + $installSeconds = Invoke-TimedMsi @("/i", $msi, "/qn", "/norestart", "/l*v", (Join-Path $root "install-$round.log")) + $files = @(Get-ChildItem -LiteralPath $installDir -Recurse -File) + $installedBytes = ($files | Measure-Object Length -Sum).Sum + $verify = Join-Path $PSScriptRoot "verify_windows_bundle.py" + python $verify $installDir + if ($LASTEXITCODE -ne 0) { throw "Installed bundle failed verification" } + # The existing harness isolates APPDATA and tests visible/hidden launches, + # tray activation and single-instance recovery. It is not a frame benchmark. + & (Join-Path $PSScriptRoot "test_windows_single_instance_e2e.ps1") ` + -ExePath (Join-Path $installDir "UTHelper.exe") ` + -StartupAliasPath (Join-Path $installDir "UTHelperAutostart.exe") ` + -WorkingDirectory $installDir -WindowTimeoutSeconds 15 + if ($LASTEXITCODE -ne 0) { throw "Installed application smoke test failed" } + $uninstallSeconds = Invoke-TimedMsi @("/x", $productCode, "/qn", "/norestart", "/l*v", (Join-Path $root "uninstall-$round.log")) + $active = $false + if (Test-Path -LiteralPath $installDir) { throw "Uninstall did not remove the installed bundle" } + $samples.Add(@{ + install_seconds = $installSeconds; uninstall_seconds = $uninstallSeconds + installed_bytes = $installedBytes; installed_files = $files.Count + smoke = "manual/autostart/hidden/activation passed" + }) + } + $result = @{ + schema_version = 1; artifact = [IO.Path]::GetFileName($msi) + bytes = (Get-Item -LiteralPath $msi).Length + sha256 = (Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash.ToLowerInvariant() + scope = "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing" + samples = @($samples.ToArray()) + } + $destination = [IO.Path]::GetFullPath($OutputPath) + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + $result | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $destination -Encoding utf8 +} finally { + if ($active) { [void](Invoke-TimedMsi @("/x", $productCode, "/qn", "/norestart")) } + # Retain installer logs in RUNNER_TEMP for failure diagnostics; the runner is + # disposable. They are never uploaded with customer or credential state. +} diff --git a/tests/test_measure_package.py b/tests/test_measure_package.py new file mode 100644 index 0000000..44156ea --- /dev/null +++ b/tests/test_measure_package.py @@ -0,0 +1,38 @@ +import io +import zipfile + +import pytest + +from scripts.measure_package import inspect_package + + +def test_package_report_accounts_for_abis_nested_cache_and_reads(tmp_path): + inner = io.BytesIO() + with zipfile.ZipFile(inner, "w") as archive: + archive.writestr("main.pyc", b"compiled") + archive.writestr("__pycache__/main.cpython-314.pyc", b"redundant") + package = tmp_path / "fixture.apk" + with zipfile.ZipFile(package, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("lib/arm64-v8a/libpython.so", b"runtime" * 100) + archive.writestr("lib/x86_64/libpython.so", b"runtime" * 100) + archive.writestr("assets/app.zip", inner.getvalue()) + + report = inspect_package(package, read_repeats=2) + + assert report["bytes"] == package.stat().st_size + assert report["native_abis"] == ["arm64-v8a", "x86_64"] + assert report["groups"]["lib/arm64-v8a"]["expanded_bytes"] == 700 + assert report["python_archives"][0]["cache_directory_bytes"] == 9 + assert len(report["archive_read"]["seconds"]) == 2 + assert "not OS install" in report["archive_read"]["scope"] + assert not (tmp_path / "main.pyc").exists() + + +def test_duplicate_archive_paths_do_not_silently_double_count(tmp_path): + package = tmp_path / "duplicate.apk" + with zipfile.ZipFile(package, "w") as archive: + archive.writestr("classes.dex", b"first") + with pytest.warns(UserWarning): + archive.writestr("classes.dex", b"second") + with pytest.raises(ValueError, match="duplicate"): + inspect_package(package) From c400ec3c4e3a3a36b4083040f7a628ec4644d778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Tue, 27 Oct 2026 23:56:15 +0700 Subject: [PATCH 02/11] fix(benchmarks): support empty macOS options and scoped reruns --- .github/workflows/package-benchmark.yml | 31 +++++++++++++++---------- scripts/measure_windows_install.ps1 | 7 ++++-- tests/test_release_hardening.py | 4 +++- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml index 0479737..87c9798 100644 --- a/.github/workflows/package-benchmark.yml +++ b/.github/workflows/package-benchmark.yml @@ -2,22 +2,23 @@ name: Package benchmark on: workflow_dispatch: - push: - branches: [codex/package-performance] - paths: - - '.github/workflows/package-benchmark.yml' - - 'scripts/measure_package.py' - - 'scripts/measure_windows_install.ps1' + inputs: + platform: + description: Platform group to measure + type: choice + options: [all, android, ios, windows] + default: all permissions: contents: read concurrency: - group: package-benchmark-${{ github.ref }} + group: package-benchmark-${{ github.ref }}-${{ inputs.platform || 'all' }} cancel-in-progress: true jobs: android: + if: inputs.platform == 'all' || inputs.platform == 'android' runs-on: ubuntu-latest timeout-minutes: 55 strategy: @@ -59,10 +60,13 @@ jobs: BUILD=$(python -c 'from scripts.release_metadata import release_build_number; import sys; print(release_build_number(sys.argv[1]))' "$VERSION") python scripts/release_metadata.py --runtime-output src/assets/release-version python scripts/generate_public_runtime_config.py --output src/assets/diagnostics-config.json - read -r -a OPTIONS <<< "$FLAGS" - flet build apk --build-version "$VERSION" --build-number "$BUILD" "${OPTIONS[@]}" --yes --verbose || test -d build/flutter + set -- + if [ -n "$FLAGS" ]; then read -r -a OPTIONS <<< "$FLAGS"; set -- "${OPTIONS[@]}"; fi + flet build apk --build-version "$VERSION" --build-number "$BUILD" "$@" --yes --verbose || test -d build/flutter flet-android-notifications-patch --project-root build/flutter - flet build apk --build-version "$VERSION" --build-number "$BUILD" "${OPTIONS[@]}" --yes --verbose + python scripts/release_metadata.py --runtime-output src/assets/release-version + python scripts/generate_public_runtime_config.py --output src/assets/diagnostics-config.json + flet build apk --build-version "$VERSION" --build-number "$BUILD" "$@" --yes --verbose mkdir -p measurements mapfile -t PACKAGES < <(find build/apk -maxdepth 1 -type f -name '*.apk') test "${#PACKAGES[@]}" -eq 1 @@ -92,6 +96,7 @@ jobs: retention-days: 7 ios: + if: inputs.platform == 'all' || inputs.platform == 'ios' runs-on: macos-latest timeout-minutes: 65 strategy: @@ -124,8 +129,9 @@ jobs: BUILD=$(python -c 'from scripts.release_metadata import release_build_number; import sys; print(release_build_number(sys.argv[1]))' "$VERSION") python scripts/release_metadata.py --runtime-output src/assets/release-version python scripts/generate_public_runtime_config.py --output src/assets/diagnostics-config.json - read -r -a OPTIONS <<< "$FLAGS" - flet build ipa --build-version "$VERSION" --build-number "$BUILD" "${OPTIONS[@]}" --yes --verbose + set -- + if [ -n "$FLAGS" ]; then read -r -a OPTIONS <<< "$FLAGS"; set -- "${OPTIONS[@]}"; fi + flet build ipa --build-version "$VERSION" --build-number "$BUILD" "$@" --yes --verbose mkdir -p measurements find build/ipa -mindepth 1 -maxdepth 1 -type d -name '*.xcarchive' > archives.txt test "$(wc -l < archives.txt | tr -d ' ')" -eq 1 @@ -147,6 +153,7 @@ jobs: retention-days: 7 windows: + if: inputs.platform == 'all' || inputs.platform == 'windows' runs-on: windows-latest timeout-minutes: 65 env: diff --git a/scripts/measure_windows_install.ps1 b/scripts/measure_windows_install.ps1 index f5b92fc..ce984a7 100644 --- a/scripts/measure_windows_install.ps1 +++ b/scripts/measure_windows_install.ps1 @@ -55,11 +55,14 @@ try { if ($LASTEXITCODE -ne 0) { throw "Installed bundle failed verification" } # The existing harness isolates APPDATA and tests visible/hidden launches, # tray activation and single-instance recovery. It is not a frame benchmark. - & (Join-Path $PSScriptRoot "test_windows_single_instance_e2e.ps1") ` + $activation = & (Join-Path $PSScriptRoot "test_windows_single_instance_e2e.ps1") ` -ExePath (Join-Path $installDir "UTHelper.exe") ` -StartupAliasPath (Join-Path $installDir "UTHelperAutostart.exe") ` -WorkingDirectory $installDir -WindowTimeoutSeconds 15 - if ($LASTEXITCODE -ne 0) { throw "Installed application smoke test failed" } + $activation | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0 -or ($activation | Out-String) -match 'single_instance_fail_open') { + throw "Installed application smoke test failed" + } $uninstallSeconds = Invoke-TimedMsi @("/x", $productCode, "/qn", "/norestart", "/l*v", (Join-Path $root "uninstall-$round.log")) $active = $false if (Test-Path -LiteralPath $installDir) { throw "Uninstall did not remove the installed bundle" } diff --git a/tests/test_release_hardening.py b/tests/test_release_hardening.py index a74cc9a..d6276e3 100644 --- a/tests/test_release_hardening.py +++ b/tests/test_release_hardening.py @@ -135,6 +135,7 @@ def test_legacy_installer_cannot_author_a_competing_application_version(): def test_android_build_workflows_install_the_notification_patcher(): for workflow_path in ( ".github/workflows/build-android.yml", + ".github/workflows/package-benchmark.yml", ".github/workflows/release.yml", ): workflow = _read(workflow_path) @@ -447,6 +448,7 @@ def test_every_release_flet_build_generates_packaged_runtime_assets_first(): ".github/workflows/release.yml", ".github/workflows/build-ios.yml", ".github/workflows/build-android.yml", + ".github/workflows/package-benchmark.yml", "scripts/build_android.ps1", } generator = "scripts/generate_public_runtime_config.py" @@ -497,7 +499,7 @@ def test_every_release_flet_build_generates_packaged_runtime_assets_first(): assert version_output in version_matching[0] previous_build = build_index - assert total_builds == 9 + assert total_builds == 13 for workflow_path in ( ".github/workflows/release.yml", From 9972a2a5df2f391e1f412a7706c0aed674940606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 09:47:57 +0700 Subject: [PATCH 03/11] fix(benchmarks): pass existing WiX acceptance explicitly to isolated jobs --- .github/workflows/package-benchmark.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml index 87c9798..aeda668 100644 --- a/.github/workflows/package-benchmark.yml +++ b/.github/workflows/package-benchmark.yml @@ -8,6 +8,10 @@ on: type: choice options: [all, android, ios, windows] default: all + wix_eula: + description: Existing owner-accepted WiX EULA marker (wix7), required for Windows + type: string + default: '' permissions: contents: read @@ -159,7 +163,7 @@ jobs: env: PYTHONUTF8: '1' PYTHONIOENCODING: utf-8 - WIX_EULA_ACCEPTED: ${{ vars.WIX_EULA_ACCEPTED }} + WIX_EULA_ACCEPTED: ${{ inputs.wix_eula }} strategy: fail-fast: false matrix: From f49b1cc7baf30d6e5f4b8d400a7b96624c7f1455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 10:04:18 +0700 Subject: [PATCH 04/11] perf(benchmarks): compare Windows help-file removal without trimming runtimes --- .github/workflows/package-benchmark.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml index aeda668..19b298c 100644 --- a/.github/workflows/package-benchmark.yml +++ b/.github/workflows/package-benchmark.yml @@ -12,12 +12,17 @@ on: description: Existing owner-accepted WiX EULA marker (wix7), required for Windows type: string default: '' + windows_profile: + description: Windows comparison group + type: choice + options: [both, documentation-only] + default: both permissions: contents: read concurrency: - group: package-benchmark-${{ github.ref }}-${{ inputs.platform || 'all' }} + group: package-benchmark-${{ github.ref }}-${{ inputs.platform || 'all' }}-${{ inputs.windows_profile || 'both' }} cancel-in-progress: true jobs: @@ -167,11 +172,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - name: baseline - flags: '' - - name: cleanup - flags: '--cleanup-app' + include: ${{ fromJSON(inputs.windows_profile == 'documentation-only' && '[{"name":"documentation","flags":"--cleanup-app --cleanup-package-files **.chm"}]' || '[{"name":"baseline","flags":""},{"name":"cleanup","flags":"--cleanup-app"}]') }} steps: - name: Check out benchmark source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 From 0dcbce6ad01f037d2c706fa689881d8cde02a8f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 10:06:29 +0700 Subject: [PATCH 05/11] perf(packaging): retain validated cleanup and document measured tradeoffs --- .github/workflows/package-benchmark.yml | 6 +- docs/README.md | 2 + docs/testing/package-performance-results.json | 296 ++++++++++++++++++ docs/testing/package-performance.md | 150 +++++++++ pyproject.toml | 4 + scripts/README.md | 8 + scripts/build_android.ps1 | 17 +- scripts/measure_package.py | 7 +- scripts/measure_windows_install.ps1 | 5 + tests/test_autostart_and_tray.py | 5 +- tests/test_release_hardening.py | 2 +- 11 files changed, 493 insertions(+), 9 deletions(-) create mode 100644 docs/testing/package-performance-results.json create mode 100644 docs/testing/package-performance.md diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml index 19b298c..59f041f 100644 --- a/.github/workflows/package-benchmark.yml +++ b/.github/workflows/package-benchmark.yml @@ -35,7 +35,7 @@ jobs: matrix: include: - name: universal-modern - flags: '' + flags: '--no-cleanup-app' - name: universal-clean flags: '--cleanup-app' - name: arm64-modern @@ -113,7 +113,7 @@ jobs: matrix: include: - name: baseline - flags: '' + flags: '--no-cleanup-app' - name: cleanup flags: '--cleanup-app' steps: @@ -172,7 +172,7 @@ jobs: strategy: fail-fast: false matrix: - include: ${{ fromJSON(inputs.windows_profile == 'documentation-only' && '[{"name":"documentation","flags":"--cleanup-app --cleanup-package-files **.chm"}]' || '[{"name":"baseline","flags":""},{"name":"cleanup","flags":"--cleanup-app"}]') }} + include: ${{ fromJSON(inputs.windows_profile == 'documentation-only' && '[{"name":"documentation","flags":"--cleanup-app --cleanup-package-files **.chm"}]' || '[{"name":"baseline","flags":"--no-cleanup-app"},{"name":"cleanup","flags":"--cleanup-app"}]') }} steps: - name: Check out benchmark source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 diff --git a/docs/README.md b/docs/README.md index a1709e5..e5399bd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,8 @@ UI và metadata đóng gói được sinh từ nguồn này. iOS, or desktop presentation on another runtime. - [Notification E2E matrix](testing/notification-e2e-matrix.md) — automated and real-device evidence expected per platform. +- [Package performance](testing/package-performance.md) — measured APK/IPA size, + packaging tradeoffs and isolated Windows installation benchmarks. - [Architecture decisions](adr/) — accepted decisions that constrain current code. ## Architecture history diff --git a/docs/testing/package-performance-results.json b/docs/testing/package-performance-results.json new file mode 100644 index 0000000..e1a32a8 --- /dev/null +++ b/docs/testing/package-performance-results.json @@ -0,0 +1,296 @@ +{ + "measurements": [ + { + "archive_read": { + "median_seconds": 0.2518943390000459, + "scope": "host sequential decompression with CRC; warm-cache, not OS install", + "seconds": [ + 0.2611800120000112, + 0.24720042000001285, + 0.2518943390000459 + ] + }, + "artifact": "UTHelper.apk", + "bytes": 36291515, + "entry_count": 571, + "expanded_bytes": 87356315, + "host": { + "machine": "x86_64", + "system": "Linux" + }, + "native_abis": [ + "arm64-v8a" + ], + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40544518112", + "scope": "archive structure and sequential reads; not installation or frame timing", + "sha256": "77cda2448b1c118e702800343d3bbd513c3017e8e51b1ae9616eda17dfe182cc", + "source_commit": "639876cb5cf34f4feb316adba091c03771230ecd", + "variant": "android-arm64-legacy/arm64-legacy" + }, + { + "archive_read": { + "median_seconds": 0.1573023099999773, + "scope": "host sequential decompression with CRC; warm-cache, not OS install", + "seconds": [ + 0.15747567999994772, + 0.15678534000005584, + 0.1573023099999773 + ] + }, + "artifact": "UTHelper.apk", + "bytes": 67347963, + "entry_count": 571, + "expanded_bytes": 87356315, + "host": { + "machine": "x86_64", + "system": "Linux" + }, + "native_abis": [ + "arm64-v8a" + ], + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40544518112", + "scope": "archive structure and sequential reads; not installation or frame timing", + "sha256": "4a21f1ce89346d11e838c46fb023487a89c69fdad0912f9938379e34e5f7de1c", + "source_commit": "639876cb5cf34f4feb316adba091c03771230ecd", + "variant": "android-arm64-modern/arm64-modern" + }, + { + "archive_read": { + "median_seconds": 0.18132401600007597, + "scope": "host sequential decompression with CRC; warm-cache, not OS install", + "seconds": [ + 0.18132401600007597, + 0.18120450300000357, + 0.18166117900000245 + ] + }, + "artifact": "UTHelper.apk", + "bytes": 163499557, + "entry_count": 699, + "expanded_bytes": 182490911, + "host": { + "machine": "x86_64", + "system": "Linux" + }, + "native_abis": [ + "arm64-v8a", + "armeabi-v7a", + "x86_64" + ], + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40544518112", + "scope": "archive structure and sequential reads; not installation or frame timing", + "sha256": "d1cd724ddcffd6662e82edf4d9a384a52ca3b5c534fb4b00e219ade03f9f1f3c", + "source_commit": "639876cb5cf34f4feb316adba091c03771230ecd", + "variant": "android-universal-clean/universal-clean" + }, + { + "archive_read": { + "median_seconds": 0.1810958620000065, + "scope": "host sequential decompression with CRC; warm-cache, not OS install", + "seconds": [ + 0.18115615399995022, + 0.1810958620000065, + 0.18069049800010362 + ] + }, + "artifact": "UTHelper.apk", + "bytes": 163510745, + "entry_count": 699, + "expanded_bytes": 182514588, + "host": { + "machine": "x86_64", + "system": "Linux" + }, + "native_abis": [ + "arm64-v8a", + "armeabi-v7a", + "x86_64" + ], + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40544518112", + "scope": "archive structure and sequential reads; not installation or frame timing", + "sha256": "e5a7dafedd30cc832ef23819854d5408299e1e40fc5c5536be1bbb2019343277", + "source_commit": "639876cb5cf34f4feb316adba091c03771230ecd", + "variant": "android-universal-modern/universal-modern" + }, + { + "archive_read": { + "median_seconds": 0.1705503750000048, + "scope": "host sequential decompression with CRC; warm-cache, not OS install", + "seconds": [ + 0.1811076250000383, + 0.1669790000000262, + 0.1705503750000048 + ] + }, + "artifact": "baseline-diagnostic.ipa", + "bytes": 36704553, + "entry_count": 3471, + "expanded_bytes": 89424793, + "host": { + "machine": "arm64", + "system": "Darwin" + }, + "native_abis": [], + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40545771860", + "scope": "archive structure and sequential reads; not installation or frame timing", + "sha256": "b70e3b15a88e00aa9ad89237c1177dbb102568459ba06d41c1aeab319df2441c", + "source_commit": "c400ec3c4e3a3a36b4083040f7a628ec4644d778", + "variant": "ios-baseline/baseline" + }, + { + "archive_read": { + "median_seconds": 0.22567983300001515, + "scope": "host sequential decompression with CRC; warm-cache, not OS install", + "seconds": [ + 0.21189595799995686, + 0.22567983300001515, + 0.25661533399994596 + ] + }, + "artifact": "cleanup-diagnostic.ipa", + "bytes": 36691320, + "entry_count": 3467, + "expanded_bytes": 89401637, + "host": { + "machine": "arm64", + "system": "Darwin" + }, + "native_abis": [], + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40545771860", + "scope": "archive structure and sequential reads; not installation or frame timing", + "sha256": "78f3cca7021e80a114f7d4c0e19d0b0e1001689ea97ec503ce088b53440bdbe7", + "source_commit": "c400ec3c4e3a3a36b4083040f7a628ec4644d778", + "variant": "ios-cleanup/cleanup" + }, + { + "artifact": "UTHelper-2.4.1.msi", + "bytes": 50300570, + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40623030510", + "samples": [ + { + "install_seconds": 13.4556383, + "installed_bytes": 155030802.0, + "installed_files": 3471, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 19.1555686 + }, + { + "install_seconds": 12.3984377, + "installed_bytes": 155030802.0, + "installed_files": 3471, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 18.0833468 + }, + { + "install_seconds": 12.1200647, + "installed_bytes": 155030802.0, + "installed_files": 3471, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 17.8119949 + } + ], + "scope": "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing", + "sha256": "37f1b564d2993744b7d6c986b573bd302903c9547dc8744f9a4be5418a297c3b", + "source_commit": "9972a2a5df2f391e1f412a7706c0aed674940606", + "variant": "windows-baseline/high" + }, + { + "artifact": "UTHelper-2.4.1.msi", + "bytes": 53384858, + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40623030510", + "samples": [ + { + "install_seconds": 12.5396575, + "installed_bytes": 155030802.0, + "installed_files": 3471, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 17.7236579 + }, + { + "install_seconds": 12.1389538, + "installed_bytes": 155030802.0, + "installed_files": 3471, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 17.6352606 + }, + { + "install_seconds": 12.1738439, + "installed_bytes": 155030802.0, + "installed_files": 3471, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 17.7384267 + } + ], + "scope": "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing", + "sha256": "4b53b8b1acff7c66882a39b4765f75d5117811855a8833e3fa9b7add15ecad7c", + "source_commit": "9972a2a5df2f391e1f412a7706c0aed674940606", + "variant": "windows-baseline/medium" + }, + { + "artifact": "UTHelper-2.4.1.msi", + "bytes": 50320683, + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40623030510", + "samples": [ + { + "install_seconds": 11.9370449, + "installed_bytes": 155007688.0, + "installed_files": 3468, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 17.2310095 + }, + { + "install_seconds": 10.7099169, + "installed_bytes": 155007688.0, + "installed_files": 3468, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 16.1899527 + }, + { + "install_seconds": 10.4740664, + "installed_bytes": 155007688.0, + "installed_files": 3468, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 15.7165341 + } + ], + "scope": "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing", + "sha256": "d45a2b577e7e6260bd816021357a8898bc93a95c3f937f87606f098f58d345fc", + "source_commit": "9972a2a5df2f391e1f412a7706c0aed674940606", + "variant": "windows-cleanup/high" + }, + { + "artifact": "UTHelper-2.4.1.msi", + "bytes": 53380395, + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40623030510", + "samples": [ + { + "install_seconds": 13.1471596, + "installed_bytes": 155007688.0, + "installed_files": 3468, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 19.6476585 + }, + { + "install_seconds": 14.5096592, + "installed_bytes": 155007688.0, + "installed_files": 3468, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 19.6079558 + }, + { + "install_seconds": 15.4843346, + "installed_bytes": 155007688.0, + "installed_files": 3468, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 17.6231703 + } + ], + "scope": "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing", + "sha256": "072c8ca9d8abe58270863e9d8f642dd1a3e6ebf486c787d4513d41cb59c704a1", + "source_commit": "9972a2a5df2f391e1f412a7706c0aed674940606", + "variant": "windows-cleanup/medium" + } + ], + "schema_version": 1, + "scope": "Diagnostic builds and host/hosted-runner measurements. No physical-mobile install or frame evidence. See package-performance.md for method and limits." +} diff --git a/docs/testing/package-performance.md b/docs/testing/package-performance.md new file mode 100644 index 0000000..082c29a --- /dev/null +++ b/docs/testing/package-performance.md @@ -0,0 +1,150 @@ +# Package size and installation benchmark + +## Scope and provenance + +The experiment keeps the UTHelper 2.4.1 application, dependencies, native plugins, +signing checks, notifications and update boundaries intact. No production asset is +replaced. APKs are debug-signed diagnostics; device IPAs are unsigned and require +user signing. Neither is a production update. + +- Android: [matrix run 40544518112](https://github.com/Chouwzi/UTHelper/actions/runs/40544518112), + source `639876cb5cf34f4feb316adba091c03771230ecd`. +- iOS: [corrected matrix run 40545771860](https://github.com/Chouwzi/UTHelper/actions/runs/40545771860), + source `c400ec3c4e3a3a36b4083040f7a628ec4644d778`. +- Windows: [installation matrix run 40623030510](https://github.com/Chouwzi/UTHelper/actions/runs/40623030510), + source `9972a2a5df2f391e1f412a7706c0aed674940606`. + +The commits differ only in benchmark tooling; application source and packaging +defaults are identical. Baseline and candidate are rebuilt rather than comparing +different application releases. `pubspec.lock`, source revision, package hash, +manifest and measurements accompany the diagnostic artifacts. The first macOS +baseline failed on Bash's empty-array handling; the corrected iOS run replaces +that measurement. The first Windows measurement stopped before installation +because the environment-scoped WiX acceptance was unavailable; the corrected run +receives the already accepted `wix7` marker explicitly. + +## Android results + +MiB means 1,048,576 bytes. Expanded archive bytes are not an Android storage report. + +| Variant | APK MiB | Expanded archive MiB | Reduction from universal | +|---|---:|---:|---:| +| Universal, modern native packaging | 155.936 | 174.060 | baseline | +| Universal, application cleanup | 155.925 | 174.037 | 0.007% | +| ARM64, modern native packaging, cleanup | 64.228 | 83.309 | 58.81% | +| ARM64, legacy native compression, cleanup | 34.610 | 83.309 | 77.81% | + +All four builds passed APK signature verification, exact application/version/build +checks and the five notification/background receiver checks. This is native build +and package evidence, not a device execution test. No Android device is connected; +the owner selected host measurements first. Mobile install time, first launch, +memory, battery and frame timing remain unmeasured. + +The package reader also performs three complete sequential reads with CRC checks. +These are warm-cache host decompression measurements on separate hosted VMs, not +Android installation times or a controlled cross-VM speed comparison. They must +not decide startup or frame-performance acceptance. + +ARM64-only packages omit ARM32 and x86-64 support. Keep universal as the default +and as the compatibility option. Modern packaging is the initial balance choice: +it cuts the download substantially while preserving direct native-library mapping. +Legacy packaging is the smaller-download experiment; it extracts native libraries +at install time and can increase installed storage and loading cost. It is not the +default without device evidence. + +## iOS results + +| Variant | IPA MiB | Expanded archive MiB | Entries | +|---|---:|---:|---:| +| Baseline | 35.0042 | 85.2821 | 3,471 | +| Application cleanup | 34.9916 | 85.2600 | 3,467 | + +Both device IPAs passed the repository's native bundle identity, ARM64, version, +build-number and unsigned-sideload-container verification. Cleanup saves about +13 KiB in this clean CI build; it is not a substantial iOS size optimization. +The largest compressed components include Dart App.framework (about 7 MiB), +Python packages (about 5.4 MiB), Python stdlib (about 5.2 MiB), and Flutter.framework +(about 4.2 MiB). Removing required runtime libraries would change the application +contract, so no such removal is proposed. iPhone installation/startup/frame timing +is outside this Windows-host experiment. + +## Windows method + +Baseline and `--cleanup-app` build on separate disposable Windows runners. Each +runner creates private MSI candidates with the existing high CAB compression and +medium CAB compression, then repeats real quiet installation/uninstallation three +times per candidate. Every installed bundle is verified and runs the existing +isolated single-instance/autostart/hidden-window/activation smoke matrix before +uninstall. It never installs over a maintainer's application or uses their profile. + +The first sample includes Windows Installer initialization; subsequent samples +reuse OS/file caches. Compare repeat distributions, not a single best value. +The EXE bootstrapper is not timed separately. Filesystem bytes and file count are +measured after install. Build time and ZIP read time are not installer time. + +| Bundle | CAB compression | MSI MiB | Median install s | Median uninstall s | Installed MiB / files | +|---|---|---:|---:|---:|---:| +| Baseline | high | 47.970 | 12.398 | 18.083 | 147.849 / 3,471 | +| Baseline | medium | 50.912 | 12.174 | 17.724 | 147.849 / 3,471 | +| Application cleanup | high | 47.990 | 10.710 | 16.190 | 147.827 / 3,468 | +| Application cleanup | medium | 50.908 | 14.510 | 19.608 | 147.827 / 3,468 | + +All 12 installation/uninstallation cycles and their native activation smoke checks +passed. Cleanup removes only about 23 KiB from a clean hosted build. Its faster +high-compression installation on another VM is not evidence that removing those +23 KiB caused a speedup. Medium compression costs about 2.94 MiB more download and +does not consistently improve installation. Keep high compression in production; +do not claim an installer speed improvement from these samples. A separate trial +removes only compiled help (`.chm`) files, retaining all runtime modules. + +The [machine-readable results](package-performance-results.json) retain all samples, +artifact hashes, source commits and run links. Refresh them from the diagnostic +artifacts after a new workflow run; keep the method and limits in this document +in sync. Full package/lock/manifest reports remain in the short-lived workflow +artifacts and the operator's private baseline directory. + +## Changes retained + +- Enable application cleanup through the supported Flet source configuration; + all three platforms built successfully with it. Compilation remains enabled. +- Expose explicit Android architecture/legacy/output-directory options in the + existing patched build script. Universal and modern packaging remain defaults. +- Keep Windows high CAB compression. Do not remove Pillow, lxml, SSL, Pydantic, + Python runtime or notification components to manufacture a smaller result. +- Do not change the production updater or publish benchmark artifacts as releases. + +## Reproduce + +The benchmark workflow is manual and read-only with respect to repository contents, +release assets and secrets. It accepts `platform=android|ios|windows|all`; Windows +also requires the owner's existing WiX EULA acceptance marker. It publishes only +short-lived diagnostic artifacts, never a GitHub release. + +Local Android candidates use the same required two-pass notification patch path: + +```powershell +./scripts/build_android.ps1 -Architecture arm64-v8a -OutputDir build/apk-arm64-modern +./scripts/build_android.ps1 -Architecture arm64-v8a -LegacyPackaging -OutputDir build/apk-arm64-legacy +``` + +Measure an existing artifact without installing or rewriting it: + +```powershell +python scripts/measure_package.py build/apk-arm64-modern/UTHelper.apk --output build/arm64-report.json +``` + +Keep application/package compilation enabled. Cleanup removes redundant cache +directories after producing adjacent runtime bytecode; never recursively delete +all `.pyc` files. Do not edit generated Dart code or re-zip a production APK to +implement these changes: build from the authored configuration and verify again. + +The release inventory and updater currently require a universal APK. Adding +architecture-specific production assets is a separate coordinated change to +inventory, runtime selection, signature verification and compatibility fallback; +the benchmark does not silently replace that working update path. + +## Supporting sources + +- [Flet Android ABI and native packaging](https://flet.dev/docs/publish/android/) +- [Flet compilation and cleanup](https://flet.dev/docs/publish/) +- [Flutter size analysis and download/install distinction](https://docs.flutter.dev/perf/app-size) diff --git a/pyproject.toml b/pyproject.toml index 34de4d6..69f52fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,10 @@ module = "main" app = true packages = true +[tool.flet.cleanup] +# Keep adjacent runtime bytecode; remove redundant build-time cache directories. +app = true + [tool.flet.flutter.pubspec.dependency_overrides] # Flet 0.86.5's template pins 1.0.0; current generated JNI bindings require 1.1. # https://github.com/flet-dev/flet/issues/6919 diff --git a/scripts/README.md b/scripts/README.md index bc084e8..ce4e77b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -44,6 +44,14 @@ this directory. Product tests belong in `tests/` so pytest and CI collect them. - `measure_windows_performance.py` — bounded CPU and memory sampling for a selected Windows launch mode; it is not part of CI. +- `measure_package.py` — read-only APK/IPA structure, size and CRC-checked archive + reads; archive timings are not installation timings. +- `measure_windows_install.ps1` — repeated real MSI install/uninstall and native + activation smoke checks, restricted to disposable GitHub-hosted Windows runners. + +See [package performance](../docs/testing/package-performance.md) for experimental +Android architecture/compression profiles, iOS measurements and Windows installer +comparisons. Production release defaults are independent of the diagnostic matrix. ## Repository governance diff --git a/scripts/build_android.ps1 b/scripts/build_android.ps1 index 6b28d60..05cc246 100644 --- a/scripts/build_android.ps1 +++ b/scripts/build_android.ps1 @@ -1,6 +1,10 @@ param( [ValidateSet("apk", "aab")] - [string]$Target = "apk" + [string]$Target = "apk", + [ValidateSet("universal", "arm64-v8a", "armeabi-v7a", "x86_64")] + [string]$Architecture = "universal", + [switch]$LegacyPackaging, + [string]$OutputDir ) $ErrorActionPreference = "Stop" @@ -8,6 +12,13 @@ $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $fletCommand = Get-Command flet -ErrorAction Stop $patchCommand = Get-Command flet-android-notifications-patch -ErrorAction Stop +# Preserve universal compatibility unless a device architecture is explicitly +# selected. Legacy packaging trades download size for extraction at install time. +$fletOptions = @("--cleanup-app") +if ($Architecture -ne "universal") { $fletOptions += @("--arch", $Architecture) } +if ($LegacyPackaging) { $fletOptions += "--android-legacy-packaging" } +if ($OutputDir) { $fletOptions += @("--output", $OutputDir) } + $env:PYTHONIOENCODING = "utf-8" $env:PYTHONUTF8 = "1" # Kotlin incremental caches cannot relativize Pub cache sources on C: against @@ -66,7 +77,7 @@ try { if (Test-Path -LiteralPath $gradleProperties -PathType Leaf) { Set-BoundedAndroidGradleConfig -GradlePropertiesPath $gradleProperties } - & $fletCommand.Source build $Target --verbose + & $fletCommand.Source build $Target @fletOptions --verbose $firstExit = $LASTEXITCODE if (-not (Test-Path -LiteralPath (Join-Path $projectRoot "build/flutter"))) { throw "Flet did not create build/flutter (exit $firstExit)." @@ -86,7 +97,7 @@ try { if ($LASTEXITCODE -ne 0) { throw "Diagnostics config generation failed before final Android build." } - & $fletCommand.Source build $Target --verbose + & $fletCommand.Source build $Target @fletOptions --verbose if ($LASTEXITCODE -ne 0) { throw "Final Android build failed with exit code $LASTEXITCODE." } diff --git a/scripts/measure_package.py b/scripts/measure_package.py index 9f169a2..2026820 100644 --- a/scripts/measure_package.py +++ b/scripts/measure_package.py @@ -7,6 +7,7 @@ import hashlib import io import json +import os from pathlib import Path import platform import statistics @@ -39,7 +40,11 @@ def inspect_package(path: Path, *, read_repeats: int = 0) -> dict: "artifact": path.name, "bytes": path.stat().st_size, "sha256": file_sha256(path), - "host": {"system": platform.system(), "machine": platform.machine()}, + "host": { + "system": platform.system(), "machine": platform.machine(), + "python": platform.python_version(), "processor": platform.processor(), + "logical_cpus": os.cpu_count(), + }, "scope": "archive structure and sequential reads; not installation or frame timing", } groups = defaultdict(lambda: {"stored_bytes": 0, "expanded_bytes": 0, "files": 0}) diff --git a/scripts/measure_windows_install.ps1 b/scripts/measure_windows_install.ps1 index ce984a7..23a8c22 100644 --- a/scripts/measure_windows_install.ps1 +++ b/scripts/measure_windows_install.ps1 @@ -77,6 +77,11 @@ try { bytes = (Get-Item -LiteralPath $msi).Length sha256 = (Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash.ToLowerInvariant() scope = "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing" + host = @{ + windows = [Environment]::OSVersion.VersionString + logical_cpus = [Environment]::ProcessorCount + processor = (Get-CimInstance Win32_Processor | Select-Object -First 1 -ExpandProperty Name) + } samples = @($samples.ToArray()) } $destination = [IO.Path]::GetFullPath($OutputPath) diff --git a/tests/test_autostart_and_tray.py b/tests/test_autostart_and_tray.py index 71909a3..91d6b63 100644 --- a/tests/test_autostart_and_tray.py +++ b/tests/test_autostart_and_tray.py @@ -301,7 +301,10 @@ def run_setup(): assert tray._thread is None class TestAutostart(unittest.TestCase): - def test_autostart_windows(self): + @patch("platform_utils.autostart.has_package_identity", return_value=False) + def test_autostart_windows(self, _has_package_identity): + # This integration case verifies the Run key. A packaged test host uses + # StartupTask in production, which has no UTHelper_Test_Autostart task. # We only run actual registry test if on Windows if sys.platform != "win32": self.skipTest("Autostart tests are Windows-only") diff --git a/tests/test_release_hardening.py b/tests/test_release_hardening.py index d6276e3..6d5e01a 100644 --- a/tests/test_release_hardening.py +++ b/tests/test_release_hardening.py @@ -256,7 +256,7 @@ def test_local_android_build_script_applies_the_native_notification_patch(): assert '"org.gradle.workers.max" = "2"' in script assert '"kotlin.compiler.execution.strategy" = "in-process"' in script assert '"kotlin.incremental" = "false"' in script - assert script.count("build $Target --verbose") == 2 + assert script.count("build $Target @fletOptions --verbose") == 2 def test_wix7_build_requires_owner_confirmed_eula_variable(): From 8b52f459fca2765a63c7002b03bd86a70cec3147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 10:27:36 +0700 Subject: [PATCH 06/11] perf(packaging): trim validated Windows help and preserve benchmark baselines --- .github/workflows/package-benchmark.yml | 26 +++++--- docs/testing/package-performance-results.json | 64 +++++++++++++++++++ docs/testing/package-performance.md | 27 ++++++-- pyproject.toml | 7 +- scripts/README.md | 2 + scripts/prepare_package_benchmark.py | 60 +++++++++++++++++ tests/test_prepare_package_benchmark.py | 22 +++++++ 7 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 scripts/prepare_package_benchmark.py create mode 100644 tests/test_prepare_package_benchmark.py diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml index 59f041f..a674da8 100644 --- a/.github/workflows/package-benchmark.yml +++ b/.github/workflows/package-benchmark.yml @@ -35,13 +35,13 @@ jobs: matrix: include: - name: universal-modern - flags: '--no-cleanup-app' + flags: '' - name: universal-clean - flags: '--cleanup-app' + flags: '' - name: arm64-modern - flags: '--arch arm64-v8a --cleanup-app' + flags: '--arch arm64-v8a' - name: arm64-legacy - flags: '--arch arm64-v8a --cleanup-app --android-legacy-packaging' + flags: '--arch arm64-v8a --android-legacy-packaging' steps: - name: Check out benchmark source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 @@ -62,9 +62,11 @@ jobs: env: FLAGS: ${{ matrix.flags }} VARIANT: ${{ matrix.name }} + APP_CLEANUP: ${{ matrix.name == 'universal-modern' && 'false' || 'true' }} run: | set -euo pipefail pip install -e ".[android-build]" + python scripts/prepare_package_benchmark.py --app-cleanup "$APP_CLEANUP" --evidence measurements VERSION=$(python scripts/release_metadata.py --print-version) BUILD=$(python -c 'from scripts.release_metadata import release_build_number; import sys; print(release_build_number(sys.argv[1]))' "$VERSION") python scripts/release_metadata.py --runtime-output src/assets/release-version @@ -113,9 +115,9 @@ jobs: matrix: include: - name: baseline - flags: '--no-cleanup-app' + flags: '' - name: cleanup - flags: '--cleanup-app' + flags: '' steps: - name: Check out benchmark source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 @@ -131,9 +133,11 @@ jobs: env: FLAGS: ${{ matrix.flags }} VARIANT: ${{ matrix.name }} + APP_CLEANUP: ${{ matrix.name == 'baseline' && 'false' || 'true' }} run: | set -euo pipefail pip install -e . + python scripts/prepare_package_benchmark.py --app-cleanup "$APP_CLEANUP" --evidence measurements VERSION=$(python scripts/release_metadata.py --print-version) BUILD=$(python -c 'from scripts.release_metadata import release_build_number; import sys; print(release_build_number(sys.argv[1]))' "$VERSION") python scripts/release_metadata.py --runtime-output src/assets/release-version @@ -172,7 +176,7 @@ jobs: strategy: fail-fast: false matrix: - include: ${{ fromJSON(inputs.windows_profile == 'documentation-only' && '[{"name":"documentation","flags":"--cleanup-app --cleanup-package-files **.chm"}]' || '[{"name":"baseline","flags":"--no-cleanup-app"},{"name":"cleanup","flags":"--cleanup-app"}]') }} + include: ${{ fromJSON(inputs.windows_profile == 'documentation-only' && '[{"name":"documentation"}]' || '[{"name":"baseline"},{"name":"cleanup"}]') }} steps: - name: Check out benchmark source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 @@ -190,11 +194,14 @@ jobs: - name: Build baseline or cleaned Windows bundle shell: pwsh env: - FLAGS: ${{ matrix.flags }} + APP_CLEANUP: ${{ matrix.name == 'baseline' && 'false' || 'true' }} + HELP_CLEANUP: ${{ matrix.name == 'documentation' && 'true' || 'false' }} run: | $ErrorActionPreference = 'Stop' pip install -e ".[windows]" if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed' } + python scripts/prepare_package_benchmark.py --app-cleanup $env:APP_CLEANUP --help-cleanup $env:HELP_CLEANUP --evidence measurements + if ($LASTEXITCODE -ne 0) { throw 'Benchmark configuration staging failed' } $version = python scripts/release_metadata.py --print-version $build = python -c 'from scripts.release_metadata import release_build_number; import sys; print(release_build_number(sys.argv[1]))' $version python scripts/release_metadata.py --runtime-output src/assets/release-version @@ -205,8 +212,7 @@ jobs: if ((Get-FileHash $template -Algorithm SHA256).Hash.ToLowerInvariant() -ne '8f95dc20ef6d901d9b5ee59f00e33d19f1d2bc6be8d6d3b800c4aab3d7315b73') { throw 'Template hash changed' } python scripts/prepare_flet_diagnostics_template.py --source $template --output build/support/template-diagnostics.zip if ($LASTEXITCODE -ne 0) { throw 'Template validation failed' } - $options = @($env:FLAGS -split ' ' | Where-Object { $_ }) - flet build windows --build-version $version --build-number $build --template build/support/template-diagnostics.zip @options --yes --verbose + flet build windows --build-version $version --build-number $build --template build/support/template-diagnostics.zip --yes --verbose if ($LASTEXITCODE -ne 0) { throw 'Windows build failed' } python scripts/verify_flutter_diagnostics.py --template build/support/template-diagnostics.zip --project-root build/flutter if ($LASTEXITCODE -ne 0) { throw 'Flutter diagnostics verification failed' } diff --git a/docs/testing/package-performance-results.json b/docs/testing/package-performance-results.json index e1a32a8..75972b5 100644 --- a/docs/testing/package-performance-results.json +++ b/docs/testing/package-performance-results.json @@ -289,6 +289,70 @@ "sha256": "072c8ca9d8abe58270863e9d8f642dd1a3e6ebf486c787d4513d41cb59c704a1", "source_commit": "9972a2a5df2f391e1f412a7706c0aed674940606", "variant": "windows-cleanup/medium" + }, + { + "artifact": "UTHelper-2.4.1.msi", + "bytes": 47912156, + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40624562664", + "samples": [ + { + "install_seconds": 15.7133532, + "installed_bytes": 152364789.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 26.7159555 + }, + { + "install_seconds": 16.8434126, + "installed_bytes": 152364789.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 28.6138152 + }, + { + "install_seconds": 14.2106526, + "installed_bytes": 152364789.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 20.9050572 + } + ], + "scope": "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing", + "sha256": "0f995b50659dae07797395ea5f3b19bc317959df9ca5f7dc7ae352d0c92b9326", + "source_commit": "f49b1cc7baf30d6e5f4b8d400a7b96624c7f1455", + "variant": "windows-documentation/high" + }, + { + "artifact": "UTHelper-2.4.1.msi", + "bytes": 50963676, + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40624562664", + "samples": [ + { + "install_seconds": 13.3765913, + "installed_bytes": 152364789.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 20.9482769 + }, + { + "install_seconds": 13.45746, + "installed_bytes": 152364789.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 21.1536978 + }, + { + "install_seconds": 13.4265665, + "installed_bytes": 152364789.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 22.1149521 + } + ], + "scope": "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing", + "sha256": "dc54699acfe37c71fff229ac023d802bf150532110f17ec4c7c515972d034dff", + "source_commit": "f49b1cc7baf30d6e5f4b8d400a7b96624c7f1455", + "variant": "windows-documentation/medium" } ], "schema_version": 1, diff --git a/docs/testing/package-performance.md b/docs/testing/package-performance.md index 082c29a..d10643b 100644 --- a/docs/testing/package-performance.md +++ b/docs/testing/package-performance.md @@ -13,6 +13,8 @@ user signing. Neither is a production update. source `c400ec3c4e3a3a36b4083040f7a628ec4644d778`. - Windows: [installation matrix run 40623030510](https://github.com/Chouwzi/UTHelper/actions/runs/40623030510), source `9972a2a5df2f391e1f412a7706c0aed674940606`. +- Windows help-file cleanup: [run 40624562664](https://github.com/Chouwzi/UTHelper/actions/runs/40624562664), + source `f49b1cc` (the full revision is retained in the JSON results). The commits differ only in benchmark tooling; application source and packaging defaults are identical. Baseline and candidate are rebuilt rather than comparing @@ -88,14 +90,21 @@ measured after install. Build time and ZIP read time are not installer time. | Baseline | medium | 50.912 | 12.174 | 17.724 | 147.849 / 3,471 | | Application cleanup | high | 47.990 | 10.710 | 16.190 | 147.827 / 3,468 | | Application cleanup | medium | 50.908 | 14.510 | 19.608 | 147.827 / 3,468 | +| Application + help-file cleanup | high | 45.693 | 15.713 | 26.716 | 145.306 / 3,467 | +| Application + help-file cleanup | medium | 48.603 | 13.427 | 21.154 | 145.306 / 3,467 | -All 12 installation/uninstallation cycles and their native activation smoke checks -passed. Cleanup removes only about 23 KiB from a clean hosted build. Its faster +All 18 installation/uninstallation cycles and their native activation smoke checks +passed. Application cleanup removes only about 23 KiB from a clean hosted build. Its faster high-compression installation on another VM is not evidence that removing those 23 KiB caused a speedup. Medium compression costs about 2.94 MiB more download and does not consistently improve installation. Keep high compression in production; -do not claim an installer speed improvement from these samples. A separate trial -removes only compiled help (`.chm`) files, retaining all runtime modules. +do not claim an installer speed improvement from these samples. The help-file trial +removes one PyWin32 compiled help (`.chm`) file, retaining all runtime modules. It +saves 2.52 MiB installed and about 2.30 MiB in the high-compression MSI compared +with application cleanup alone. The high-compression MSI is 4.75% smaller than +baseline. Its slower install samples also came from another VM; the results do +not establish a causal speed improvement or regression. Help-file cleanup is +retained for its measured size reduction and passing native activation checks. The [machine-readable results](package-performance-results.json) retain all samples, artifact hashes, source commits and run links. Refresh them from the diagnostic @@ -107,6 +116,8 @@ artifacts and the operator's private baseline directory. - Enable application cleanup through the supported Flet source configuration; all three platforms built successfully with it. Compilation remains enabled. +- Remove only Windows package help files with the tested `**.chm` glob in + `[tool.flet.windows.cleanup]`; keep all runtime dependencies. - Expose explicit Android architecture/legacy/output-directory options in the existing patched build script. Universal and modern packaging remain defaults. - Keep Windows high CAB compression. Do not remove Pillow, lxml, SSL, Pydantic, @@ -120,6 +131,14 @@ release assets and secrets. It accepts `platform=android|ios|windows|all`; Windo also requires the owner's existing WiX EULA acceptance marker. It publishes only short-lived diagnostic artifacts, never a GitHub release. +The workflow stages explicit cleanup settings in its disposable checkout with +`prepare_package_benchmark.py`. Baseline disables application cleanup and retains +help files; cleanup enables application cleanup only; the Windows +`documentation-only` profile enables both. Original and staged authored TOML are +saved as evidence. This avoids inheriting newly adopted defaults into the +baseline. Flet 0.86.5 does not support `--no-cleanup-app`; source configuration is +used instead. No generated Dart source is edited. + Local Android candidates use the same required two-pass notification patch path: ```powershell diff --git a/pyproject.toml b/pyproject.toml index 69f52fe..0b37046 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ app = true # https://github.com/flet-dev/flet/issues/6919 jni = "1.1.0" -# ── Android-specific configuration ── +# ── Windows-specific configuration ── [tool.flet.windows] dependencies = [ "pystray>=0.19.5", @@ -77,6 +77,11 @@ dependencies = [ "lxml>=6.1.0", ] +[tool.flet.windows.cleanup] +# PyWin32 compiled help is not used at runtime; retain every runtime module. +package_files = ["**.chm"] + +# ── Android-specific configuration ── [tool.flet.android] bundle_id = "com.uthelper.uthelper" adaptive_icon_background = "#0F172A" diff --git a/scripts/README.md b/scripts/README.md index ce4e77b..60b2d0f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -48,6 +48,8 @@ this directory. Product tests belong in `tests/` so pytest and CI collect them. reads; archive timings are not installation timings. - `measure_windows_install.ps1` — repeated real MSI install/uninstall and native activation smoke checks, restricted to disposable GitHub-hosted Windows runners. +- `prepare_package_benchmark.py` — stage cleanup variants in a disposable benchmark + checkout, preserving application settings and saving both TOML configurations. See [package performance](../docs/testing/package-performance.md) for experimental Android architecture/compression profiles, iOS measurements and Windows installer diff --git a/scripts/prepare_package_benchmark.py b/scripts/prepare_package_benchmark.py new file mode 100644 index 0000000..ee8971b --- /dev/null +++ b/scripts/prepare_package_benchmark.py @@ -0,0 +1,60 @@ +"""Stage explicit cleanup settings in a disposable benchmark checkout.""" + +from __future__ import annotations + +import argparse +import copy +import json +from pathlib import Path +import re +import tomllib + + +def benchmark_configuration(source: str, *, app_cleanup: bool, help_cleanup: bool) -> str: + original = tomllib.loads(source) + expected = copy.deepcopy(original) + flet = expected["tool"]["flet"] + flet.setdefault("cleanup", {})["app"] = app_cleanup + flet.setdefault("windows", {}).setdefault("cleanup", {})["package_files"] = ( + ["**.chm"] if help_cleanup else [] + ) + # Rewrite only the two authored cleanup tables. Reparse and compare the entire + # document before writing, so dependencies, compilation and identity survive. + for name, settings in ( + ("tool.flet.cleanup", flet["cleanup"]), + ("tool.flet.windows.cleanup", flet["windows"]["cleanup"]), + ): + block = f"[{name}]\n" + "".join( + f"{json.dumps(key)} = {json.dumps(value)}\n" + for key, value in settings.items() + ) + pattern = rf"(?ms)^\[{re.escape(name)}\][^\n]*\n.*?(?=^\[|\Z)" + source, count = re.subn(pattern, lambda _: block + "\n", source) + if count == 0: + source = source.rstrip() + "\n\n" + block + if tomllib.loads(source) != expected: + raise ValueError("Benchmark staging changed settings outside cleanup") + return source + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--app-cleanup", choices=("true", "false"), required=True) + parser.add_argument("--help-cleanup", choices=("true", "false"), default="false") + parser.add_argument("--project", type=Path, default=Path("pyproject.toml")) + parser.add_argument("--evidence", type=Path, required=True) + args = parser.parse_args() + source = args.project.read_text(encoding="utf-8") + staged = benchmark_configuration( + source, + app_cleanup=args.app_cleanup == "true", + help_cleanup=args.help_cleanup == "true", + ) + args.evidence.mkdir(parents=True, exist_ok=True) + (args.evidence / "pyproject-original.toml").write_text(source, encoding="utf-8") + (args.evidence / "pyproject-benchmark.toml").write_text(staged, encoding="utf-8") + args.project.write_text(staged, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/test_prepare_package_benchmark.py b/tests/test_prepare_package_benchmark.py new file mode 100644 index 0000000..96ccc7e --- /dev/null +++ b/tests/test_prepare_package_benchmark.py @@ -0,0 +1,22 @@ +from pathlib import Path +import tomllib + +import pytest + +from scripts.prepare_package_benchmark import benchmark_configuration + + +@pytest.mark.parametrize("app,help_files", [(False, False), (True, False), (True, True)]) +def test_benchmark_can_restore_baseline_and_candidates_without_changing_app(app, help_files): + source = (Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8") + original = tomllib.loads(source) + staged = tomllib.loads( + benchmark_configuration(source, app_cleanup=app, help_cleanup=help_files) + ) + expected = original + expected["tool"]["flet"]["cleanup"]["app"] = app + expected["tool"]["flet"]["windows"].setdefault("cleanup", {})["package_files"] = ( + ["**.chm"] if help_files else [] + ) + assert staged == expected + assert staged["tool"]["flet"]["compile"] == {"app": True, "packages": True} From 2240501fc510f207a3789ae3762e8dae3311eecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 10:43:47 +0700 Subject: [PATCH 07/11] docs(performance): record final native install confirmation and stack tradeoffs --- docs/testing/package-performance.md | 32 +++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/testing/package-performance.md b/docs/testing/package-performance.md index d10643b..d7319d7 100644 --- a/docs/testing/package-performance.md +++ b/docs/testing/package-performance.md @@ -15,10 +15,12 @@ user signing. Neither is a production update. source `9972a2a5df2f391e1f412a7706c0aed674940606`. - Windows help-file cleanup: [run 40624562664](https://github.com/Chouwzi/UTHelper/actions/runs/40624562664), source `f49b1cc` (the full revision is retained in the JSON results). +- Final source-configuration confirmation: [run 40626799330](https://github.com/Chouwzi/UTHelper/actions/runs/40626799330), + source `8b52f459fca2765a63c7002b03bd86a70cec3147`. -The commits differ only in benchmark tooling; application source and packaging -defaults are identical. Baseline and candidate are rebuilt rather than comparing -different application releases. `pubspec.lock`, source revision, package hash, +The initial matrix runs use identical application source and dependency constraints; +candidates change packaging options. Baseline and candidate are rebuilt rather +than comparing different application releases. `pubspec.lock`, source revision, package hash, manifest and measurements accompany the diagnostic artifacts. The first macOS baseline failed on Bash's empty-array handling; the corrected iOS run replaces that measurement. The first Windows measurement stopped before installation @@ -36,6 +38,17 @@ MiB means 1,048,576 bytes. Expanded archive bytes are not an Android storage rep | ARM64, modern native packaging, cleanup | 64.228 | 83.309 | 58.81% | | ARM64, legacy native compression, cleanup | 34.610 | 83.309 | 77.81% | +The universal APK carries 48.004 MiB of ARM64 native libraries, 40.315 MiB of +ARM32 libraries and 50.413 MiB of x86-64 libraries. In the ARM64 set alone, +`libapp.so` is 14.376 MiB, `libflutter.so` is 11.045 MiB and `libpython3.14.so` +is 5.559 MiB, before Python packages, stdlib and supporting native dependencies. +Python has a real runtime cost, but shipping three architectures is the largest +measured opportunity here. A stack rewrite is not required for the 59% reduction. +Any future Dart-only rewrite needs a feature-parity prototype and the same +notification, background-sync, authentication and update checks before its size +or speed can be compared fairly; this experiment does not claim a measured win +for an unbuilt replacement. + All four builds passed APK signature verification, exact application/version/build checks and the five notification/background receiver checks. This is native build and package evidence, not a device execution test. No Android device is connected; @@ -92,10 +105,12 @@ measured after install. Build time and ZIP read time are not installer time. | Application cleanup | medium | 50.908 | 14.510 | 19.608 | 147.827 / 3,468 | | Application + help-file cleanup | high | 45.693 | 15.713 | 26.716 | 145.306 / 3,467 | | Application + help-file cleanup | medium | 48.603 | 13.427 | 21.154 | 145.306 / 3,467 | +| Source-config confirmation | high | 45.681 | 13.585 | 21.438 | 145.306 / 3,467 | +| Source-config confirmation | medium | 48.595 | 13.804 | 21.580 | 145.306 / 3,467 | -All 18 installation/uninstallation cycles and their native activation smoke checks -passed. Application cleanup removes only about 23 KiB from a clean hosted build. Its faster -high-compression installation on another VM is not evidence that removing those +All 24 installation/uninstallation cycles and their native activation smoke checks +passed. Application cleanup removes only about 23 KiB from a clean hosted build. +Its faster high-compression installation on another VM is not evidence that removing those 23 KiB caused a speedup. Medium compression costs about 2.94 MiB more download and does not consistently improve installation. Keep high compression in production; do not claim an installer speed improvement from these samples. The help-file trial @@ -105,6 +120,11 @@ with application cleanup alone. The high-compression MSI is 4.75% smaller than baseline. Its slower install samples also came from another VM; the results do not establish a causal speed improvement or regression. Help-file cleanup is retained for its measured size reduction and passing native activation checks. +The final source-config build repeated all six install/activation checks on +Windows build 26100, an AMD EPYC 7763 host with four logical CPUs allocated to +the runner. Its staged and authored configurations are semantically identical; +the selected defaults reproduce the measured profile. Separate builds vary by +small packaging overhead, so the high-compression result is about 45.68–45.69 MiB. The [machine-readable results](package-performance-results.json) retain all samples, artifact hashes, source commits and run links. Refresh them from the diagnostic From bb566a198da8600edeab0ab012044db0370ac4a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 10:44:14 +0700 Subject: [PATCH 08/11] docs(performance): retain final Windows samples and runner provenance --- docs/testing/package-performance-results.json | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/docs/testing/package-performance-results.json b/docs/testing/package-performance-results.json index 75972b5..3508070 100644 --- a/docs/testing/package-performance-results.json +++ b/docs/testing/package-performance-results.json @@ -290,6 +290,80 @@ "source_commit": "9972a2a5df2f391e1f412a7706c0aed674940606", "variant": "windows-cleanup/medium" }, + { + "artifact": "UTHelper-2.4.1.msi", + "bytes": 47899868, + "host": { + "logical_cpus": 4, + "processor": "AMD EPYC 7763 64-Core Processor ", + "windows": "Microsoft Windows NT 10.0.26100.0" + }, + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40626799330", + "samples": [ + { + "install_seconds": 17.6783466, + "installed_bytes": 152364907.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 23.6715223 + }, + { + "install_seconds": 13.5847791, + "installed_bytes": 152364907.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 21.412796 + }, + { + "install_seconds": 13.4815058, + "installed_bytes": 152364907.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 21.4381331 + } + ], + "scope": "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing", + "sha256": "8b9688fbf17eb2151f248edcfc881dd4fb692acfab5fe73f825f4cb572e98b65", + "source_commit": "8b52f459fca2765a63c7002b03bd86a70cec3147", + "variant": "windows-documentation-confirmation/high" + }, + { + "artifact": "UTHelper-2.4.1.msi", + "bytes": 50955484, + "host": { + "logical_cpus": 4, + "processor": "AMD EPYC 7763 64-Core Processor ", + "windows": "Microsoft Windows NT 10.0.26100.0" + }, + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40626799330", + "samples": [ + { + "install_seconds": 13.8041353, + "installed_bytes": 152364907.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 21.4415946 + }, + { + "install_seconds": 13.9449229, + "installed_bytes": 152364907.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 21.5804642 + }, + { + "install_seconds": 13.644793, + "installed_bytes": 152364907.0, + "installed_files": 3467, + "smoke": "manual/autostart/hidden/activation passed", + "uninstall_seconds": 22.2816712 + } + ], + "scope": "real MSI install/uninstall and activation smoke on a disposable hosted Windows runner; warm OS cache, not physical-device frame timing", + "sha256": "0b3c0bdc765731f4a722992ca5a69874b3331815e85960798f21f0c2abf3ce37", + "source_commit": "8b52f459fca2765a63c7002b03bd86a70cec3147", + "variant": "windows-documentation-confirmation/medium" + }, { "artifact": "UTHelper-2.4.1.msi", "bytes": 47912156, From cb94aeea58ec9741ab88acfcf4ba9f8456088cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 10:59:48 +0700 Subject: [PATCH 09/11] perf(benchmark): isolate Android device experiments from user data --- .github/workflows/package-benchmark.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml index a674da8..c29059f 100644 --- a/.github/workflows/package-benchmark.yml +++ b/.github/workflows/package-benchmark.yml @@ -17,12 +17,17 @@ on: type: choice options: [both, documentation-only] default: both + android_namespace: + description: Isolate device experiments from an installed UTHelper + type: choice + options: [original, isolated] + default: original permissions: contents: read concurrency: - group: package-benchmark-${{ github.ref }}-${{ inputs.platform || 'all' }}-${{ inputs.windows_profile || 'both' }} + group: package-benchmark-${{ github.ref }}-${{ inputs.platform || 'all' }}-${{ inputs.windows_profile || 'both' }}-${{ inputs.android_namespace || 'original' }} cancel-in-progress: true jobs: @@ -63,6 +68,7 @@ jobs: FLAGS: ${{ matrix.flags }} VARIANT: ${{ matrix.name }} APP_CLEANUP: ${{ matrix.name == 'universal-modern' && 'false' || 'true' }} + APP_ID: ${{ inputs.android_namespace == 'isolated' && 'com.uthelper.benchmark' || 'com.uthelper.uthelper' }} run: | set -euo pipefail pip install -e ".[android-build]" @@ -73,11 +79,11 @@ jobs: python scripts/generate_public_runtime_config.py --output src/assets/diagnostics-config.json set -- if [ -n "$FLAGS" ]; then read -r -a OPTIONS <<< "$FLAGS"; set -- "${OPTIONS[@]}"; fi - flet build apk --build-version "$VERSION" --build-number "$BUILD" "$@" --yes --verbose || test -d build/flutter + flet build apk --bundle-id "$APP_ID" --build-version "$VERSION" --build-number "$BUILD" "$@" --yes --verbose || test -d build/flutter flet-android-notifications-patch --project-root build/flutter python scripts/release_metadata.py --runtime-output src/assets/release-version python scripts/generate_public_runtime_config.py --output src/assets/diagnostics-config.json - flet build apk --build-version "$VERSION" --build-number "$BUILD" "$@" --yes --verbose + flet build apk --bundle-id "$APP_ID" --build-version "$VERSION" --build-number "$BUILD" "$@" --yes --verbose mkdir -p measurements mapfile -t PACKAGES < <(find build/apk -maxdepth 1 -type f -name '*.apk') test "${#PACKAGES[@]}" -eq 1 @@ -85,7 +91,7 @@ jobs: ANALYZER="$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" test "$("$ANALYZER" manifest version-name "$APK")" = "$VERSION" test "$("$ANALYZER" manifest version-code "$APK")" = "$BUILD" - test "$("$ANALYZER" manifest application-id "$APK")" = com.uthelper.uthelper + test "$("$ANALYZER" manifest application-id "$APK")" = "$APP_ID" "$ANALYZER" manifest print "$APK" > measurements/android-manifest.xml for receiver in ScheduledNotificationReceiver ScheduledNotificationBootReceiver ActionBroadcastReceiver DeadlineAlarmReceiver RescheduleReceiver; do grep -q "$receiver" measurements/android-manifest.xml From 533ff4733fe35f10ca2f0e92162ee3a8b47c2f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 11:22:45 +0700 Subject: [PATCH 10/11] perf(android): measure isolated installs and authenticated tablet runtime --- docs/README.md | 2 + .../android-device-performance-results.json | 1580 +++++++++++++++++ docs/testing/android-device-performance.md | 171 ++ docs/testing/package-performance.md | 8 +- scripts/README.md | 2 + scripts/measure_android_install.py | 158 ++ tests/test_measure_android_install.py | 19 + 7 files changed, 1937 insertions(+), 3 deletions(-) create mode 100644 docs/testing/android-device-performance-results.json create mode 100644 docs/testing/android-device-performance.md create mode 100644 scripts/measure_android_install.py create mode 100644 tests/test_measure_android_install.py diff --git a/docs/README.md b/docs/README.md index e5399bd..5d899a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,8 @@ UI và metadata đóng gói được sinh từ nguồn này. real-device evidence expected per platform. - [Package performance](testing/package-performance.md) — measured APK/IPA size, packaging tradeoffs and isolated Windows installation benchmarks. +- [Android device performance](testing/android-device-performance.md) — physical + tablet runtime checks and isolated install measurements that preserve user data. - [Architecture decisions](adr/) — accepted decisions that constrain current code. ## Architecture history diff --git a/docs/testing/android-device-performance-results.json b/docs/testing/android-device-performance-results.json new file mode 100644 index 0000000..8509200 --- /dev/null +++ b/docs/testing/android-device-performance-results.json @@ -0,0 +1,1580 @@ +{ + "artifact_sha256": "4a21f1ce89346d11e838c46fb023487a89c69fdad0912f9938379e34e5f7de1c", + "checked_paths": [ + "authenticated dashboard", + "calendar", + "grades", + "settings", + "notification settings", + "refresh", + "authentication retained across cold launches" + ], + "dashboard_readiness": [ + { + "layout_query_seconds": 1.269479699999465, + "observed_dashboard_seconds_upper_bound": 1.5648885999999038, + "polls": 1 + }, + { + "layout_query_seconds": 1.2151292000007743, + "observed_dashboard_seconds_upper_bound": 1.5596787000004042, + "polls": 1 + }, + { + "layout_query_seconds": 1.3131659999999101, + "observed_dashboard_seconds_upper_bound": 1.5920179999993707, + "polls": 1 + } + ], + "device": { + "abis": [ + "arm64-v8a" + ], + "android_release": "16", + "api": 36, + "battery_temperature_c_range": [ + 34.4, + 36.3 + ], + "charging": true, + "mem_total_kib": 11489048, + "model": "Samsung SM-X826B", + "physical_pixels": [ + 1752, + 2800 + ], + "runtime_orientation": "landscape", + "soc": "MT6989", + "thermal_status_range": [ + 0, + 1 + ] + }, + "frame_timeline": { + "application_frames_available": false, + "classification": "unavailable; compositor-only frames cannot establish Flutter application jank rate", + "private_trace_sha256": "5fe43c223aefe3ebe62c7d6416b3e7988e84b32cfb8d131831e5d5cdaad123d1" + }, + "idle_cpu": { + "percent_one_core": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "scope": "8 one-second top samples, first discarded; short idle snapshot, not long-run battery or idle acceptance" + }, + "isolated_install_environment": { + "battery_temperature_c_after": 36.9, + "cache": "warm OS/filesystem; no global cache reset", + "charging": true, + "thermal_status_range": [ + 1, + 2 + ] + }, + "isolated_installs": { + "native_dependency_delta_from_authenticated_apk": { + "posix": { + "authenticated": "6.5.3", + "isolated": "6.5.4" + } + }, + "package": "com.uthelper.benchmark", + "run_url": "https://github.com/Chouwzi/UTHelper/actions/runs/40629782354", + "samples": [ + { + "code_scan_complete": false, + "extract_native_libs": false, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "290", + "WaitTime": "292" + }, + "install_seconds": 0.8252993000005517, + "login_ui_verified": true, + "round": 1, + "shell_visible_code_kib": 159855, + "thermal_status_before": 1, + "uninstall_seconds": 0.2192484000006516, + "variant": "universal" + }, + { + "code_scan_complete": false, + "extract_native_libs": false, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "491", + "WaitTime": "492" + }, + "install_seconds": 1.0095805000000837, + "login_ui_verified": true, + "round": 1, + "shell_visible_code_kib": 65851, + "thermal_status_before": 1, + "uninstall_seconds": 0.26944519999960903, + "variant": "modern" + }, + { + "code_scan_complete": false, + "extract_native_libs": true, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "252", + "WaitTime": "255" + }, + "install_seconds": 1.3095499000000927, + "login_ui_verified": true, + "round": 1, + "shell_visible_code_kib": 84799, + "thermal_status_before": 1, + "uninstall_seconds": 0.2935665000004519, + "variant": "legacy" + }, + { + "code_scan_complete": false, + "extract_native_libs": true, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "272", + "WaitTime": "283" + }, + "install_seconds": 1.3702222000001711, + "login_ui_verified": true, + "round": 2, + "shell_visible_code_kib": 84799, + "thermal_status_before": 1, + "uninstall_seconds": 0.23952400000052876, + "variant": "legacy" + }, + { + "code_scan_complete": false, + "extract_native_libs": false, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "225", + "WaitTime": "241" + }, + "install_seconds": 11.379844400000366, + "login_ui_verified": true, + "round": 2, + "shell_visible_code_kib": 159855, + "thermal_status_before": 2, + "uninstall_seconds": 0.1991131000004316, + "variant": "universal" + }, + { + "code_scan_complete": false, + "extract_native_libs": false, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "201", + "WaitTime": "211" + }, + "install_seconds": 10.81820179999977, + "login_ui_verified": true, + "round": 2, + "shell_visible_code_kib": 65851, + "thermal_status_before": 2, + "uninstall_seconds": 0.16978029999972932, + "variant": "modern" + }, + { + "code_scan_complete": false, + "extract_native_libs": false, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "245", + "WaitTime": "251" + }, + "install_seconds": 10.700043300000289, + "login_ui_verified": true, + "round": 3, + "shell_visible_code_kib": 65851, + "thermal_status_before": 2, + "uninstall_seconds": 0.1723283999999694, + "variant": "modern" + }, + { + "code_scan_complete": false, + "extract_native_libs": true, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "226", + "WaitTime": "228" + }, + "install_seconds": 11.038222799999858, + "login_ui_verified": true, + "round": 3, + "shell_visible_code_kib": 84799, + "thermal_status_before": 2, + "uninstall_seconds": 0.18915169999945647, + "variant": "legacy" + }, + { + "code_scan_complete": false, + "extract_native_libs": false, + "initial_display": { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "229", + "WaitTime": "233" + }, + "install_seconds": 10.732659100000092, + "login_ui_verified": true, + "round": 3, + "shell_visible_code_kib": 159855, + "thermal_status_before": 2, + "uninstall_seconds": 0.1811321000004682, + "variant": "universal" + } + ], + "schema_version": 1, + "scope": "Primary-user clean installs of an isolated package; staged APK install time excludes USB transfer. am start TotalTime is Android initial display, not Python UI readiness. Login UI is separately verified. No authenticated data is removed.", + "source_commit": "cb94aeea58ec9741ab88acfcf4ba9f8456088cb2", + "variants": { + "legacy": { + "bytes": 36290387, + "sha256": "bee5a1a4f0d40b55f3eeb785fd7813e05c9e942cd93d0ff9d2c45bba26dde918", + "usb_push_seconds": 1.430823099999543 + }, + "modern": { + "bytes": 67348103, + "sha256": "64e76e68c04eb193b9701b421965c44678c8b373572c6a00e9593b4a7a9d868e", + "usb_push_seconds": 1.533417699999518 + }, + "universal": { + "bytes": 163510585, + "sha256": "d29e480bfedc87693ba856be385a830c4b15ef49595680b58cccb3f4c143621f", + "usb_push_seconds": 3.1082333000003928 + } + } + }, + "legacy_authenticated": { + "artifact_sha256": "bee5a1a4f0d40b55f3eeb785fd7813e05c9e942cd93d0ff9d2c45bba26dde918", + "checked_paths": [ + "authenticated dashboard", + "calendar", + "grades", + "settings", + "refresh", + "authentication retained across cold launches" + ], + "dashboard_readiness": [ + { + "layout_query_seconds": 1.3046838000000207, + "observed_dashboard_seconds_upper_bound": 1.5943872000007104, + "polls": 1 + }, + { + "layout_query_seconds": 1.0227424000004248, + "observed_dashboard_seconds_upper_bound": 2.335087399999793, + "polls": 2 + }, + { + "layout_query_seconds": 1.0596675000006144, + "observed_dashboard_seconds_upper_bound": 2.402604799999608, + "polls": 2 + } + ], + "environment": { + "battery_temperature_c": 36.7, + "charging": true, + "thermal_status": 2, + "thermal_status_range": [ + 1, + 2 + ] + }, + "schema_version": 1, + "scope": "Authenticated ARM64-legacy physical-device diagnostics; no account data or captures exported", + "scroll": [ + { + "gesture": 1, + "intervals_ms": [ + 8.347077, + 8.319307, + 8.338847, + 8.326692, + 8.366538, + 8.304539, + 8.357308, + 8.328538, + 8.315077, + 8.331692, + 8.332923, + 8.341616, + 8.326077, + 8.364, + 8.305538, + 8.332077, + 8.331308, + 8.335384, + 8.346539, + 66.679923 + ], + "max_ms": 66.679923, + "median_ms": 8.3325, + "p95_ms": 8.366538, + "presented_frames": 21 + }, + { + "gesture": 2, + "intervals_ms": [ + 8.297616, + 8.334769, + 8.368846, + 8.296692, + 8.339385, + 8.329462, + 8.333615, + 8.336923, + 8.340539, + 8.319769, + 8.338154, + 8.329769, + 8.337231, + 8.367077, + 8.297307, + 8.388923, + 8.277154, + 8.345, + 8.329231, + 8.328615, + 8.380616, + 8.286154, + 8.33323, + 8.332, + 8.33077, + 8.333769, + 8.334769, + 8.333231, + 8.334154, + 8.331769, + 8.339077, + 8.330154, + 8.331077, + 8.334461, + 8.332, + 8.336923, + 8.331692, + 8.336847 + ], + "max_ms": 8.388923, + "median_ms": 8.333423, + "p95_ms": 8.368846, + "presented_frames": 39 + }, + { + "gesture": 3, + "intervals_ms": [ + 8.376308, + 8.279077, + 8.337154, + 8.327384, + 8.362462, + 8.382538, + 8.269769, + 8.325539, + 8.328077, + 8.380846, + 8.285846, + 8.334539, + 8.432307, + 8.258462, + 8.307615, + 8.418231, + 8.292231, + 8.413615, + 8.226154, + 8.319692, + 8.333539, + 8.339077, + 8.366153, + 8.32, + 8.42277, + 8.274154, + 8.276538, + 8.421, + 8.247, + 8.332077, + 8.338461, + 8.328847, + 8.332, + 8.333846, + 8.333846, + 8.334461, + 8.332385, + 8.338077, + 8.344, + 8.561308, + 8.090769, + 8.337538, + 8.328539, + 8.332923, + 8.334846, + 8.334462, + 8.346461, + 8.330769, + 8.34677, + 8.312846, + 8.323692, + 8.340077, + 8.332, + 8.336231, + 8.361, + 8.296923, + 8.337154, + 8.332692, + 8.350769, + 8.333769, + 8.314847 + ], + "max_ms": 8.561308, + "median_ms": 8.333769, + "p95_ms": 8.421, + "presented_frames": 62 + }, + { + "gesture": 4, + "intervals_ms": [ + 8.225846, + 8.346462, + 8.312077, + 16.671384, + 8.414769, + 8.262385, + 8.319769, + 8.330693, + 8.349307, + 8.328, + 8.328, + 8.329462, + 8.450538, + 8.22, + 8.328308, + 8.334154, + 8.429769, + 8.238154, + 8.386538, + 8.294462, + 8.317231, + 8.351, + 8.330538, + 8.363077, + 8.380846, + 8.285539, + 8.289615, + 8.336539, + 8.329615, + 8.389461, + 8.27977, + 8.501461, + 8.172, + 8.364693, + 8.44923, + 8.207077, + 8.344923, + 8.292, + 8.379923, + 8.290847, + 8.337769, + 8.324923, + 8.334846, + 8.333231, + 8.341846, + 8.325154, + 8.594769, + 8.261846, + 8.494231, + 8.045846, + 8.268923, + 8.337231, + 8.336923, + 8.326462, + 8.335384, + 8.383923, + 8.281231, + 8.331692, + 8.334539, + 8.360923, + 8.319692, + 8.322692 + ], + "max_ms": 16.671384, + "median_ms": 8.3311925, + "p95_ms": 8.450538, + "presented_frames": 63 + }, + { + "gesture": 5, + "intervals_ms": [ + 8.275692, + 8.320385, + 16.691615, + 8.345923, + 8.384923, + 8.247385, + 8.333846, + 8.371923, + 8.289231, + 16.669615, + 8.360847, + 8.325307, + 8.311693, + 8.384615, + 8.309154, + 8.309, + 8.331, + 8.337231, + 8.352615, + 8.313231, + 8.337846, + 8.330846, + 8.328231, + 8.348, + 8.321615, + 8.334154, + 8.377846, + 8.380231, + 8.343077, + 8.231385, + 8.407384, + 8.275154, + 8.317769, + 8.336693, + 8.331615, + 8.349846, + 8.313923, + 8.335, + 16.686154, + 8.312, + 8.347077, + 8.321, + 8.335692, + 8.330385, + 8.334154, + 8.333307, + 8.409231, + 8.267923, + 8.328077, + 8.374385, + 16.622846, + 8.327692, + 8.339308, + 8.333615, + 8.331308, + 8.372615, + 8.293, + 8.487693, + 8.179077, + 8.332923, + 8.347923, + 8.337846 + ], + "max_ms": 16.691615, + "median_ms": 8.334, + "p95_ms": 8.487693, + "presented_frames": 63 + }, + { + "gesture": 6, + "intervals_ms": [ + 8.242384, + 8.358462, + 8.385307, + 8.253231, + 16.669539, + 8.374384, + 8.329923, + 8.293231, + 8.346769, + 8.343, + 8.359077, + 8.286539, + 8.407615, + 8.268308, + 8.330846, + 8.325461, + 8.33977, + 8.332307, + 8.330154, + 8.329154, + 8.338154, + 8.398154, + 8.284307, + 8.321847, + 8.328692, + 8.339, + 8.329846, + 8.329846, + 8.423154, + 8.287615, + 8.297847, + 8.345846, + 8.313231, + 8.356076, + 8.315308, + 8.328692, + 8.333231, + 8.344616, + 8.335923, + 8.395077, + 8.255077, + 8.369923, + 8.325538, + 8.463308, + 8.178538, + 8.333846, + 8.345154, + 8.325, + 8.330693, + 8.352384, + 8.419077, + 8.222769, + 8.338077, + 8.349539, + 8.328615, + 8.342462, + 8.362461, + 8.282154, + 16.672692, + 8.328846, + 8.332693, + 8.340538 + ], + "max_ms": 16.672692, + "median_ms": 8.332962, + "p95_ms": 8.419077, + "presented_frames": 63 + } + ], + "scroll_repeat": { + "reason": "Investigate initial 66.68 ms presentation gap after dashboard settled; original samples retained", + "scroll": [ + { + "gesture": 1, + "intervals_ms": [ + 8.309846, + 8.333308, + 8.330692, + 8.330538, + 8.337154, + 8.344077, + 8.340923, + 8.318385, + 8.566231, + 8.094077, + 8.338846, + 8.345538, + 8.316231, + 8.370846, + 8.295923, + 8.332077, + 8.533539, + 16.590769, + 8.214384, + 8.33077, + 8.365538, + 8.304385, + 8.343, + 8.379692, + 8.336923, + 8.301846, + 8.345616, + 8.300615, + 8.364615, + 8.289847, + 8.335, + 8.452384, + 8.215616, + 8.332307, + 8.40777, + 8.257461, + 8.373, + 8.295077, + 8.340539, + 8.346461, + 8.433615, + 8.307385, + 8.239615, + 8.362462, + 8.300923, + 8.373923, + 8.359923, + 8.273923, + 8.372231, + 8.286539, + 8.33323, + 8.332, + 8.33477, + 8.332615, + 8.373769, + 8.296, + 8.329616, + 8.471, + 8.198846, + 8.350154, + 8.315923 + ], + "max_ms": 16.590769, + "median_ms": 8.333308, + "p95_ms": 8.471, + "presented_frames": 62 + }, + { + "gesture": 2, + "intervals_ms": [ + 8.335692, + 8.345539, + 8.317153, + 8.333231, + 8.400308, + 8.308692, + 8.291923, + 8.330154, + 8.344923, + 8.421308, + 8.276923, + 8.297154, + 8.344077, + 8.467, + 8.203154, + 8.312, + 8.334692, + 8.333308, + 8.335307, + 8.335385, + 8.328077, + 8.354692, + 8.352308, + 8.293231, + 8.372307, + 8.296616, + 8.401615, + 8.271692, + 8.33477, + 8.373153, + 8.284616, + 8.340077, + 8.359077, + 8.304, + 8.335, + 8.329615, + 8.357769, + 8.312308, + 8.331077, + 8.332308, + 8.381846, + 8.296384, + 8.351308, + 8.307154, + 8.346385, + 8.321615, + 8.330385, + 8.388, + 8.370153, + 8.243077, + 8.353539, + 8.308692, + 8.334769, + 8.332308, + 8.337462, + 8.331692, + 8.372385, + 16.631, + 8.328692, + 8.395923, + 8.272385 + ], + "max_ms": 16.631, + "median_ms": 8.334692, + "p95_ms": 8.401615, + "presented_frames": 62 + }, + { + "gesture": 3, + "intervals_ms": [ + 8.302769, + 8.414769, + 8.321154, + 8.264692, + 8.371923, + 8.295462, + 8.353538, + 8.313539, + 8.346769, + 8.318461, + 8.389462, + 16.748077, + 8.197231, + 8.338692, + 8.355462, + 8.308615, + 8.332, + 8.371, + 8.295077, + 8.333846, + 8.332308, + 8.335769, + 8.361846, + 8.416, + 8.233539, + 8.320538, + 8.356077, + 8.314385, + 8.332692, + 8.332308, + 8.332307, + 8.334462, + 8.338461, + 8.326462, + 8.348846, + 8.318154, + 8.334769, + 8.354846, + 8.311077, + 8.333462, + 8.379461, + 8.314462, + 8.304, + 8.357846, + 16.662077, + 8.359077, + 8.291384, + 8.337231, + 8.344693, + 8.31723, + 8.332923, + 8.334154, + 8.331693, + 8.335615, + 8.329308, + 8.339, + 8.329615, + 8.358154, + 8.308923, + 8.417461, + 8.249616, + 8.333461 + ], + "max_ms": 16.748077, + "median_ms": 8.3334615, + "p95_ms": 8.414769, + "presented_frames": 63 + }, + { + "gesture": 4, + "intervals_ms": [ + 8.328846, + 8.334231, + 8.339384, + 8.350462, + 8.376, + 8.279384, + 8.348539, + 8.306846, + 8.379, + 8.285615, + 8.460231, + 8.216077, + 8.325231, + 8.335615, + 8.331385, + 8.409307, + 8.258077, + 8.336, + 8.518154, + 8.144616, + 8.337538, + 8.330231, + 8.332615, + 8.333539, + 8.333769, + 8.334846, + 8.333846, + 8.354385, + 8.307385, + 8.337923, + 8.387307, + 8.296077, + 8.319, + 8.331693, + 8.333615, + 8.343077, + 8.464846, + 8.228615, + 8.30377, + 8.329461, + 8.344385, + 8.320923, + 8.333154, + 8.372, + 8.295384, + 8.348616, + 8.325923, + 8.319692, + 8.335308, + 8.334538, + 8.351616, + 8.47023, + 8.176231, + 8.418769, + 8.250231, + 8.330769, + 8.333462, + 8.334154, + 8.497923, + 8.210461, + 8.291308, + 8.332615 + ], + "max_ms": 8.518154, + "median_ms": 8.333577, + "p95_ms": 8.460231, + "presented_frames": 63 + }, + { + "gesture": 5, + "intervals_ms": [ + 8.316307, + 8.333616, + 8.358077, + 8.309846, + 8.330538, + 8.332, + 8.337539, + 8.331923, + 8.336077, + 8.328846, + 8.333615, + 8.335616, + 8.327769, + 8.336308, + 8.36423, + 8.301923, + 8.333462, + 8.339769, + 8.331692, + 8.326385, + 8.334462, + 8.336615, + 8.436077, + 8.228846, + 8.372, + 8.296923, + 8.334846, + 8.331077, + 8.331385, + 8.351615, + 8.316385, + 8.333846, + 8.402692, + 8.402462, + 8.194461, + 8.405616, + 8.275308, + 16.655153, + 8.334385, + 8.352615, + 8.425616, + 16.574769, + 8.329769, + 8.318154, + 8.377923, + 8.291616, + 8.339461, + 8.328231, + 8.329538, + 8.359154, + 8.309769, + 8.325847, + 8.347076, + 8.322231, + 8.331923, + 8.336077, + 8.331923, + 16.800693, + 8.207384, + 8.426077, + 8.235154, + 8.333462 + ], + "max_ms": 16.800693, + "median_ms": 8.3335385, + "p95_ms": 8.426077, + "presented_frames": 63 + }, + { + "gesture": 6, + "intervals_ms": [ + 8.337154, + 8.363077, + 8.337615, + 8.295, + 8.372693, + 8.295923, + 16.671077, + 8.328384, + 8.334462, + 16.666385, + 8.336076, + 8.347693, + 8.317461, + 8.333616, + 8.333231, + 8.366384, + 8.316616, + 8.338538, + 8.323308, + 8.319461, + 8.357769, + 8.308924, + 8.335384, + 16.668308, + 8.330154, + 8.339461, + 8.338077, + 8.335692, + 8.322231, + 8.330385, + 8.335384, + 8.345539, + 8.322769, + 8.395077, + 8.271385, + 8.348692, + 8.317538, + 8.343, + 8.321231, + 16.687462, + 16.655, + 8.339692, + 8.368615, + 8.347693, + 8.273538, + 8.332923, + 8.333539, + 8.352923, + 8.328692, + 8.320231, + 8.336692, + 8.324616, + 8.334077, + 8.348923, + 8.325307, + 8.325154, + 8.430769, + 8.246154, + 8.324693, + 8.331615, + 8.339077, + 8.334846 + ], + "max_ms": 16.687462, + "median_ms": 8.335115, + "p95_ms": 16.655, + "presented_frames": 63 + } + ] + }, + "source_commit": "cb94aeea58ec9741ab88acfcf4ba9f8456088cb2", + "startup": [ + { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "197", + "WaitTime": "198", + "pss_kib": 292689, + "rss_kib": 403549 + }, + { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "251", + "WaitTime": "252", + "pss_kib": 287417, + "rss_kib": 398229 + }, + { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "235", + "WaitTime": "237", + "pss_kib": 297084, + "rss_kib": 407861 + } + ], + "thermal_after": "2" + }, + "schema_version": 1, + "scope": "Physical-device authenticated runtime and isolated clean-install diagnostics; no account data or captures exported", + "scroll": [ + { + "gesture": 1, + "intervals_ms": [ + 8.331077, + 8.372615, + 8.338385, + 8.312923, + 8.310154, + 8.348, + 8.313231, + 8.336307, + 8.365231, + 8.309, + 8.327, + 8.333539, + 8.330538, + 8.331923, + 8.337846, + 8.333539, + 8.333615, + 8.394462, + 8.272615, + 8.330692, + 8.359385, + 8.329615, + 8.311693, + 16.670692, + 8.329231, + 8.333, + 8.33623, + 8.336924, + 8.325538, + 8.340615, + 16.663077, + 8.346462, + 8.315154, + 8.334769, + 8.357538, + 8.312308, + 8.329846, + 8.344308, + 8.324846, + 16.667692, + 16.672385, + 8.324231, + 8.365923, + 8.306154, + 8.372538, + 16.642231, + 8.312, + 16.671923, + 8.331385, + 8.329846, + 8.344307, + 8.341, + 8.316, + 8.339385, + 8.339615, + 8.328, + 8.328693, + 8.396923, + 8.270154, + 8.333461, + 8.331769, + 8.353462 + ], + "max_ms": 16.672385, + "median_ms": 8.333539, + "p95_ms": 16.663077, + "presented_frames": 63 + }, + { + "gesture": 2, + "intervals_ms": [ + 8.335154, + 8.335, + 8.42, + 8.240308, + 8.349, + 8.340231, + 8.311461, + 8.334462, + 8.337153, + 8.328385, + 8.335692, + 8.332616, + 8.335384, + 8.331, + 8.382231, + 8.302769, + 8.341462, + 8.307077, + 8.333615, + 8.332923, + 8.397231, + 8.269231, + 8.349231, + 8.316846, + 8.352307, + 8.36777, + 8.282077, + 8.332923, + 8.42923, + 8.236308, + 8.331769, + 8.335385, + 8.333769, + 8.361846, + 8.366154, + 8.284, + 8.332077, + 8.319692, + 8.336924, + 8.331615, + 8.406846, + 8.265462, + 8.435077, + 8.232384, + 8.335692, + 8.336924, + 8.331076, + 8.374077, + 8.290231, + 8.337231, + 8.319615, + 8.341, + 8.328539, + 8.333923, + 8.329461, + 8.349847, + 8.32, + 8.338846, + 8.333769, + 8.328385, + 8.368538, + 8.300615 + ], + "max_ms": 8.435077, + "median_ms": 8.333846000000001, + "p95_ms": 8.397231, + "presented_frames": 63 + }, + { + "gesture": 3, + "intervals_ms": [ + 16.693231, + 8.370385, + 8.262846, + 8.336846, + 8.346539, + 8.322769, + 8.327923, + 8.337231, + 8.33023, + 8.340539, + 8.327692, + 8.333308, + 8.331308, + 8.333923, + 8.334692, + 8.334231, + 8.331615, + 8.426769, + 8.239693, + 8.330769, + 8.446231, + 8.222692, + 8.337846, + 8.341923, + 8.347616, + 8.38923, + 8.332, + 8.251693, + 8.394153, + 8.351077, + 8.25977, + 8.330692, + 8.336, + 8.330231, + 8.329154, + 8.368923, + 16.635692, + 8.333923, + 16.685846, + 8.319077, + 8.328846, + 8.337308, + 8.330154, + 8.334077, + 8.331077, + 8.352, + 8.314769, + 8.335692, + 8.331154, + 8.328308, + 8.340307, + 8.33077, + 8.331615, + 8.372385, + 8.336307, + 8.363693, + 8.263, + 8.339077, + 8.32923, + 8.332308, + 8.335077, + 8.329615 + ], + "max_ms": 16.693231, + "median_ms": 8.333923, + "p95_ms": 8.426769, + "presented_frames": 63 + }, + { + "gesture": 4, + "intervals_ms": [ + 8.335308, + 8.333539, + 8.376615, + 8.287154, + 8.369154, + 8.32, + 8.335461, + 8.310769, + 8.380231, + 8.288385, + 8.330077, + 8.336307, + 8.334847, + 8.347384, + 8.317539, + 8.334153, + 8.332231, + 8.333231, + 8.334154, + 8.343461, + 8.328, + 8.331077, + 8.343385, + 8.321846, + 8.329846, + 8.372, + 8.295077, + 8.442385, + 8.262231, + 8.297769, + 8.417615, + 8.273154, + 8.403154, + 8.241769, + 8.359769, + 8.302077, + 8.405616, + 8.265461, + 8.334231, + 8.335923, + 8.331462, + 8.333846, + 8.352, + 8.526384, + 8.129923, + 8.325231, + 8.332231, + 8.331385, + 8.332076, + 8.332847, + 8.334461, + 8.336692, + 8.331077, + 8.331693, + 8.349769, + 8.335154, + 8.331, + 8.319692, + 8.342539, + 8.33623, + 8.320923 + ], + "max_ms": 8.526384, + "median_ms": 8.333231, + "p95_ms": 8.405616, + "presented_frames": 62 + }, + { + "gesture": 5, + "intervals_ms": [ + 8.292231, + 8.36923, + 8.297539, + 8.365846, + 8.305846, + 8.343385, + 8.316, + 8.344384, + 8.358693, + 8.302461, + 8.328385, + 8.339923, + 8.423385, + 8.237615, + 8.332615, + 8.341847, + 8.333846, + 8.355077, + 8.335307, + 8.344616, + 8.287461, + 8.339385, + 8.352615, + 8.320231, + 8.392692, + 8.264, + 8.335077, + 8.339077, + 8.328308, + 8.340231, + 8.345538, + 8.330231, + 8.318154, + 8.332307, + 8.371923, + 8.297616, + 8.371615, + 8.297923, + 8.351923, + 8.392308, + 8.251385, + 8.332692, + 8.336308, + 8.332, + 8.347384, + 8.514462, + 8.165769, + 8.326846, + 8.311077, + 8.331616, + 8.332615, + 8.332, + 8.342154, + 8.325846, + 8.335692, + 8.333, + 8.328923, + 8.335385, + 8.386077, + 8.311692, + 8.302462, + 8.332384 + ], + "max_ms": 8.514462, + "median_ms": 8.332846, + "p95_ms": 8.386077, + "presented_frames": 63 + }, + { + "gesture": 6, + "intervals_ms": [ + 8.331923, + 8.354154, + 8.318154, + 8.340923, + 8.325923, + 8.399, + 8.261846, + 8.335385, + 8.378846, + 8.3, + 8.326154, + 8.331, + 8.331077, + 8.333923, + 8.337846, + 8.330769, + 8.329231, + 8.334692, + 8.339077, + 8.328385, + 8.368846, + 8.326846, + 8.311692, + 8.332616, + 8.434461, + 8.223385, + 8.333154, + 8.335077, + 8.338846, + 8.368846, + 8.324, + 8.308692, + 8.343077, + 8.320539, + 16.671384, + 8.327385, + 8.355077, + 8.316077, + 8.348308, + 8.31323, + 8.339693, + 8.329153, + 8.329, + 8.336847, + 8.33323, + 8.332923, + 8.33477, + 8.333615, + 8.332615, + 8.334077, + 8.354539, + 8.310154, + 8.334154, + 8.331, + 8.357, + 8.312615, + 8.328231, + 8.339077, + 8.335077, + 8.32923, + 8.336, + 8.331154 + ], + "max_ms": 16.671384, + "median_ms": 8.3330385, + "p95_ms": 8.368846, + "presented_frames": 63 + } + ], + "scroll_scope": "SurfaceFlinger Flutter SurfaceView presentation timestamp deltas during six 500ms swipes; 128-entry snapshots before/after each swipe, deduplicated; not Flutter build/raster time or application jank classification", + "source_commit": "639876cb5cf34f4feb316adba091c03771230ecd", + "startup": [ + { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "322", + "WaitTime": "325", + "pss_kib": 312634, + "rss_kib": 414537 + }, + { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "201", + "WaitTime": "203", + "pss_kib": 313783, + "rss_kib": 415313 + }, + { + "LaunchState": "COLD", + "Status": "ok", + "TotalTime": "193", + "WaitTime": "198", + "pss_kib": 316092, + "rss_kib": 417601 + } + ], + "startup_scope": "am start -W after force-stop, cached data preserved; initial display is not fully interactive Python readiness", + "thermal_after": "0" +} diff --git a/docs/testing/android-device-performance.md b/docs/testing/android-device-performance.md new file mode 100644 index 0000000..1d38ab4 --- /dev/null +++ b/docs/testing/android-device-performance.md @@ -0,0 +1,171 @@ +# Android device packaging and runtime measurements + +## Device and scope + +Measured on 2026-10-28 using a Samsung SM-X826B, MT6989 SoC, Android 16 / API 36, +ARM64-only ABI, approximately 11 GiB visible RAM and a 1752 × 2800 display rotated +to landscape. The tablet was charging with normal background apps left running. +Battery temperature ranged from 34.4–36.3 °C during the initial runtime checks; +Android thermal status changed from 0 to 1 (light throttling). This is one capable +tablet, not acceptance evidence for low-memory phones or iOS. + +The owner logged into the ARM64 modern diagnostic APK. Its SHA-256 is +`4a21f1ce89346d11e838c46fb023487a89c69fdad0912f9938379e34e5f7de1c`, built from +`639876cb5cf34f4feb316adba091c03771230ecd` in the original +[native matrix](https://github.com/Chouwzi/UTHelper/actions/runs/40544518112). +It uses a release Flutter runtime with a diagnostic debug certificate. The login +session and profile are preserved. No credentials, screenshots, UI dumps or raw +system traces are committed or uploaded; those remain in the operator's private +baseline directory. Numerical samples are retained in the linked JSON report. + +## Authenticated runtime + +| Measurement | Result | Interpretation | +|---|---|---| +| Process-cold initial display, 3 launches | 322 / 201 / 193 ms | `am start -W` after force-stop; OS/file caches retained | +| Dashboard observed after launch, 3 additional checks | 1.565 / 1.560 / 1.592 s | Upper bounds including 1.215–1.313 s layout-query overhead | +| Scroll presentation intervals | P50 8.334 / P95 8.420 / P99 16.671 / max 16.693 ms | 371 intervals across six 500 ms alternating swipes | +| PSS after cold launch | 305.3–308.7 MiB | Whole app process, seven seconds after launch | +| RSS after cold launch | 404.8–407.8 MiB | Shared pages included; not additive with PSS | +| Short idle CPU snapshot | 0% in seven samples | One-second `top` samples after discarding the first; not long-run acceptance | + +Dashboard data, scrolling, calendar, grades, settings, notification settings and a +refresh were observed working. Authentication survived the cold-launch series. +These are positive UI and provider-data observations, not a full notification, +background-delivery, battery, 100-card stress or low-end-device acceptance run. + +The dashboard upper bound requires its filter/settings semantics to be present +and the login dialog to be absent. It does not measure completion of every network +request. Android initial display is not Python UI readiness or time to fully +interactive content. + +Frame intervals come from the app's Flutter SurfaceView in +`dumpsys SurfaceFlinger --latency`: collect before/after each gesture, deduplicate +presentation timestamps, and calculate intervals among newly observed frames. +Each snapshot holds at most 128 rows; each observed gesture produced 62–63 frames. +The results describe short active-scroll presentation cadence, not Flutter +build/raster durations or an application-wide FPS guarantee. The display adapts +its refresh rate; a global idle refresh-period reading is not the gesture budget. + +Perfetto captured the app's native activity and compositor work, but application +FrameTimeline slices for this Vulkan SurfaceView were absent. Compositor-only +FrameTimeline slices and sparse Android `gfxinfo` frames cannot establish the +Flutter app's jank rate. This remains unavailable, rather than being reported as +zero jank. A Flutter profile build or supported app-frame instrumentation is +needed for a build/raster breakdown. + +## Authenticated legacy-compression candidate + +After the install matrix, the owner separately logged into the isolated 34.609 MiB +APK (`bee5a1a4f0d40b55f3eeb785fd7813e05c9e942cd93d0ff9d2c45bba26dde918`). +Both logged-in profiles were retained. Dashboard, scrolling, calendar, grades, +settings, refresh and persistence across process-cold launches were checked on +this candidate as well. + +| Measurement | Legacy result | +|---|---| +| Process-cold initial display | 197 / 251 / 235 ms | +| Dashboard observed upper bounds, including layout polling | 1.594 / 2.335 / 2.403 s | +| First six-swipe series | 305 intervals; P50 8.333 / P95 8.432 / P99 16.671 / max 66.680 ms | +| Six-swipe repeat after settling | 370 intervals; P50 8.334 / P95 8.465 / P99 16.668 / max 16.801 ms | +| PSS after launch | 280.7–290.1 MiB | +| RSS after launch | 388.9–398.3 MiB | + +The 66.68 ms interval is retained as an observed presentation gap. It did not +repeat in the follow-up series; its cause is not established. Thermal status was +2 during the initial legacy checks and 1 after the repeat. These sequential, +differently warmed runs, differing native `posix` patch versions and separate +profiles do not establish that compression caused a latency or memory change. +Dashboard polling has substantial observer overhead and checks visible controls, +not completion of every data request. + +Both ARM64 profiles worked in the scoped authenticated checks on this tablet. +Legacy compression is now supported by physical-device UI evidence as a smaller +download option, with an extraction/storage tradeoff. It is not universal-device, +long-duration, background-notification or battery acceptance, and it does not +justify silently changing the production release/updater contract. + +## Isolated installation comparison + +CI matrix APKs have different ephemeral debug certificates. Replacing them in +`com.uthelper.uthelper` would require uninstalling the logged-in application. +The manual workflow's `android_namespace=isolated` instead builds +`com.uthelper.benchmark` from the same source and three packaging profiles. It +verifies the actual application ID, version, signatures and notification receivers +after both notification-patch build passes. These diagnostic packages are never +production updates. + +The isolated candidates come from +[run 40629782354](https://github.com/Chouwzi/UTHelper/actions/runs/40629782354), +source `cb94aee`. Their resolved native dependency version maps match each other. +The fresh build resolved `posix` 6.5.4, versus 6.5.3 in the earlier authenticated +APK. Therefore install-profile comparisons use only the new matched cohort; +authenticated runtime results above describe the earlier APK independently. + +The install harness accepts only that exact isolated package identity, refuses an +existing package/data baseline, restricts installation/removal to user 0, checks +that all candidates share a version code, and verifies the actual Python login UI +after every install before removing its own sample. It leaves the authenticated +UTHelper package untouched. + +It pushes each APK once, then times `pm install` separately so USB transfer does +not masquerade as package-manager installation. Three rounds rotate universal, +modern and legacy order. No global cache, device settings or user-owned data are +cleared. Code-directory scans can be partial because Android hides ART/oat files +from shell; partial values are explicitly marked and must not be reported as +total installed storage. Application data is not included in code scans. + +All nine rotated clean-install / Python-login-UI / uninstall cycles passed on the +physical tablet. APK signature verification and the earlier native bundle checks +also passed for this isolated cohort. + +| Profile | APK MiB | USB push s | Staged install s, three rounds | Initial display ms, three rounds | Shell-visible code MiB, partial | +|---|---:|---:|---|---|---:| +| Universal modern | 155.936 | 3.108 | 0.825 / 11.380 / 10.733 | 290 / 225 / 229 | 156.108 | +| ARM64 modern | 64.228 | 1.533 | 1.010 / 10.818 / 10.700 | 491 / 201 / 245 | 64.308 | +| ARM64 legacy compression | 34.609 | 1.431 | 1.310 / 1.370 / 11.038 | 252 / 272 / 226 | 82.812 | + +USB push was measured once per APK, not as a repeated transfer benchmark. Most +later install samples gained an approximately ten-second delay across profiles; +thermal status was 1 initially and 2 in later samples, with battery temperature +36.9 °C afterwards. The delay's cause is unproven. Do not attribute it to APK size, +compression or thermal status alone, and do not rank installer speed from these +small, mixed-state samples. They establish successful native installation and UI +startup, not a controlled speedup. OS/file caches were warm. + +The smaller legacy APK enables native-library extraction and its visible code +scan is about 18.5 MiB larger than ARM64 modern. Both scans exclude inaccessible +ART/oat data, so they are not Android Settings total-storage measurements. Modern +packaging remains a reasonable balanced candidate: substantially less download +than universal, no native extraction, and independently checked authenticated UI. +Legacy also passed the bounded authenticated checks above and remains the +smaller-download option. Neither is established as faster to install here. + +Refresh the [numerical report](android-device-performance-results.json) after a +new run, retaining APK hashes, source/workflow provenance and all samples. Keep +captures private. The original [host packaging report](package-performance.md) +retains the APK/IPA size matrix and Windows installation measurements. + +## Reproduce + +Build isolated candidates through the manual `Package benchmark` workflow with +`platform=android` and `android_namespace=isolated`, then download and verify the +diagnostic artifacts. On the explicitly selected device, with no existing isolated +benchmark package: + +```powershell +python scripts/measure_android_install.py --serial DEVICE_SERIAL ` + --aapt SDK/build-tools/35.0.0/aapt.exe --android-cli PATH/android.exe --sdk SDK ` + --universal PATH/universal.apk --modern PATH/arm64-modern.apk ` + --legacy PATH/arm64-legacy.apk --output PRIVATE_DIRECTORY/install-results.json +``` + +The output directory also holds private layout evidence. Copy only the numerical +summary into the maintained report. Leave the real logged-in application intact. + +## Measurement references + +- [Android adb and activity manager](https://developer.android.com/tools/adb) +- [Android initial and full display timing](https://developer.android.com/topic/performance/issues/launch-time) +- [Android rendering measurement limits](https://developer.android.com/topic/performance/issues/render) +- [Perfetto FrameTimeline](https://perfetto.dev/docs/data-sources/frametimeline) diff --git a/docs/testing/package-performance.md b/docs/testing/package-performance.md index d7319d7..cde8e4f 100644 --- a/docs/testing/package-performance.md +++ b/docs/testing/package-performance.md @@ -51,9 +51,11 @@ for an unbuilt replacement. All four builds passed APK signature verification, exact application/version/build checks and the five notification/background receiver checks. This is native build -and package evidence, not a device execution test. No Android device is connected; -the owner selected host measurements first. Mobile install time, first launch, -memory, battery and frame timing remain unmeasured. +and package evidence, not a device execution test. The initial experiment used +host measurements at the owner's request. A subsequently connected Samsung tablet +is covered in the [Android device report](android-device-performance.md), which +separates authenticated runtime checks from isolated fresh-install measurements. +Battery and low-end-phone acceptance remain outside these samples. The package reader also performs three complete sequential reads with CRC checks. These are warm-cache host decompression measurements on separate hosted VMs, not diff --git a/scripts/README.md b/scripts/README.md index 60b2d0f..a47099e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -48,6 +48,8 @@ this directory. Product tests belong in `tests/` so pytest and CI collect them. reads; archive timings are not installation timings. - `measure_windows_install.ps1` — repeated real MSI install/uninstall and native activation smoke checks, restricted to disposable GitHub-hosted Windows runners. +- `measure_android_install.py` — rotated clean-install measurements restricted to + the isolated `com.uthelper.benchmark` package; preserves the real app's profile. - `prepare_package_benchmark.py` — stage cleanup variants in a disposable benchmark checkout, preserving application settings and saving both TOML configurations. diff --git a/scripts/measure_android_install.py b/scripts/measure_android_install.py new file mode 100644 index 0000000..7a19c61 --- /dev/null +++ b/scripts/measure_android_install.py @@ -0,0 +1,158 @@ +"""Measure fresh installs of the isolated benchmark app, preserving UTHelper data.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import time +import uuid + + +PACKAGE = "com.uthelper.benchmark" + + +def package_identity(badging: str) -> tuple[str, str]: + match = re.search(r"^package: name='([^']+)' versionCode='(\d+)'", badging, re.M) + if not match or match[1] != PACKAGE: + raise ValueError("Only com.uthelper.benchmark APKs may be installed or removed") + return match[1], match[2] + + +def launch_fields(output: str) -> dict: + fields = dict(re.findall(r"^(Status|LaunchState|TotalTime|WaitTime):\s*(.+)$", output, re.M)) + if fields.get("Status") != "ok" or not fields.get("TotalTime", "").isdigit(): + raise ValueError("Android did not report a successful measured launch") + return fields + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--serial", required=True) + parser.add_argument("--aapt", type=Path, required=True) + parser.add_argument("--android-cli", type=Path, required=True) + parser.add_argument("--sdk", type=Path, required=True) + parser.add_argument("--universal", type=Path, required=True) + parser.add_argument("--modern", type=Path, required=True) + parser.add_argument("--legacy", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + def run(command: list[str], timeout: int = 120) -> str: + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", timeout=timeout) + if result.returncode: + raise RuntimeError(f"Command failed ({command[0]}): {result.stderr[:300]}") + return result.stdout + + def adb(*parts: str) -> str: + return run(["adb", "-s", args.serial, *parts]) + + if adb("shell", "am", "get-current-user").strip() != "0": + raise RuntimeError("Measure only in the inspected primary profile") + # Refuse existing data even if the package is currently uninstalled for user 0. + prior = adb("shell", "dumpsys", "package", PACKAGE) + if "Unable to find package" not in prior: + raise RuntimeError("Existing benchmark package/data must not be overwritten") + apks = {"universal": args.universal, "modern": args.modern, "legacy": args.legacy} + versions = {} + result = {"schema_version": 1, "package": PACKAGE, "variants": {}, "samples": []} + for name, apk in apks.items(): + _, versions[name] = package_identity(run([str(args.aapt), "dump", "badging", str(apk)])) + with apk.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + result["variants"][name] = {"bytes": apk.stat().st_size, "sha256": digest} + if len(set(versions.values())) != 1: + raise ValueError("All candidates must use the same version code") + result["scope"] = ( + "Primary-user clean installs of an isolated package; staged APK install time excludes USB transfer. " + "am start TotalTime is Android initial display, not Python UI readiness. " + "Login UI is separately verified. No authenticated data is removed." + ) + remote = "/data/local/tmp/uthelper-bench-" + uuid.uuid4().hex + adb("shell", "mkdir", remote) + active = False + staged = [] + try: + for name, apk in apks.items(): + destination = f"{remote}/{name}.apk" + start = time.perf_counter() + adb("push", str(apk), destination) + result["variants"][name]["usb_push_seconds"] = time.perf_counter() - start + staged.append(destination) + # Rotate order to reduce systematic warm-cache/thermal ordering effects. + for round_index, order in enumerate(( + ("universal", "modern", "legacy"), + ("legacy", "universal", "modern"), + ("modern", "legacy", "universal"), + )): + for name in order: + thermal = adb("shell", "dumpsys", "thermalservice") + start = time.perf_counter() + installed = adb("shell", "pm", "install", "--user", "0", "-t", f"{remote}/{name}.apk") + if installed.strip() != "Success": + raise RuntimeError("Clean benchmark installation failed") + active = True + sample = {"round": round_index + 1, "variant": name, + "install_seconds": time.perf_counter() - start} + status = re.search(r"^Thermal Status:\s*(\d+)", thermal, re.M) + sample["thermal_status_before"] = int(status[1]) if status else None + state = adb("shell", "dumpsys", "package", PACKAGE) + code = re.search(r"^\s*codePath=(/data/app/[\w/.~+=-]+)$", state, re.M) + if not code: + raise RuntimeError("Unexpected installed benchmark code path") + # Production Android can hide ART/oat directories from shell. + # Keep a partial scan explicitly bounded, never call it total storage. + disk = subprocess.run( + ["adb", "-s", args.serial, "shell", "du", "-sk", code[1]], + capture_output=True, text=True, encoding="utf-8", timeout=30, + ) + size = re.match(r"(\d+)\s", disk.stdout) + sample["shell_visible_code_kib"] = int(size[1]) if size else None + sample["code_scan_complete"] = disk.returncode == 0 + sample["extract_native_libs"] = "extractNativeLibs=true" in state + adb("shell", "pm", "grant", "--user", "0", PACKAGE, "android.permission.POST_NOTIFICATIONS") + sample["initial_display"] = launch_fields(adb( + "shell", "am", "start", "-W", "--user", "0", "-n", PACKAGE + "/.MainActivity", + )) + time.sleep(7) + # CLI console encoding follows the Windows host locale. Its + # explicit JSON output file is UTF-8 and also retains private + # UI evidence without printing other apps' notification text. + layout_path = args.output.parent / f"layout-{round_index + 1}-{name}.json" + layout_path.parent.mkdir(parents=True, exist_ok=True) + run([ + str(args.android_cli), "--no-metrics", "--sdk", str(args.sdk), + "layout", "--device", args.serial, "--flat", "--output", str(layout_path), + ]) + layout = json.loads(layout_path.read_text(encoding="utf-8")) + if not any( + node.get("content-desc") == "Đăng nhập UTH" + for window in layout if window.get("type") == "APPLICATION" + for node in window.get("content", []) + ): + raise RuntimeError("The Python login UI did not become available") + sample["login_ui_verified"] = True + adb("shell", "am", "force-stop", "--user", "0", PACKAGE) + start = time.perf_counter() + removed = adb("shell", "pm", "uninstall", "--user", "0", "--versionCode", versions[name], PACKAGE) + if removed.strip() != "Success": + raise RuntimeError("Benchmark uninstall failed") + active = False + sample["uninstall_seconds"] = time.perf_counter() - start + result["samples"].append(sample) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps(sample), flush=True) + finally: + if active: + adb("shell", "pm", "uninstall", "--user", "0", "--versionCode", next(iter(versions.values())), PACKAGE) + for path in staged: + adb("shell", "rm", "-f", path) + adb("shell", "rmdir", remote) + + +if __name__ == "__main__": + main() diff --git a/tests/test_measure_android_install.py b/tests/test_measure_android_install.py new file mode 100644 index 0000000..6f406f1 --- /dev/null +++ b/tests/test_measure_android_install.py @@ -0,0 +1,19 @@ +import pytest + +from scripts.measure_android_install import launch_fields, package_identity + + +def test_accepts_isolated_identity_and_successful_native_launch(): + assert package_identity( + "package: name='com.uthelper.benchmark' versionCode='2004001' versionName='2.4.1'" + ) == ("com.uthelper.benchmark", "2004001") + assert launch_fields("Status: ok\nLaunchState: COLD\nTotalTime: 230\nWaitTime: 235\n")[ + "TotalTime" + ] == "230" + + +def test_refuses_user_app_identity_and_unsuccessful_launch(): + with pytest.raises(ValueError, match="Only com.uthelper.benchmark"): + package_identity("package: name='com.uthelper.uthelper' versionCode='2004001'") + with pytest.raises(ValueError, match="successful"): + launch_fields("Status: timeout\nTotalTime: 0\n") From f067c87e737121e310199b83fd51233d958a4988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Wed, 28 Oct 2026 12:57:33 +0700 Subject: [PATCH 11/11] feat(release): ship compressed ARM64 packages and lean Windows installers in 2.5.0 --- .github/workflows/package-benchmark.yml | 6 +-- .github/workflows/release.yml | 54 ++++++++++++++----- README.md | 6 ++- docs/README.md | 2 +- .../adr/0003-signed-release-update-channel.md | 16 ++++-- docs/releases/2.5.0.md | 31 +++++++++++ docs/testing/package-performance.md | 22 +++++--- pyproject.toml | 3 +- scripts/build_android.ps1 | 9 ++-- scripts/release_inventory.py | 6 ++- scripts/verify_android_release.py | 29 +++++++++- src/core/update_manifest.py | 14 +++++ src/platform_utils/update_packages.py | 7 ++- tests/test_release_inventory.py | 10 ++-- tests/test_release_metadata.py | 2 +- tests/test_update_manifest.py | 28 ++++++++++ tests/test_update_packages.py | 15 ++++++ tests/test_verify_android_release.py | 37 ++++++++++--- 18 files changed, 247 insertions(+), 50 deletions(-) create mode 100644 docs/releases/2.5.0.md diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml index c29059f..bccd5d6 100644 --- a/.github/workflows/package-benchmark.yml +++ b/.github/workflows/package-benchmark.yml @@ -40,11 +40,11 @@ jobs: matrix: include: - name: universal-modern - flags: '' + flags: '--no-android-legacy-packaging' - name: universal-clean - flags: '' + flags: '--no-android-legacy-packaging' - name: arm64-modern - flags: '--arch arm64-v8a' + flags: '--arch arm64-v8a --no-android-legacy-packaging' - name: arm64-legacy flags: '--arch arm64-v8a --android-legacy-packaging' steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48662ef..3218460 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,14 @@ jobs: needs: [validate-release-source, validate-release-credentials] runs-on: ubuntu-latest timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + include: + - architecture: universal + suffix: '' + - architecture: arm64 + suffix: '-arm64-v8a' environment: release permissions: contents: read @@ -131,6 +139,8 @@ jobs: ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_KEY_ALIAS: ${{ vars.ANDROID_KEY_ALIAS }} ANDROID_SIGNING_CERT_SHA256: ${{ vars.ANDROID_SIGNING_CERT_SHA256 }} + ARCHITECTURE: ${{ matrix.architecture }} + APK_SUFFIX: ${{ matrix.suffix }} run: | set -euo pipefail test -n "$ANDROID_KEYSTORE_BASE64" @@ -149,13 +159,16 @@ jobs: export FLET_ANDROID_SIGNING_KEY_PASSWORD="$ANDROID_KEY_PASSWORD" export FLET_ANDROID_SIGNING_KEY_ALIAS="$ANDROID_KEY_ALIAS" + set -- --android-legacy-packaging + if [ "$ARCHITECTURE" = arm64 ]; then set -- "$@" --arch arm64-v8a; fi + python scripts/release_metadata.py --pyproject pyproject.toml --runtime-output src/assets/release-version python scripts/generate_public_runtime_config.py --require-configured --sentry-dsn "$SENTRY_DSN" --output src/assets/diagnostics-config.json - flet build apk --build-version "$VERSION" --build-number "$BUILD_NUMBER" --yes --verbose || test -d build/flutter + flet build apk --build-version "$VERSION" --build-number "$BUILD_NUMBER" "$@" --yes --verbose || test -d build/flutter flet-android-notifications-patch --project-root build/flutter python scripts/release_metadata.py --pyproject pyproject.toml --runtime-output src/assets/release-version python scripts/generate_public_runtime_config.py --require-configured --sentry-dsn "$SENTRY_DSN" --output src/assets/diagnostics-config.json - flet build apk --build-version "$VERSION" --build-number "$BUILD_NUMBER" --yes --verbose + flet build apk --build-version "$VERSION" --build-number "$BUILD_NUMBER" "$@" --yes --verbose grep -R -q "androidx.work:work-runtime-ktx:2.11.2" build/flutter-packages/flet_uth_background_sync/android/build.gradle grep -R -q "androidx.room:room-runtime:2.8.4" build/flutter-packages/flet_uth_background_sync/android/build.gradle @@ -182,7 +195,7 @@ jobs: grep -q "RescheduleReceiver" final-android-manifest.xml mkdir -p release evidence - FINAL_APK="release/UTHelper-$VERSION.apk" + FINAL_APK="release/UTHelper-$VERSION$APK_SUFFIX.apk" mv "$APK" "$FINAL_APK" python scripts/verify_android_release.py \ --apk "$FINAL_APK" \ @@ -192,20 +205,21 @@ jobs: --certificate-sha256 "$ANDROID_SIGNING_CERT_SHA256" \ --commit-sha "$GITHUB_SHA" \ --workflow-run-id "$GITHUB_RUN_ID" \ - --output "evidence/UTHelper-$VERSION.apk.verification.json" + --architecture "$ARCHITECTURE" \ + --output "evidence/UTHelper-$VERSION$APK_SUFFIX.apk.verification.json" - name: Attest signed APK uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d timeout-minutes: 5 with: - subject-path: release/UTHelper-${{ needs['validate-release-source'].outputs.version }}.apk + subject-path: release/UTHelper-${{ needs['validate-release-source'].outputs.version }}${{ matrix.suffix }}.apk - name: Upload signed Android package and evidence uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 timeout-minutes: 5 with: - name: release-android + name: release-android-${{ matrix.architecture }} path: | - release/UTHelper-${{ needs['validate-release-source'].outputs.version }}.apk - evidence/UTHelper-${{ needs['validate-release-source'].outputs.version }}.apk.verification.json + release/UTHelper-${{ needs['validate-release-source'].outputs.version }}${{ matrix.suffix }}.apk + evidence/UTHelper-${{ needs['validate-release-source'].outputs.version }}${{ matrix.suffix }}.apk.verification.json if-no-files-found: error retention-days: 7 @@ -498,7 +512,13 @@ jobs: uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 timeout-minutes: 5 with: - name: release-android + name: release-android-universal + path: incoming/android + - name: Download ARM64 Android release artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + timeout-minutes: 5 + with: + name: release-android-arm64 path: incoming/android - name: Download iOS release artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 @@ -545,11 +565,12 @@ jobs: subject-path: | release/UTHelper-${{ needs['validate-release-source'].outputs.version }}.ipa release/UTHelper-${{ needs['validate-release-source'].outputs.version }}.apk + release/UTHelper-${{ needs['validate-release-source'].outputs.version }}-arm64-v8a.apk release/UTHelper-Setup-${{ needs['validate-release-source'].outputs.version }}.exe release/UTHelper-${{ needs['validate-release-source'].outputs.version }}.msi release/release-manifest.json release/SHA256SUMS - - name: Verify attestations and atomically publish six assets + - name: Verify attestations and atomically publish seven assets timeout-minutes: 10 shell: bash env: @@ -561,6 +582,7 @@ jobs: ASSETS=( "release/UTHelper-$VERSION.ipa" "release/UTHelper-$VERSION.apk" + "release/UTHelper-$VERSION-arm64-v8a.apk" "release/UTHelper-Setup-$VERSION.exe" "release/UTHelper-$VERSION.msi" "release/release-manifest.json" @@ -600,13 +622,15 @@ jobs: } trap cleanup_draft EXIT + NOTES=() + if [ -f "docs/releases/$VERSION.md" ]; then NOTES=(-F "body=@docs/releases/$VERSION.md"); fi release_record=$(gh api -X POST "repos/$GITHUB_REPOSITORY/releases" \ -f tag_name="$TAG" \ -f target_commitish="$GITHUB_SHA" \ -f name="UTHelper $VERSION" \ -F draft=true \ -F prerelease=false \ - -F generate_release_notes=true) + -F generate_release_notes=true "${NOTES[@]}") CREATED_RELEASE_ID=$(printf '%s' "$release_record" | jq -er '.id') [[ "$CREATED_RELEASE_ID" =~ ^[0-9]+$ ]] gh api "repos/$GITHUB_REPOSITORY/releases/$CREATED_RELEASE_ID" | \ @@ -615,6 +639,7 @@ jobs: gh release upload "$TAG" \ "release/UTHelper-$VERSION.ipa" \ "release/UTHelper-$VERSION.apk" \ + "release/UTHelper-$VERSION-arm64-v8a.apk" \ "release/UTHelper-Setup-$VERSION.exe" \ "release/UTHelper-$VERSION.msi" \ "release/release-manifest.json" \ @@ -632,6 +657,7 @@ jobs: expected = { f"UTHelper-{version}.ipa", f"UTHelper-{version}.apk", + f"UTHelper-{version}-arm64-v8a.apk", f"UTHelper-Setup-{version}.exe", f"UTHelper-{version}.msi", "release-manifest.json", @@ -640,7 +666,7 @@ jobs: record = json.loads(Path("remote-release.json").read_text(encoding="utf-8")) assets = record.get("assets", []) names = {asset.get("name") for asset in assets} - if names != expected or len(assets) != 6: + if names != expected or len(assets) != 7: raise SystemExit(f"remote draft asset names mismatch: {sorted(names)}") lines = [] for asset in sorted(assets, key=lambda item: item["name"]): @@ -671,8 +697,8 @@ jobs: if hashlib.sha256(path.read_bytes()).digest() != hashlib.sha256(peer.read_bytes()).digest(): raise SystemExit(f"remote digest mismatch: {path.name}") sums = (local / "SHA256SUMS").read_text(encoding="utf-8") - if not sums.endswith("\n") or "\r" in sums or len(sums.splitlines()) != 5: - raise SystemExit("SHA256SUMS is not the exact deterministic five-entry inventory") + if not sums.endswith("\n") or "\r" in sums or len(sums.splitlines()) != 6: + raise SystemExit("SHA256SUMS is not the exact deterministic six-entry inventory") PY gh release edit "$TAG" --draft=false --latest diff --git a/README.md b/README.md index 8dbb3d7..33387fe 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,14 @@ UTHelper là dự án độc lập dành cho sinh viên. Dự án không đại Đại học Giao thông vận tải Thành phố Hồ Chí Minh và không thay thế các cổng thông tin chính thức của trường. -Phiên bản mã nguồn hiện tại là `2.4.1`. UI, runtime package và số build nền tảng +Phiên bản mã nguồn hiện tại là `2.5.0`. UI, runtime package và số build nền tảng đều được sinh từ version canonical trong `pyproject.toml`; không chỉnh version riêng trong từng nền tảng. +Bản Android ARM64 nhẹ dùng tệp `UTHelper-2.5.0-arm64-v8a.apk`; bản universal +`UTHelper-2.5.0.apk` dành cho các kiến trúc còn lại và tương thích cập nhật cũ. +Windows tiếp tục dùng bộ cài EXE/MSI đã ký, với phần đóng gói được tinh gọn. + ## Tính năng | Nhóm | Khả năng | diff --git a/docs/README.md b/docs/README.md index 5d899a5..aa34e04 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,7 @@ This directory separates maintained documentation from historical implementation records. Start here instead of searching by filename. -Version ứng dụng hiện tại: `2.4.1`. `pyproject.toml` là nguồn version canonical; +Version ứng dụng hiện tại: `2.5.0`. `pyproject.toml` là nguồn version canonical; UI và metadata đóng gói được sinh từ nguồn này. ## Maintained documentation diff --git a/docs/adr/0003-signed-release-update-channel.md b/docs/adr/0003-signed-release-update-channel.md index 71dec4e..db808d2 100644 --- a/docs/adr/0003-signed-release-update-channel.md +++ b/docs/adr/0003-signed-release-update-channel.md @@ -2,7 +2,7 @@ - Status: Accepted (iOS and Windows trust model superseded by the 2026-08-09 sideload/self-signed design) - Date: 2026-07-19 -- Updated: 2026-08-09 +- Updated: 2026-10-28 ## Context @@ -17,9 +17,15 @@ stable package identity, deterministic selection, or end-to-end verification. - `pyproject.toml` is the only authored application version. A protected `vX.Y.Z` tag must equal it and point to a commit contained in `main`. -- A public release contains exactly six assets: one unsigned device IPA, signed APK, x64 Burn +- From 2.5.0, a public release contains exactly seven assets: one unsigned device + IPA, signed ARM64 APK, signed universal compatibility APK, x64 Burn EXE, x64 machine-scoped MSI, `release-manifest.json`, and `SHA256SUMS`. MSIX, AppInstaller, ZIP, and GitHub Pages are not production release paths. +- Android ARM64 processes select the ARM64 APK. Unknown/32-bit/x86 runtimes and + older clients retain the canonical universal filename/manifest target. An ARM64 + client falls back to universal only when an older manifest has no exact ARM64 + candidate. Both APKs use native compression, the same package ID, version code + and pinned production signer. Native ABI inventories are verified independently. - Schema 3 identifies platform, architecture, package type, install channel, trusted signer identity/fingerprint, byte size, SHA-256, and the explicit install strategy. It declares iOS as `unsigned-resign-required`, Android as @@ -40,10 +46,10 @@ stable package identity, deterministic selection, or end-to-end verification. with Sideloadly/AltStore, and iOS never self-installs it. - Each native release job builds, verifies, emits SHA-bound evidence, and creates a GitHub artifact attestation. The final protected job reconstructs - only the three named current-run artifacts and verifies the exact inventory. -- Publication is a fail-closed transaction: attest all six files, verify every + only the four named current-run artifacts and verifies the exact inventory. +- Publication is a fail-closed transaction: attest all seven files, verify every attestation against `.github/workflows/release.yml`, create an empty draft, - upload six explicitly named files, re-download them through the GitHub API, + upload seven explicitly named files, re-download them through the GitHub API, compare names, sizes, API digests, local hashes, and checksum contents, then make the draft public. Cleanup may delete only the same numeric draft ID while its tag and draft state still match; it never deletes by tag or rewrites a tag. diff --git a/docs/releases/2.5.0.md b/docs/releases/2.5.0.md new file mode 100644 index 0000000..c1586c9 --- /dev/null +++ b/docs/releases/2.5.0.md @@ -0,0 +1,31 @@ +# UTHelper 2.5.0 + +## Tải xuống + +- [Android ARM64 — bản nhẹ](https://github.com/Chouwzi/UTHelper/releases/download/v2.5.0/UTHelper-2.5.0-arm64-v8a.apk) +- [Android universal — tương thích ARM32/x86-64](https://github.com/Chouwzi/UTHelper/releases/download/v2.5.0/UTHelper-2.5.0.apk) +- [Windows EXE](https://github.com/Chouwzi/UTHelper/releases/download/v2.5.0/UTHelper-Setup-2.5.0.exe) +- [Windows MSI](https://github.com/Chouwzi/UTHelper/releases/download/v2.5.0/UTHelper-2.5.0.msi) +- [iOS IPA — cần tự ký](https://github.com/Chouwzi/UTHelper/releases/download/v2.5.0/UTHelper-2.5.0.ipa) + +## Thay đổi + +Android có thêm gói ARM64 nén thư viện native, dựa trên bản thử khoảng 35 MiB đã +kiểm tra đăng nhập, cuộn, lịch, bảng điểm và refresh trên Samsung SM-X826B. Gói +universal giữ tên cũ và cũng được nén để các bản đang cài tiếp tục cập nhật được. +Ứng dụng mới ưu tiên gói ARM64 trên process ARM64 và dùng universal khi cần +tương thích. Nén native giảm dung lượng tải, đồng thời cần giải nén thư viện khi cài. + +Windows bỏ file trợ giúp PyWin32 không dùng khi chạy app và dọn cache đóng gói, +giữ nguyên các thư viện runtime, chữ ký, kênh MSI và mức nén CAB cao. Bản thử MSI +giảm từ khoảng 47,97 xuống 45,68 MiB; dung lượng bản phát hành được xác minh từ +tệp build thực tế. iOS giữ nguyên cách phân phối IPA để người dùng tự ký. + +Các gói Android được kiểm tra đúng ABI, package ID, versionCode, receiver thông +báo và chữ ký. Manifest và SHA256SUMS bao phủ cả hai APK, EXE, MSI và IPA. Bản +phát hành không dùng APK benchmark hoặc chữ ký debug. + +Số đo và giới hạn thử nghiệm nằm trong +[báo cáo host](../testing/package-performance.md) và +[báo cáo thiết bị Android](../testing/android-device-performance.md). Chưa có bằng +chứng nhất quán rằng các gói nhỏ hơn cài nhanh hơn trên mọi thiết bị. diff --git a/docs/testing/package-performance.md b/docs/testing/package-performance.md index cde8e4f..69ffa3c 100644 --- a/docs/testing/package-performance.md +++ b/docs/testing/package-performance.md @@ -1,5 +1,15 @@ # Package size and installation benchmark +## Release adoption (2.5.0) + +The owner selected the tested smaller Android profile for release. Starting at +2.5.0, release builds emit a compressed ARM64 APK plus a compressed universal +compatibility APK under its existing filename. The updater prefers ARM64 on +64-bit ARM processes and retains universal compatibility for older clients and +other architectures. Windows uses application cleanup and the tested `.chm` +removal while retaining high CAB compression. Historical 2.4.1 measurements below +remain the baseline; new release sizes are verified from the built assets. + ## Scope and provenance The experiment keeps the UTHelper 2.4.1 application, dependencies, native plugins, @@ -141,7 +151,8 @@ artifacts and the operator's private baseline directory. - Remove only Windows package help files with the tested `**.chm` glob in `[tool.flet.windows.cleanup]`; keep all runtime dependencies. - Expose explicit Android architecture/legacy/output-directory options in the - existing patched build script. Universal and modern packaging remain defaults. + existing patched build script. The subsequently selected release defaults are + described above; diagnostic modern profiles explicitly disable compression. - Keep Windows high CAB compression. Do not remove Pillow, lxml, SSL, Pydantic, Python runtime or notification components to manufacture a smaller result. - Do not change the production updater or publish benchmark artifacts as releases. @@ -164,7 +175,7 @@ used instead. No generated Dart source is edited. Local Android candidates use the same required two-pass notification patch path: ```powershell -./scripts/build_android.ps1 -Architecture arm64-v8a -OutputDir build/apk-arm64-modern +./scripts/build_android.ps1 -Architecture arm64-v8a -LegacyPackaging:$false -OutputDir build/apk-arm64-modern ./scripts/build_android.ps1 -Architecture arm64-v8a -LegacyPackaging -OutputDir build/apk-arm64-legacy ``` @@ -179,10 +190,9 @@ directories after producing adjacent runtime bytecode; never recursively delete all `.pyc` files. Do not edit generated Dart code or re-zip a production APK to implement these changes: build from the authored configuration and verify again. -The release inventory and updater currently require a universal APK. Adding -architecture-specific production assets is a separate coordinated change to -inventory, runtime selection, signature verification and compatibility fallback; -the benchmark does not silently replace that working update path. +The 2.5.0 release adoption coordinates the additional ARM64 asset with inventory, +runtime selection, native ABI/signature verification and universal compatibility. +No older published asset is replaced. ## Supporting sources diff --git a/pyproject.toml b/pyproject.toml index 0b37046..7368e24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "uthelper" -version = "2.4.1" +version = "2.5.0" description = "UTHelper — Ứng dụng theo dõi bài tập và deadline UTH E-learning" readme = "README.md" requires-python = ">=3.11" @@ -83,6 +83,7 @@ package_files = ["**.chm"] # ── Android-specific configuration ── [tool.flet.android] +legacy_packaging = true bundle_id = "com.uthelper.uthelper" adaptive_icon_background = "#0F172A" dependencies = [ diff --git a/scripts/build_android.ps1 b/scripts/build_android.ps1 index 05cc246..1c4c783 100644 --- a/scripts/build_android.ps1 +++ b/scripts/build_android.ps1 @@ -2,8 +2,8 @@ param( [ValidateSet("apk", "aab")] [string]$Target = "apk", [ValidateSet("universal", "arm64-v8a", "armeabi-v7a", "x86_64")] - [string]$Architecture = "universal", - [switch]$LegacyPackaging, + [string]$Architecture = "arm64-v8a", + [switch]$LegacyPackaging = $true, [string]$OutputDir ) @@ -12,11 +12,12 @@ $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $fletCommand = Get-Command flet -ErrorAction Stop $patchCommand = Get-Command flet-android-notifications-patch -ErrorAction Stop -# Preserve universal compatibility unless a device architecture is explicitly -# selected. Legacy packaging trades download size for extraction at install time. +# Default to the measured small ARM64 build. Universal remains available for +# compatibility. Legacy packaging extracts native libraries during installation. $fletOptions = @("--cleanup-app") if ($Architecture -ne "universal") { $fletOptions += @("--arch", $Architecture) } if ($LegacyPackaging) { $fletOptions += "--android-legacy-packaging" } +else { $fletOptions += "--no-android-legacy-packaging" } if ($OutputDir) { $fletOptions += @("--output", $OutputDir) } $env:PYTHONIOENCODING = "utf-8" diff --git a/scripts/release_inventory.py b/scripts/release_inventory.py index 66ddfcc..b798d4e 100644 --- a/scripts/release_inventory.py +++ b/scripts/release_inventory.py @@ -29,6 +29,7 @@ REQUIRED_PACKAGE_NAMES = ( "UTHelper-{version}.ipa", "UTHelper-{version}.apk", + "UTHelper-{version}-arm64-v8a.apk", "UTHelper-Setup-{version}.exe", "UTHelper-{version}.msi", ) @@ -76,6 +77,7 @@ frozenset( { "apk_signature", + "native_abis", "notification_receivers", "package_id", "sha256", @@ -188,6 +190,8 @@ def _read_evidence(path: Path, package: Path, version: str) -> VerificationEvide raise InventoryError("verification evidence fields are invalid") suffix = package.suffix.lower() platform_name, product_id, architecture, expected_checks = _EXPECTED[suffix] + if suffix == ".apk" and package.name == f"UTHelper-{version}-arm64-v8a.apk": + architecture = "arm64" checks = raw["checks"] if ( not isinstance(checks, list) @@ -260,7 +264,7 @@ def _validate_manifest(path: Path, inventory: ReleaseInventory) -> None: manifest = parse_manifest(raw, expected_release_version=inventory.version) except (OSError, UnicodeError, json.JSONDecodeError, ManifestError) as exc: raise InventoryError("release manifest is invalid") from exc - if manifest.schema_version != 3 or len(manifest.packages) != 4: + if manifest.schema_version != 3 or len(manifest.packages) != len(REQUIRED_PACKAGE_NAMES): raise InventoryError("release manifest must contain schema 3 exact inventory") expected_by_name = {item.name: item for item in inventory.packages} seen = set() diff --git a/scripts/verify_android_release.py b/scripts/verify_android_release.py index e0c0a1a..1a05eca 100644 --- a/scripts/verify_android_release.py +++ b/scripts/verify_android_release.py @@ -11,6 +11,7 @@ import subprocess from typing import Sequence import xml.etree.ElementTree as ET +import zipfile try: from scripts.release_metadata import ReleaseMetadataError, release_build_number @@ -104,9 +105,13 @@ def verify_android_release( workflow_run_id: str, output: Path, android_home: Path | None = None, + architecture: str = "universal", ) -> dict[str, object]: apk = apk.resolve() - if not apk.is_file() or apk.name != f"UTHelper-{version}.apk": + suffix = "-arm64-v8a" if architecture == "arm64" else "" + if architecture not in {"universal", "arm64"}: + raise AndroidVerificationError("Unsupported Android architecture") + if not apk.is_file() or apk.name != f"UTHelper-{version}{suffix}.apk": raise AndroidVerificationError("Android release APK filename is not canonical") if not _VERSION.fullmatch(version) or build_number <= 0: raise AndroidVerificationError("Android release version metadata is invalid") @@ -168,6 +173,23 @@ def verify_android_release( if invalid_receivers: raise AndroidVerificationError("APK notification receiver wiring is incomplete") + try: + with zipfile.ZipFile(apk) as archive: + abis = { + name.split("/")[1] + for name in archive.namelist() + if name.startswith("lib/") and name.endswith(".so") + and len(name.split("/")) == 3 + } + except zipfile.BadZipFile as exc: + raise AndroidVerificationError("APK native library archive is invalid") from exc + expected_abis = ( + {"arm64-v8a"} if architecture == "arm64" + else {"arm64-v8a", "armeabi-v7a", "x86_64"} + ) + if abis != expected_abis: + raise AndroidVerificationError("APK native ABI inventory does not match architecture") + with apk.open("rb") as stream: apk_sha256 = hashlib.file_digest(stream, "sha256").hexdigest() evidence: dict[str, object] = { @@ -177,7 +199,7 @@ def verify_android_release( "sha256": apk_sha256, "version": version, "product_id": package_id, - "architecture": "universal", + "architecture": architecture, "signature_kind": "apk-pinned", "signer_identity": package_id, "certificate_fingerprint": expected_fingerprint, @@ -186,6 +208,7 @@ def verify_android_release( "checks": sorted( ( "apk_signature", + "native_abis", "package_id", "version_name", "version_code", @@ -217,6 +240,7 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--commit-sha", required=True) parser.add_argument("--workflow-run-id", required=True) parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--architecture", choices=("universal", "arm64"), default="universal") return parser @@ -231,6 +255,7 @@ def main(argv: Sequence[str] | None = None) -> int: commit_sha=args.commit_sha, workflow_run_id=args.workflow_run_id, output=args.output, + architecture=args.architecture, ) return 0 diff --git a/src/core/update_manifest.py b/src/core/update_manifest.py index 390eba3..913284d 100644 --- a/src/core/update_manifest.py +++ b/src/core/update_manifest.py @@ -453,6 +453,20 @@ def select_candidate( ) if len(matches) > 1: raise ManifestError("ambiguous package candidates") + if ( + not matches and target.platform.lower() == "android" + and target.architecture.lower() == "arm64" + ): + # Older releases and clients retain the canonical universal APK contract. + matches = tuple( + package + for package in manifest.packages + if package.platform == "android" + and package.architecture == "universal" + and package.install_channel == target.install_channel.lower() + ) + if len(matches) > 1: + raise ManifestError("ambiguous package candidates") if not matches: return None package = matches[0] diff --git a/src/platform_utils/update_packages.py b/src/platform_utils/update_packages.py index 5b43434..2df162c 100644 --- a/src/platform_utils/update_packages.py +++ b/src/platform_utils/update_packages.py @@ -3,7 +3,9 @@ from __future__ import annotations import hashlib +import platform from pathlib import Path +import struct from typing import Protocol import platform_utils @@ -76,7 +78,10 @@ def detect_runtime_target() -> RuntimeTarget: return detect_windows_runtime_target() if platform_utils.IS_ANDROID: - return RuntimeTarget("android", "universal", "sideload") + arm64 = struct.calcsize("P") == 8 and platform.machine().lower() in { + "aarch64", "arm64", "arm64-v8a", + } + return RuntimeTarget("android", "arm64" if arm64 else "universal", "sideload") if platform_utils.IS_IOS: return RuntimeTarget("ios", "arm64", "sideload") return RuntimeTarget("other", "unknown", "source") diff --git a/tests/test_release_inventory.py b/tests/test_release_inventory.py index 214fb13..085fef8 100644 --- a/tests/test_release_inventory.py +++ b/tests/test_release_inventory.py @@ -41,6 +41,7 @@ ], ".apk": [ "apk_signature", + "native_abis", "notification_receivers", "package_id", "sha256", @@ -78,7 +79,7 @@ def write_valid_release(tmp_path, *, version=VERSION, with_manifest=False, with_ "sha256": _sha(path), "version": version, "product_id": PRODUCT[path.suffix], - "architecture": ARCH[path.suffix], + "architecture": "arm64" if name.endswith("-arm64-v8a.apk") else ARCH[path.suffix], "signature_kind": SIGNATURE_KIND[path.suffix], "signer_identity": "" if path.suffix == ".ipa" else PRODUCT[path.suffix], "certificate_fingerprint": "" if path.suffix == ".ipa" else "AB" * 32, @@ -114,13 +115,14 @@ def write_valid_release(tmp_path, *, version=VERSION, with_manifest=False, with_ return release, evidence -def test_exact_inventory_accepts_four_required_packages_and_matching_evidence(tmp_path): +def test_exact_inventory_accepts_five_required_packages_and_matching_evidence(tmp_path): release, evidence = write_valid_release(tmp_path) inventory = verify_release_inventory(release, evidence, VERSION, REPOSITORY) assert [item.name for item in inventory.packages] == [ "UTHelper-2.2.0.ipa", "UTHelper-2.2.0.apk", + "UTHelper-2.2.0-arm64-v8a.apk", "UTHelper-Setup-2.2.0.exe", "UTHelper-2.2.0.msi", ] @@ -222,7 +224,7 @@ def test_sha256sums_is_deterministic_and_covers_packages_plus_manifest(tmp_path) assert output.read_bytes() == first assert first.endswith(b"\n") and b"\r\n" not in first assert b"SHA256SUMS" not in first - assert len(first.decode("ascii").splitlines()) == 5 + assert len(first.decode("ascii").splitlines()) == 6 @pytest.mark.parametrize("mutation", ["missing", "extra", "tampered", "traversal"]) @@ -263,7 +265,7 @@ def test_generator_emits_schema_three_with_manual_ios_sideload(tmp_path): ) assert manifest["schema_version"] == 3 - assert len(manifest["packages"]) == 4 + assert len(manifest["packages"]) == 5 ios = manifest["packages"][0] assert ios["install_channel"] == "sideload" assert ios["install_strategy"] == {"kind": "manual_sideload"} diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index b5ae7c5..f05d64e 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -25,7 +25,7 @@ def test_project_version_is_only_authored_version(tmp_path): def test_this_feature_release_bumps_the_single_authored_version(): - assert read_project_version(ROOT / "pyproject.toml") == "2.4.1" + assert read_project_version(ROOT / "pyproject.toml") == "2.5.0" def test_runtime_version_is_generated_from_the_single_authored_version(tmp_path): diff --git a/tests/test_update_manifest.py b/tests/test_update_manifest.py index a8b94d4..1806954 100644 --- a/tests/test_update_manifest.py +++ b/tests/test_update_manifest.py @@ -65,6 +65,34 @@ def _schema3_package(**changes): return value +@pytest.mark.parametrize("target_arch,expected_arch", [("arm64", "arm64"), ("universal", "universal")]) +def test_android_prefers_exact_abi_without_breaking_existing_clients(target_arch, expected_arch): + packages = [ + _schema3_package( + platform="android", architecture=arch, package_type="apk", install_channel="sideload", + signature_kind="apk-pinned", install_strategy={"kind": "android_package_installer"}, + url=f"https://github.com/Chouwzi/UTHelper/releases/download/v2.2.0/{arch}.apk", + ) for arch in ("universal", "arm64") + ] + document = _schema3(packages[0]) + document["packages"] = packages + manifest = parse_manifest(document, expected_release_version="2.2.0") + candidate = select_candidate(manifest, current_version="2.1.0", target=RuntimeTarget("android", target_arch, "sideload")) + assert candidate.package.architecture == expected_arch + + +def test_arm64_client_can_still_update_from_older_universal_only_manifest(): + package = _schema3_package( + platform="android", architecture="universal", package_type="apk", install_channel="sideload", + signature_kind="apk-pinned", install_strategy={"kind": "android_package_installer"}, + url="https://github.com/Chouwzi/UTHelper/releases/download/v2.2.0/universal.apk", + ) + manifest = parse_manifest(_schema3(package), expected_release_version="2.2.0") + candidate = select_candidate(manifest, current_version="2.1.0", target=RuntimeTarget("android", "arm64", "sideload")) + assert candidate.package.architecture == "universal" + assert select_candidate(manifest, current_version="2.1.0", target=RuntimeTarget("android", "arm64", "other")) is None + + def test_schema2_selects_only_exact_runtime_target(): manifest = parse_manifest( _schema2([_package()]), diff --git a/tests/test_update_packages.py b/tests/test_update_packages.py index 7988c17..a53104e 100644 --- a/tests/test_update_packages.py +++ b/tests/test_update_packages.py @@ -31,6 +31,21 @@ ) +@pytest.mark.parametrize("machine,bits,architecture", [ + ("aarch64", 8, "arm64"), ("arm64-v8a", 8, "arm64"), + ("aarch64", 4, "universal"), ("armv7l", 4, "universal"), + ("x86_64", 8, "universal"), ("unknown", 8, "universal"), +]) +def test_android_target_uses_small_apk_only_for_64_bit_arm(monkeypatch, machine, bits, architecture): + from platform_utils import update_packages + + monkeypatch.setattr(update_packages.platform_utils, "IS_WINDOWS", False) + monkeypatch.setattr(update_packages.platform_utils, "IS_ANDROID", True) + monkeypatch.setattr(update_packages.platform, "machine", lambda: machine) + monkeypatch.setattr(update_packages.struct, "calcsize", lambda _: bits) + assert detect_runtime_target() == RuntimeTarget("android", architecture, "sideload") + + def test_compiled_windows_release_pin_is_exact_sha256_identity(): assert TRUSTED_WINDOWS_SIGNER_SHA256 == frozenset( {"7E3547EE6A31325A47BE22049E238BA83CA1D90AFB8A30D053060D02678A0B3C"} diff --git a/tests/test_verify_android_release.py b/tests/test_verify_android_release.py index 9124abc..a4696cf 100644 --- a/tests/test_verify_android_release.py +++ b/tests/test_verify_android_release.py @@ -4,6 +4,7 @@ import os from pathlib import Path import subprocess +import zipfile import pytest @@ -17,6 +18,29 @@ COMMIT_SHA = "1" * 40 +def _write_apk(path: Path, abis=("arm64-v8a", "armeabi-v7a", "x86_64")): + with zipfile.ZipFile(path, "w") as archive: + for abi in abis: + archive.writestr(f"lib/{abi}/libapp.so", b"native-fixture") + + +@pytest.mark.parametrize("abis,valid", [(("arm64-v8a",), True), (("x86_64",), False), (("arm64-v8a", "x86_64"), False)]) +def test_arm64_release_requires_exact_native_abi_inventory(tmp_path, monkeypatch, abis, valid): + apk = tmp_path / f"UTHelper-{VERSION}-arm64-v8a.apk" + _write_apk(apk, abis) + monkeypatch.setattr(subprocess, "run", _fake_run) + options = dict( + apk=apk, version=VERSION, build_number=BUILD_NUMBER, package_id=PACKAGE_ID, + certificate_sha256=FINGERPRINT, commit_sha=COMMIT_SHA, workflow_run_id="12345", + output=tmp_path / "evidence.json", android_home=_sdk(tmp_path), architecture="arm64", + ) + if valid: + assert verify_android_release(**options)["architecture"] == "arm64" + else: + with pytest.raises(AndroidVerificationError, match="native ABI"): + verify_android_release(**options) + + def _sdk(tmp_path: Path) -> Path: sdk = tmp_path / "android-sdk" for path in ( @@ -62,7 +86,7 @@ def _fake_run(command, **kwargs): def test_verify_android_release_emits_exact_native_evidence(tmp_path, monkeypatch): apk = tmp_path / f"UTHelper-{VERSION}.apk" - apk.write_bytes(b"PK\x03\x04signed-apk") + _write_apk(apk) output = tmp_path / "evidence.json" monkeypatch.setattr(subprocess, "run", _fake_run) @@ -85,6 +109,7 @@ def test_verify_android_release_emits_exact_native_evidence(tmp_path, monkeypatc assert evidence["certificate_fingerprint"] == FINGERPRINT assert evidence["checks"] == [ "apk_signature", + "native_abis", "notification_receivers", "package_id", "sha256", @@ -114,7 +139,7 @@ def test_verify_android_release_rejects_non_apk_magic(tmp_path, bad_bytes): def test_verify_android_release_rejects_wrong_signer(tmp_path, monkeypatch): apk = tmp_path / f"UTHelper-{VERSION}.apk" - apk.write_bytes(b"PK\x03\x04signed-apk") + _write_apk(apk) def wrong_signer(command, **kwargs): result = _fake_run(command, **kwargs) @@ -149,7 +174,7 @@ def test_verify_android_release_accepts_supported_apksigner_labels( tmp_path, monkeypatch, certificate_line ): apk = tmp_path / f"UTHelper-{VERSION}.apk" - apk.write_bytes(b"PK\x03\x04signed-apk") + _write_apk(apk) def labelled_signer(command, **kwargs): result = _fake_run(command, **kwargs) @@ -177,7 +202,7 @@ def test_verify_android_release_rejects_any_additional_signer_identity( tmp_path, monkeypatch ): apk = tmp_path / f"UTHelper-{VERSION}.apk" - apk.write_bytes(b"PK\x03\x04signed-apk") + _write_apk(apk) def mixed_signers(command, **kwargs): result = _fake_run(command, **kwargs) @@ -205,7 +230,7 @@ def mixed_signers(command, **kwargs): def test_verify_android_release_rejects_noncanonical_build_number(tmp_path): apk = tmp_path / f"UTHelper-{VERSION}.apk" - apk.write_bytes(b"PK\x03\x04signed-apk") + _write_apk(apk) with pytest.raises(AndroidVerificationError, match="canonical"): verify_android_release( @@ -225,7 +250,7 @@ def test_verify_android_release_rejects_receiver_names_outside_receiver_elements tmp_path, monkeypatch ): apk = tmp_path / f"UTHelper-{VERSION}.apk" - apk.write_bytes(b"PK\x03\x04signed-apk") + _write_apk(apk) def misleading_manifest(command, **kwargs): result = _fake_run(command, **kwargs)