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:
|
||||
flutter-version: '3.47.1'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
@@ -24,5 +25,45 @@ jobs:
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
# Includes test/architecture_test.dart, which fails the build if a layer
|
||||
# boundary is crossed.
|
||||
- name: Run tests
|
||||
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