Merge branch 'ci/release-apk-and-jni-name-guard'
Add a CI job that builds the release APK and verifies the names JNI resolves survived R8, so the broken-release-APK failure mode cannot recur silently. CI's existing failures are unrelated to workflow content: every run has failed at actions/checkout because the runner's job containers cannot resolve the 'gitea' hostname. That needs container.network set in the runner's config.yaml. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
flutter-version: '3.47.1'
|
flutter-version: '3.47.1'
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
- name: Get dependencies
|
- name: Get dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
@@ -24,5 +25,45 @@ jobs:
|
|||||||
- name: Analyze
|
- name: Analyze
|
||||||
run: flutter analyze
|
run: flutter analyze
|
||||||
|
|
||||||
|
# Includes test/architecture_test.dart, which fails the build if a layer
|
||||||
|
# boundary is crossed.
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: flutter test
|
run: flutter test
|
||||||
|
|
||||||
|
release-apk:
|
||||||
|
name: Release APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.47.1'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
# The debug build does not minify, so it cannot catch an R8 problem. This
|
||||||
|
# job exists because a release APK that aborted on every test shipped
|
||||||
|
# once while the debug build passed every check.
|
||||||
|
- name: Build release APK
|
||||||
|
run: flutter build apk --release
|
||||||
|
|
||||||
|
# Building is not enough on its own: a renamed JNI callback still
|
||||||
|
# produces a successful build and a broken APK. This reads R8's mapping
|
||||||
|
# and the DEX string pool to confirm the names JNI resolves survived.
|
||||||
|
- name: Check JNI names survived minification
|
||||||
|
run: ./tool/check_jni_keep_rules.sh
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: app-release-apk
|
||||||
|
path: build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
|||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Assert that R8 left the names JNI resolves at runtime alone.
|
||||||
|
#
|
||||||
|
# iperf_jni.c looks up the callback methods by name with GetMethodID. R8 renames
|
||||||
|
# by default, and a renamed callback does not fail the build — it produces an
|
||||||
|
# APK that aborts on the first callback of every test:
|
||||||
|
#
|
||||||
|
# NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V"
|
||||||
|
#
|
||||||
|
# That shipped once. Building the release APK is not enough to catch it, because
|
||||||
|
# the build succeeds; this reads R8's own mapping file and checks the names
|
||||||
|
# survived. Run after `flutter build apk --release`.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MAPPING="${1:-build/app/outputs/mapping/release/mapping.txt}"
|
||||||
|
APK="${2:-build/app/outputs/flutter-apk/app-release.apk}"
|
||||||
|
|
||||||
|
if [[ ! -f "$MAPPING" ]]; then
|
||||||
|
echo "FAIL: no R8 mapping at $MAPPING"
|
||||||
|
echo " Run 'flutter build apk --release' first. If the release build"
|
||||||
|
echo " no longer minifies, this check is meaningless — say so"
|
||||||
|
echo " explicitly rather than deleting it."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
# A class kept by name maps to itself: "a.b.C -> a.b.C:".
|
||||||
|
require_class_kept() {
|
||||||
|
local fqcn="$1"
|
||||||
|
if grep -qF -- "$fqcn -> $fqcn:" "$MAPPING"; then
|
||||||
|
echo " ok class $fqcn"
|
||||||
|
else
|
||||||
|
echo " FAIL class $fqcn was renamed or removed"
|
||||||
|
grep -F -- "$fqcn ->" "$MAPPING" | head -3 | sed 's/^/ got: /' || true
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# A method kept by name ends with "-> <name>".
|
||||||
|
require_method_kept() {
|
||||||
|
local signature="$1" name="$2"
|
||||||
|
if grep -qE -- "$signature.*-> ${name}\$" "$MAPPING"; then
|
||||||
|
echo " ok method $name"
|
||||||
|
else
|
||||||
|
echo " FAIL method $name was renamed"
|
||||||
|
grep -E -- "$signature" "$MAPPING" | head -3 | sed 's/^/ got: /' || true
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Checking JNI-visible names in $MAPPING"
|
||||||
|
|
||||||
|
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner'
|
||||||
|
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback'
|
||||||
|
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner$MainThreadCallback'
|
||||||
|
|
||||||
|
# Resolved by iperf_jni.c on the concrete callback object's class.
|
||||||
|
require_method_kept 'void onOutput\(java\.lang\.String\)' 'onOutput'
|
||||||
|
require_method_kept 'void onError\(java\.lang\.String\)' 'onError'
|
||||||
|
require_method_kept 'void onComplete\(\)' 'onComplete'
|
||||||
|
|
||||||
|
# Native methods are NOT checked against mapping.txt: R8 only emits entries for
|
||||||
|
# members it has line-number info or a rename for, and a native method has no
|
||||||
|
# body, so it is absent whether or not it was renamed. Absence would prove
|
||||||
|
# nothing. Check the DEX string pool instead — a surviving name is interned
|
||||||
|
# there, a renamed one is not.
|
||||||
|
check_dex_names() {
|
||||||
|
local apk="$1"
|
||||||
|
shift
|
||||||
|
if [[ ! -f "$apk" ]]; then
|
||||||
|
echo " skip DEX name check: no APK at $apk"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' RETURN
|
||||||
|
if ! unzip -o -q "$apk" 'classes*.dex' -d "$tmp" 2>/dev/null; then
|
||||||
|
echo " FAIL could not extract classes*.dex from $apk"
|
||||||
|
failed=1
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Dump the string pool once to a file, then grep the file. Never
|
||||||
|
# `... | strings | grep -q`: grep -q exits at the first match, SIGPIPEs
|
||||||
|
# strings, and `set -o pipefail` reports the whole pipeline as failed — so
|
||||||
|
# a name found early "fails" and one found late passes. That flakiness cost
|
||||||
|
# an afternoon; leave the intermediate file in place.
|
||||||
|
cat "$tmp"/classes*.dex | strings > "$tmp/dex-strings.txt"
|
||||||
|
|
||||||
|
local name
|
||||||
|
for name in "$@"; do
|
||||||
|
if grep -qxF -- "$name" "$tmp/dex-strings.txt"; then
|
||||||
|
echo " ok native $name"
|
||||||
|
else
|
||||||
|
echo " FAIL native $name is absent from the DEX string pool"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dex_names "${2:-build/app/outputs/flutter-apk/app-release.apk}" \
|
||||||
|
runIperfLive forceStopIperfTest setTempDir isTestRunning
|
||||||
|
|
||||||
|
if [[ $failed -ne 0 ]]; then
|
||||||
|
echo
|
||||||
|
echo "R8 renamed something JNI resolves by name. The release APK will abort"
|
||||||
|
echo "on the first callback. Fix android/app/proguard-rules.pro."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "All JNI-visible names survived minification."
|
||||||
Reference in New Issue
Block a user