Compare commits

...

3 Commits

Author SHA1 Message Date
mikkeli 85dc74247c Merge branch 'ci/release-apk-and-jni-name-guard'
CI / Analyze & Test (push) Failing after 28s
CI / Release APK (push) Failing after 1m48s
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>
2026-08-26 22:54:33 +09:00
mikkeli 94d96297f8 ci: build the release APK and check the JNI names
CI only ran analyze and test, so it would have passed for every commit that
carried the broken release APK. Adds a release-apk job that builds the
minified artifact, runs tool/check_jni_keep_rules.sh against it, and uploads
it — turning "the shipping artifact works" into something checked rather than
assumed at tag time.

Enables flutter-action's dependency cache on both jobs, and notes that the
test step now includes the layering guard.

This does not fix the current failures: every run so far has failed at
actions/checkout, before Flutter is reached, because the runner's job
containers cannot resolve the `gitea` hostname. That is runner configuration,
not workflow content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:19 +09:00
mikkeli a96f302dc7 test: guard the JNI-visible names against R8 renaming
A renamed JNI callback does not fail the build — it produces a release APK
that aborts on the first callback of every test. That shipped once, because
every on-device check used the debug build, which does not minify.

This reads R8's own mapping file for the classes and callback methods, and the
DEX string pool for the native entry points. Native methods are deliberately
not checked against mapping.txt: R8 only emits entries for members with a body
or a rename, so a native method is absent either way and absence would prove
nothing.

Verified by deleting the callback keep rules and rebuilding: the check reports
MainThreadCallback renamed along with onOutput, onError and onComplete, which
is exactly the failure that shipped — with no device involved.

Also avoids `... | strings | grep -q` and dumps to a file first. Under
`set -o pipefail`, grep -q exits at the first match and SIGPIPEs strings, so
the pipeline reports failure for a name found early and success for one found
late. It reported two of four names missing while all four were present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:19 +09:00
2 changed files with 153 additions and 0 deletions
+41
View File
@@ -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
+112
View File
@@ -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."