diff --git a/.github/workflows/package-benchmark.yml b/.github/workflows/package-benchmark.yml new file mode 100644 index 0000000..bccd5d6 --- /dev/null +++ b/.github/workflows/package-benchmark.yml @@ -0,0 +1,256 @@ +name: Package benchmark + +on: + workflow_dispatch: + inputs: + platform: + description: Platform group to measure + 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: '' + windows_profile: + description: Windows comparison group + 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' }}-${{ inputs.android_namespace || 'original' }} + cancel-in-progress: true + +jobs: + android: + if: inputs.platform == 'all' || inputs.platform == 'android' + runs-on: ubuntu-latest + timeout-minutes: 55 + strategy: + fail-fast: false + matrix: + include: + - name: universal-modern + flags: '--no-android-legacy-packaging' + - name: universal-clean + flags: '--no-android-legacy-packaging' + - name: arm64-modern + flags: '--arch arm64-v8a --no-android-legacy-packaging' + - name: arm64-legacy + flags: '--arch arm64-v8a --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 }} + 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]" + 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 + 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 --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 --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 + 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")" = "$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 + 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: + if: inputs.platform == 'all' || inputs.platform == 'ios' + runs-on: macos-latest + timeout-minutes: 65 + strategy: + fail-fast: false + matrix: + include: + - name: baseline + flags: '' + - name: cleanup + flags: '' + 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 }} + 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 + 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 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 + 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: + if: inputs.platform == 'all' || inputs.platform == 'windows' + runs-on: windows-latest + timeout-minutes: 65 + env: + PYTHONUTF8: '1' + PYTHONIOENCODING: utf-8 + WIX_EULA_ACCEPTED: ${{ inputs.wix_eula }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.windows_profile == 'documentation-only' && '[{"name":"documentation"}]' || '[{"name":"baseline"},{"name":"cleanup"}]') }} + 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: + 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 + 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' } + 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' } + 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/.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 a1709e5..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 @@ -23,6 +23,10 @@ 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. +- [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/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/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-results.json b/docs/testing/package-performance-results.json new file mode 100644 index 0000000..3508070 --- /dev/null +++ b/docs/testing/package-performance-results.json @@ -0,0 +1,434 @@ +{ + "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" + }, + { + "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, + "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, + "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..69ffa3c --- /dev/null +++ b/docs/testing/package-performance.md @@ -0,0 +1,201 @@ +# 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, +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`. +- 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 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 +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% | + +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. 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 +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 | +| 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 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 +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 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 +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. +- 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. 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. + +## 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. + +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 +./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 +``` + +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 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 + +- [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..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" @@ -56,12 +56,16 @@ 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 jni = "1.1.0" -# ── Android-specific configuration ── +# ── Windows-specific configuration ── [tool.flet.windows] dependencies = [ "pystray>=0.19.5", @@ -73,7 +77,13 @@ 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] +legacy_packaging = true bundle_id = "com.uthelper.uthelper" adaptive_icon_background = "#0F172A" dependencies = [ diff --git a/scripts/README.md b/scripts/README.md index bc084e8..a47099e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -44,6 +44,18 @@ 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. +- `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. + +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..1c4c783 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 = "arm64-v8a", + [switch]$LegacyPackaging = $true, + [string]$OutputDir ) $ErrorActionPreference = "Stop" @@ -8,6 +12,14 @@ $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $fletCommand = Get-Command flet -ErrorAction Stop $patchCommand = Get-Command flet-android-notifications-patch -ErrorAction Stop +# 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" $env:PYTHONUTF8 = "1" # Kotlin incremental caches cannot relativize Pub cache sources on C: against @@ -66,7 +78,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 +98,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_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/scripts/measure_package.py b/scripts/measure_package.py new file mode 100644 index 0000000..2026820 --- /dev/null +++ b/scripts/measure_package.py @@ -0,0 +1,117 @@ +"""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 +import os +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(), + "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}) + 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..23a8c22 --- /dev/null +++ b/scripts/measure_windows_install.ps1 @@ -0,0 +1,94 @@ +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. + $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 + $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" } + $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" + 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) + 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/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/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_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_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") 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) 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} diff --git a/tests/test_release_hardening.py b/tests/test_release_hardening.py index a74cc9a..6d5e01a 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) @@ -255,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(): @@ -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", 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)