Compare commits
20 Commits
b76098c7e6
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ff423f8bbf | |||
| 47d849a122 | |||
| ba7f335b67 | |||
| 5b6c2c0435 | |||
| b01f8cdf87 | |||
| b960581fd1 | |||
| 7981ca2022 | |||
| ae2033865c | |||
| 86b290d978 | |||
| 32575530dc | |||
| 0dd0b95afb | |||
| 66826bbce6 | |||
| 85dc74247c | |||
| 94d96297f8 | |||
| a96f302dc7 | |||
| 7ca74366a3 | |||
| ff13d63333 | |||
| 2b3f20d3a0 | |||
| 894712b7a8 | |||
| 895b963745 |
@@ -3,20 +3,39 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master ]
|
branches: [ master ]
|
||||||
|
paths:
|
||||||
|
- 'lib/**.dart'
|
||||||
|
- 'test/**.dart'
|
||||||
|
- 'android/**'
|
||||||
|
- 'tool/**'
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
- 'pubspec.lock'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master ]
|
branches: [ master ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Both jobs target a self-hosted runner, by capability rather than by hostname.
|
||||||
|
# See tool/setup_ci_runner.sh for how that runner is provisioned and why it is
|
||||||
|
# not the Gitea host: Flutter cannot build an Android APK on a Linux arm64 host
|
||||||
|
# (flutter/flutter#189724), and the Gitea box is aarch64. The runner is
|
||||||
|
# registered at android-stuff org scope so any repo in the org can use it.
|
||||||
|
#
|
||||||
|
# The runner is host-mode, so Flutter, the JDK and the Android SDK/NDK are the
|
||||||
|
# machine's own — the same toolchain that produced the verified release, and no
|
||||||
|
# multi-hundred-MB download per run. Nothing here installs a toolchain; the
|
||||||
|
# Toolchain step fails loudly if one is missing.
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze & Test
|
name: Analyze & Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: flutter-android
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
- name: Toolchain
|
||||||
with:
|
run: |
|
||||||
flutter-version: '3.47.1'
|
flutter --version
|
||||||
channel: 'stable'
|
echo "ANDROID_HOME=${ANDROID_HOME:?not set on this runner}"
|
||||||
|
|
||||||
- name: Get dependencies
|
- name: Get dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
@@ -24,5 +43,36 @@ 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: flutter-android
|
||||||
|
needs: [analyze]
|
||||||
|
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024–2026 Mikkeli Matlock
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Note: This project bundles iperf3 v3.19, which is licensed under the
|
||||||
|
BSD-3-Clause license. See the bundled iperf3 source for its full license text.
|
||||||
@@ -40,9 +40,6 @@ domain, so shipping an iperf3 ELF in assets and running it via `Process.start()`
|
|||||||
fails with "Permission denied". iperf3 is therefore compiled as a shared library
|
fails with "Permission denied". iperf3 is therefore compiled as a shared library
|
||||||
(`libiperf3jni.so`) and called in-process over JNI.
|
(`libiperf3jni.so`) and called in-process over JNI.
|
||||||
|
|
||||||
`assets/iperf3/` still holds the pre-compiled binaries from that earlier
|
|
||||||
approach. They are not declared in `pubspec.yaml`, so they are not packaged into
|
|
||||||
the APK, and nothing in the app reads them.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ android {
|
|||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
|
||||||
|
// proguard-rules.pro keeps the names JNI resolves at runtime.
|
||||||
|
// Without it a release build aborts on the first callback.
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# JNI reaches these by name. R8 renames by default, so without these rules a
|
||||||
|
# release build resolves nothing: GetMethodID("onOutput", ...) returns NULL,
|
||||||
|
# raises NoSuchMethodError, and the next JNI call aborts the process with
|
||||||
|
# NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V"
|
||||||
|
# Debug builds do not minify, so this only ever appears in release.
|
||||||
|
|
||||||
|
# The plugin object holds the native method declarations and is looked up from
|
||||||
|
# C by its fully qualified name (Java_com_novoyuuparosk_iperf3gui_IperfRunner_*).
|
||||||
|
-keep class com.novoyuuparosk.iperf3gui.IperfRunner { *; }
|
||||||
|
|
||||||
|
# The callback interface and everything implementing it: iperf_jni.c resolves
|
||||||
|
# onOutput/onError/onComplete by name on the concrete object's class, so the
|
||||||
|
# implementors' method names must survive too, not just the interface's.
|
||||||
|
-keep interface com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback { *; }
|
||||||
|
-keep class * implements com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback { *; }
|
||||||
|
|
||||||
|
# Belt and braces for any future native method: keep the name of any class
|
||||||
|
# declaring one, and the native methods themselves.
|
||||||
|
-keepclasseswithmembernames,includedescriptorclasses class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
@@ -86,6 +86,21 @@ struct iperf_run_state {
|
|||||||
* iperf_run_client() and cleared after it returns. */
|
* iperf_run_client() and cleared after it returns. */
|
||||||
static struct iperf_run_state *g_current_state = NULL;
|
static struct iperf_run_state *g_current_state = NULL;
|
||||||
|
|
||||||
|
/* Resolve a method ID, clearing the exception a failed lookup leaves behind.
|
||||||
|
*
|
||||||
|
* GetMethodID raises NoSuchMethodError on failure, and any further JNI call
|
||||||
|
* made while an exception is pending aborts the process. Returning NULL and
|
||||||
|
* clearing lets the caller fail on its own terms. */
|
||||||
|
static jmethodID resolve_method(JNIEnv *env, jclass cls,
|
||||||
|
const char *name, const char *sig) {
|
||||||
|
jmethodID mid = (*env)->GetMethodID(env, cls, name, sig);
|
||||||
|
if ((*env)->ExceptionCheck(env)) {
|
||||||
|
(*env)->ExceptionClear(env);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return mid;
|
||||||
|
}
|
||||||
|
|
||||||
/* Helper: invoke the output callback via CallVoidMethod */
|
/* Helper: invoke the output callback via CallVoidMethod */
|
||||||
static void signal_output(JNIEnv *env, struct iperf_run_state *state,
|
static void signal_output(JNIEnv *env, struct iperf_run_state *state,
|
||||||
const char *msg) {
|
const char *msg) {
|
||||||
@@ -272,13 +287,25 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
|
|||||||
|
|
||||||
/* Resolve callback method IDs up front so failures can be reported. */
|
/* Resolve callback method IDs up front so failures can be reported. */
|
||||||
jclass callback_cls = (*env)->GetObjectClass(env, callback);
|
jclass callback_cls = (*env)->GetObjectClass(env, callback);
|
||||||
jmethodID mid_output = (*env)->GetMethodID(
|
jmethodID mid_output = resolve_method(
|
||||||
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
|
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
|
||||||
jmethodID mid_error = (*env)->GetMethodID(
|
jmethodID mid_error = resolve_method(
|
||||||
env, callback_cls, "onError", "(Ljava/lang/String;)V");
|
env, callback_cls, "onError", "(Ljava/lang/String;)V");
|
||||||
jmethodID mid_complete = (*env)->GetMethodID(
|
jmethodID mid_complete = resolve_method(
|
||||||
env, callback_cls, "onComplete", "()V");
|
env, callback_cls, "onComplete", "()V");
|
||||||
|
|
||||||
|
/* If the names did not resolve there is no way to report anything: the
|
||||||
|
* reporting path is the thing that is missing. Bail out loudly instead.
|
||||||
|
*
|
||||||
|
* In practice this means R8 renamed the callback in a release build and
|
||||||
|
* android/app/proguard-rules.pro has stopped covering it. */
|
||||||
|
if (!mid_output || !mid_error || !mid_complete) {
|
||||||
|
LOGE("Callback methods did not resolve (onOutput=%p onError=%p "
|
||||||
|
"onComplete=%p) — check the -keep rules for IperfCallback",
|
||||||
|
mid_output, mid_error, mid_complete);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/* Report a startup failure directly on the callback object and finish,
|
/* Report a startup failure directly on the callback object and finish,
|
||||||
* so Dart's completer always resolves instead of waiting for a timeout. */
|
* so Dart's completer always resolves instead of waiting for a timeout. */
|
||||||
#define FAIL_EARLY(msg) do { \
|
#define FAIL_EARLY(msg) do { \
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -183,6 +183,62 @@ Dart uses `Completer<Iperf3Result>` to await native completion. The MethodChanne
|
|||||||
| `isTestRunning()` | `isTestRunning` | Check if test is in progress |
|
| `isTestRunning()` | `isTestRunning` | Check if test is in progress |
|
||||||
| `setTempDir(String)` | `setTempDir` | Point `mkstemp()` at the app cache dir |
|
| `setTempDir(String)` | `setTempDir` | Point `mkstemp()` at the app cache dir |
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Both jobs run on a **self-hosted runner registered at `android-stuff` org scope**,
|
||||||
|
targeted by the capability label `flutter-android` rather than by hostname.
|
||||||
|
`tool/setup_ci_runner.sh` provisions it.
|
||||||
|
|
||||||
|
It does not run on the Gitea host. Gitea lives on an aarch64 box, and Flutter
|
||||||
|
cannot build an Android APK on a Linux arm64 host at all — the SDK ships no
|
||||||
|
arm64-host `gen_snapshot` ([flutter#189724](https://github.com/flutter/flutter/issues/189724),
|
||||||
|
[#75864](https://github.com/flutter/flutter/issues/75864), open as of July 2026).
|
||||||
|
An x86_64 runner is a requirement, not a preference.
|
||||||
|
|
||||||
|
The runner is **host mode**: jobs use the machine's own Flutter, JDK and Android
|
||||||
|
SDK/NDK. CI therefore builds with the same toolchain that produced the verified
|
||||||
|
release, and no NDK is downloaded per run. The cost is that there is no
|
||||||
|
container boundary, which is what decides the next paragraph.
|
||||||
|
|
||||||
|
Jobs run as a dedicated unprivileged account, `gitea-runner`, in the
|
||||||
|
`flutterdev` group. In host mode the service account *is* the sandbox — there
|
||||||
|
is no other — and a workflow file is code that runs as whoever the service runs
|
||||||
|
as. A human account would hand every workflow that user's home directory and
|
||||||
|
SSH keys; root would be worse and would also miss the warm caches in that home,
|
||||||
|
re-downloading dependencies every run. The dedicated account keeps its own
|
||||||
|
Gradle and pub caches under `/var/lib/gitea-runner`, so the first Android build
|
||||||
|
is slow by design and later ones are not.
|
||||||
|
|
||||||
|
`flutterdev` is what grants write access to `/opt/flutter` and
|
||||||
|
`/opt/android-sdk`; both carry ACLs with `group:flutterdev:rwx` and matching
|
||||||
|
`default:` entries, so new files inherit it. The setup script proves that access
|
||||||
|
as the service account rather than assuming group membership is sufficient.
|
||||||
|
|
||||||
|
It installs a **system** service, not a user service: root-managed lifecycle,
|
||||||
|
starts at boot, no lingering session. That the service is root-managed is
|
||||||
|
unrelated to the jobs being unprivileged — `User=` in the unit settles the
|
||||||
|
second, and conflating the two is easy to do.
|
||||||
|
|
||||||
|
`sudo ./tool/setup_ci_runner.sh --check` validates this machine's toolchain
|
||||||
|
without creating anything.
|
||||||
|
|
||||||
|
Two jobs:
|
||||||
|
|
||||||
|
| Job | What it protects |
|
||||||
|
|---|---|
|
||||||
|
| `analyze` | `flutter analyze`, then `flutter test` — which includes `architecture_test.dart`, failing the build if a layer boundary is crossed |
|
||||||
|
| `release-apk` | builds the **minified** APK and runs `tool/check_jni_keep_rules.sh` |
|
||||||
|
|
||||||
|
The second exists because of a specific escape. A release APK that aborted on
|
||||||
|
every test shipped once, while every on-device check passed — because those
|
||||||
|
checks all used the debug build, which does not minify. Building the release APK
|
||||||
|
is necessary but not sufficient: a renamed JNI callback still produces a
|
||||||
|
*successful build* and a broken app. So the guard reads R8's `mapping.txt` for
|
||||||
|
the callback classes and methods, and the DEX string pool for the native entry
|
||||||
|
points. It is verified to fail: delete the keep rules from
|
||||||
|
`android/app/proguard-rules.pro`, rebuild, and it reports `MainThreadCallback`,
|
||||||
|
`onOutput`, `onError` and `onComplete` renamed.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class TestConfiguration {
|
|||||||
final int parallelStreams;
|
final int parallelStreams;
|
||||||
|
|
||||||
const TestConfiguration({
|
const TestConfiguration({
|
||||||
this.server = 'iperf.es.net',
|
this.server = 'speedtest.uztelecom.uz',
|
||||||
this.port = 5201,
|
this.port = 5201,
|
||||||
this.duration = const Duration(seconds: 10),
|
this.duration = const Duration(seconds: 10),
|
||||||
this.interval = 1,
|
this.interval = 1,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import 'package:iperf3gui/domain/services/iperf3_runner.dart';
|
|||||||
|
|
||||||
/// Test parameters, supplied at build time with --dart-define.
|
/// Test parameters, supplied at build time with --dart-define.
|
||||||
const String _server = String.fromEnvironment('IPERF_SERVER',
|
const String _server = String.fromEnvironment('IPERF_SERVER',
|
||||||
defaultValue: 'iperf.es.net');
|
defaultValue: 'speedtest.uztelecom.uz');
|
||||||
const int _port = int.fromEnvironment('IPERF_PORT', defaultValue: 5201);
|
const int _port = int.fromEnvironment('IPERF_PORT', defaultValue: 5201);
|
||||||
const int _seconds = int.fromEnvironment('IPERF_SECONDS', defaultValue: 10);
|
const int _seconds = int.fromEnvironment('IPERF_SECONDS', defaultValue: 10);
|
||||||
const int _streams = int.fromEnvironment('IPERF_STREAMS', defaultValue: 1);
|
const int _streams = int.fromEnvironment('IPERF_STREAMS', defaultValue: 1);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class _TestRunContentState extends State<_TestRunContent> {
|
|||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Server',
|
labelText: 'Server',
|
||||||
hintText: 'e.g. iperf.es.net or 192.168.1.1',
|
hintText: 'e.g. speedtest.uztelecom.uz or 192.168.1.1',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: iperf3gui
|
|||||||
description: "A Flutter-based Android GUI wrapper for iperf3."
|
description: "A Flutter-based Android GUI wrapper for iperf3."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.0+1
|
version: 0.1.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.1
|
sdk: ^3.13.1
|
||||||
|
|||||||
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."
|
||||||
Executable
+288
@@ -0,0 +1,288 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Provision this machine as a Gitea Actions runner for the android-stuff org.
|
||||||
|
#
|
||||||
|
# sudo ./tool/setup_ci_runner.sh --check # pre-flight only, changes nothing
|
||||||
|
# sudo RUNNER_TOKEN=xxxx ./tool/setup_ci_runner.sh
|
||||||
|
#
|
||||||
|
# Get an ORG-scoped token from
|
||||||
|
# <gitea>/org/android-stuff/settings/actions/runners → "Create new runner"
|
||||||
|
# Org scope, not repo scope, so every android-stuff repo shares one runner.
|
||||||
|
#
|
||||||
|
# ── WHY THIS MACHINE, NOT THE GITEA HOST ────────────────────────────────────
|
||||||
|
# Gitea runs on an aarch64 box. Flutter cannot build an Android APK on a Linux
|
||||||
|
# arm64 host at all: the SDK ships no arm64-host gen_snapshot
|
||||||
|
# (flutter/flutter#189724, #75864, open as of July 2026). An x86_64 runner is a
|
||||||
|
# requirement here, not a preference.
|
||||||
|
#
|
||||||
|
# ── WHY HOST MODE, NOT A CONTAINER ──────────────────────────────────────────
|
||||||
|
# This machine has a working Flutter + Android SDK + NDK. In host mode jobs use
|
||||||
|
# it directly, so CI builds with the same toolchain that produced the verified
|
||||||
|
# release and skips a per-run NDK download. The cost is that there is no
|
||||||
|
# container boundary — which is exactly why the service account below matters.
|
||||||
|
#
|
||||||
|
# ── WHY A DEDICATED USER, AND A SYSTEM SERVICE ──────────────────────────────
|
||||||
|
# Host mode means the service account IS the sandbox; there is no other. A
|
||||||
|
# workflow file is code, and it runs as whoever this service runs as. Running
|
||||||
|
# as a human user would give every workflow read access to that user's home,
|
||||||
|
# SSH keys included. Running as root would be worse, and would also miss the
|
||||||
|
# warm Gradle/pub caches in that home (HOME=/root), re-downloading dependencies
|
||||||
|
# every run for no benefit.
|
||||||
|
#
|
||||||
|
# So: a dedicated unprivileged account, in the flutterdev group for SDK access,
|
||||||
|
# with its own HOME so its caches are its own. A *system* service rather than a
|
||||||
|
# user service, so it starts at boot under plain systemctl with no lingering
|
||||||
|
# session — the service being root-managed is unrelated to the jobs being
|
||||||
|
# unprivileged, and conflating those two was the earlier mistake here.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${RUNNER_VERSION:-3.3.0}"
|
||||||
|
INSTANCE="${GITEA_INSTANCE:-http://pi5-16.local:3005}"
|
||||||
|
RUNNER_USER="${RUNNER_USER:-gitea-runner}"
|
||||||
|
SDK_GROUP="${SDK_GROUP:-flutterdev}"
|
||||||
|
RUNNER_HOME="${RUNNER_HOME:-/var/lib/gitea-runner}"
|
||||||
|
BIN="${RUNNER_BIN:-/usr/local/bin/gitea-runner}"
|
||||||
|
UNIT="/etc/systemd/system/gitea-runner.service"
|
||||||
|
RUNNER_NAME="${RUNNER_NAME:-$(hostname)-android}"
|
||||||
|
|
||||||
|
# Capability label, deliberately not a hostname: it says what the runner can
|
||||||
|
# do, so a workflow does not encode which machine happens to provide it.
|
||||||
|
LABEL="${RUNNER_LABEL:-flutter-android}"
|
||||||
|
|
||||||
|
CHECK_ONLY=0
|
||||||
|
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "Run with sudo: sudo RUNNER_TOKEN=... $0" >&2; exit 1; }
|
||||||
|
# --check is for validating this machine before committing to anything, so it
|
||||||
|
# deliberately does not require a token.
|
||||||
|
if [[ $CHECK_ONLY -eq 0 ]]; then
|
||||||
|
: "${RUNNER_TOKEN:?set RUNNER_TOKEN to an org registration token (see header)}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say() { printf '\n==> %s\n' "$1"; }
|
||||||
|
|
||||||
|
# ── Pre-flight, before anything is created ─────────────────────────────────
|
||||||
|
say "Pre-flight"
|
||||||
|
missing=0
|
||||||
|
for cmd in flutter dart java cmake ninja node git unzip curl; do
|
||||||
|
# Resolved from systemd's own PATH, not an interactive shell's, since that
|
||||||
|
# is what the service will actually have.
|
||||||
|
if PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||||
|
command -v "$cmd" >/dev/null; then
|
||||||
|
printf ' ok %s\n' "$cmd"
|
||||||
|
else
|
||||||
|
printf ' MISSING %s (not on systemd default PATH)\n' "$cmd"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/opt/android-sdk}}"
|
||||||
|
for d in "$SDK/ndk" "$SDK/build-tools" "$SDK/platforms"; do
|
||||||
|
[[ -d "$d" ]] && printf ' ok %s\n' "$d" \
|
||||||
|
|| { printf ' MISSING %s\n' "$d"; missing=1; }
|
||||||
|
done
|
||||||
|
getent group "$SDK_GROUP" >/dev/null \
|
||||||
|
&& printf ' ok group %s\n' "$SDK_GROUP" \
|
||||||
|
|| { printf ' MISSING group %s\n' "$SDK_GROUP"; missing=1; }
|
||||||
|
|
||||||
|
if [[ $missing -ne 0 ]]; then
|
||||||
|
echo
|
||||||
|
echo "Refusing to register: a runner advertising '$LABEL' must actually" >&2
|
||||||
|
echo "provide these, or every job fails confusingly instead of here." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $CHECK_ONLY -eq 1 ]]; then
|
||||||
|
say "Pre-flight passed"
|
||||||
|
echo " Nothing was created. Re-run with RUNNER_TOKEN set to provision."
|
||||||
|
echo " Note: SDK write access is verified as $RUNNER_USER, which can only"
|
||||||
|
echo " be checked once that account exists — so it is not covered here."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Service account ────────────────────────────────────────────────────────
|
||||||
|
say "Service account: $RUNNER_USER"
|
||||||
|
if id "$RUNNER_USER" &>/dev/null; then
|
||||||
|
echo " exists; ensuring membership of $SDK_GROUP"
|
||||||
|
usermod -aG "$SDK_GROUP" "$RUNNER_USER"
|
||||||
|
else
|
||||||
|
# --shell /bin/bash, not nologin: host-mode steps are executed as shell
|
||||||
|
# scripts, and a login shell costs nothing here — the account has no
|
||||||
|
# password, so it is only reachable via root anyway.
|
||||||
|
useradd --system \
|
||||||
|
--create-home --home-dir "$RUNNER_HOME" \
|
||||||
|
--shell /bin/bash \
|
||||||
|
--groups "$SDK_GROUP" \
|
||||||
|
--comment "Gitea Actions runner ($LABEL)" \
|
||||||
|
"$RUNNER_USER"
|
||||||
|
echo " created"
|
||||||
|
fi
|
||||||
|
install -d -o "$RUNNER_USER" -g "$SDK_GROUP" -m 0750 \
|
||||||
|
"$RUNNER_HOME" "$RUNNER_HOME/workspace"
|
||||||
|
id "$RUNNER_USER"
|
||||||
|
|
||||||
|
# Group membership is worthless if the ACLs disagree, so prove it rather than
|
||||||
|
# assume it — as the service user, against the paths a build actually writes.
|
||||||
|
#
|
||||||
|
# By actually writing a file, not by asking `test -w`. This machine's
|
||||||
|
# /usr/bin/test is uutils coreutils 0.8.0, whose -w does not credit
|
||||||
|
# supplementary group membership: it reports /opt/android-sdk unwritable for
|
||||||
|
# any account whose write access comes from the flutterdev group rather than
|
||||||
|
# from the owner bits — which is every account that should be running builds
|
||||||
|
# here. (-r and -x pass only because `other` has r-x.) bash's builtin `[ -w ]`
|
||||||
|
# gets it right, but sudo execs the external binary, so the builtin is not
|
||||||
|
# available here. A write probe is immune to the whole question.
|
||||||
|
say "Verifying $RUNNER_USER can write the SDKs"
|
||||||
|
write_ok=1
|
||||||
|
for d in "/opt/flutter/bin/cache" "$SDK"; do
|
||||||
|
if sudo -u "$RUNNER_USER" env probe="$d/.gitea-runner-probe.$$" \
|
||||||
|
sh -c 'touch "$probe" && rm -f "$probe"' 2>/dev/null; then
|
||||||
|
printf ' ok writable: %s\n' "$d"
|
||||||
|
else
|
||||||
|
printf ' FAIL not writable: %s\n' "$d"
|
||||||
|
sudo -u "$RUNNER_USER" rm -f "$d/.gitea-runner-probe.$$" 2>/dev/null || true
|
||||||
|
write_ok=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ $write_ok -ne 1 ]]; then
|
||||||
|
echo
|
||||||
|
echo "$RUNNER_USER is in $SDK_GROUP but still cannot write. Check:" >&2
|
||||||
|
echo " getfacl /opt/flutter /opt/android-sdk" >&2
|
||||||
|
echo " id $RUNNER_USER" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Binary ─────────────────────────────────────────────────────────────────
|
||||||
|
say "Installing gitea-runner $VERSION"
|
||||||
|
if [[ -x "$BIN" ]] && "$BIN" --version 2>/dev/null | grep -qF "$VERSION"; then
|
||||||
|
echo " already at $VERSION"
|
||||||
|
else
|
||||||
|
url="https://dl.gitea.com/gitea-runner/${VERSION}/gitea-runner-${VERSION}-linux-amd64"
|
||||||
|
echo " $url"
|
||||||
|
curl -fSL --retry 3 -o "$BIN.tmp" "$url"
|
||||||
|
chmod 0755 "$BIN.tmp"
|
||||||
|
mv "$BIN.tmp" "$BIN"
|
||||||
|
fi
|
||||||
|
"$BIN" --version
|
||||||
|
|
||||||
|
# ── Config ─────────────────────────────────────────────────────────────────
|
||||||
|
say "Config"
|
||||||
|
CONFIG="$RUNNER_HOME/config.yaml"
|
||||||
|
if [[ -f "$CONFIG" && -f "$RUNNER_HOME/.runner" ]]; then
|
||||||
|
# Registered: this config is in use, do not touch it.
|
||||||
|
echo " $CONFIG exists and the runner is registered — leaving it alone"
|
||||||
|
else
|
||||||
|
if [[ -f "$CONFIG" ]]; then
|
||||||
|
echo " $CONFIG exists but nothing is registered yet — rewriting it"
|
||||||
|
fi
|
||||||
|
cat > "$CONFIG" <<YAML
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
# Absolute on purpose: a relative path resolves against whatever directory
|
||||||
|
# the command was invoked from, which for `register` is wherever the admin
|
||||||
|
# happened to be standing — typically a repo this account cannot write.
|
||||||
|
file: ${RUNNER_HOME}/.runner
|
||||||
|
# One at a time: a 2-ABI NDK compile plus Gradle will saturate this box, and
|
||||||
|
# concurrent jobs would only contend for it.
|
||||||
|
capacity: 1
|
||||||
|
timeout: 45m
|
||||||
|
# ':host' runs steps directly on this machine as ${RUNNER_USER}, using the
|
||||||
|
# toolchain verified in pre-flight, rather than in a container that would
|
||||||
|
# have to fetch its own.
|
||||||
|
labels:
|
||||||
|
- "${LABEL}:host"
|
||||||
|
|
||||||
|
container:
|
||||||
|
# Unused in host mode. Kept so switching to container mode later is an edit
|
||||||
|
# rather than a rediscovery — it would need the Docker network that the
|
||||||
|
# Gitea host's own runner also needs.
|
||||||
|
network: ""
|
||||||
|
privileged: false
|
||||||
|
|
||||||
|
host:
|
||||||
|
workdir_parent: ${RUNNER_HOME}/workspace
|
||||||
|
YAML
|
||||||
|
chown "$RUNNER_USER:$SDK_GROUP" "$CONFIG"
|
||||||
|
chmod 0640 "$CONFIG"
|
||||||
|
echo " wrote $CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Registration ───────────────────────────────────────────────────────────
|
||||||
|
say "Registering with $INSTANCE"
|
||||||
|
if [[ -f "$RUNNER_HOME/.runner" ]]; then
|
||||||
|
echo " already registered ($RUNNER_HOME/.runner) — skipping."
|
||||||
|
echo " To re-register: delete that file AND remove the runner in Gitea."
|
||||||
|
else
|
||||||
|
# Run from the runner's own directory: register writes .runner and
|
||||||
|
# .runner.lock relative to cwd unless told otherwise, and this account
|
||||||
|
# cannot write wherever the admin invoked the script from.
|
||||||
|
# --labels is deliberately absent: gitea-runner ignores it when the config
|
||||||
|
# file defines labels, and warns that it is doing so.
|
||||||
|
sudo -u "$RUNNER_USER" env HOME="$RUNNER_HOME" sh -c '
|
||||||
|
cd "$1" || exit 1
|
||||||
|
exec "$2" register --no-interactive \
|
||||||
|
--instance "$3" --token "$4" --name "$5" --config "$6"
|
||||||
|
' sh "$RUNNER_HOME" "$BIN" "$INSTANCE" "$RUNNER_TOKEN" "$RUNNER_NAME" "$CONFIG"
|
||||||
|
# register writes .runner into the cwd it was given; make sure it landed.
|
||||||
|
[[ -f "$RUNNER_HOME/.runner" ]] || {
|
||||||
|
echo " registration produced no .runner in $RUNNER_HOME" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Service ────────────────────────────────────────────────────────────────
|
||||||
|
say "systemd unit"
|
||||||
|
cat > "$UNIT" <<UNITFILE
|
||||||
|
[Unit]
|
||||||
|
Description=Gitea Actions runner (${LABEL}, host mode)
|
||||||
|
Documentation=https://gitea.com/gitea/act_runner
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${RUNNER_USER}
|
||||||
|
Group=${SDK_GROUP}
|
||||||
|
WorkingDirectory=${RUNNER_HOME}
|
||||||
|
ExecStart=${BIN} daemon --config ${CONFIG}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# HOME must be explicit: it decides where Gradle and pub put their caches, and
|
||||||
|
# a service does not inherit a login environment.
|
||||||
|
Environment=HOME=${RUNNER_HOME}
|
||||||
|
Environment=GRADLE_USER_HOME=${RUNNER_HOME}/.gradle
|
||||||
|
Environment=PUB_CACHE=${RUNNER_HOME}/.pub-cache
|
||||||
|
Environment=ANDROID_HOME=${SDK}
|
||||||
|
Environment=ANDROID_SDK_ROOT=${SDK}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNITFILE
|
||||||
|
echo " wrote $UNIT"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now gitea-runner
|
||||||
|
sleep 2
|
||||||
|
systemctl --no-pager --lines=15 status gitea-runner || true
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
==> Done.
|
||||||
|
|
||||||
|
Logs: journalctl -u gitea-runner -f
|
||||||
|
Stop: systemctl stop gitea-runner
|
||||||
|
Remove: systemctl disable --now gitea-runner
|
||||||
|
rm $UNIT $BIN && rm -rf $RUNNER_HOME
|
||||||
|
userdel $RUNNER_USER
|
||||||
|
...and delete the runner in the Gitea org settings.
|
||||||
|
|
||||||
|
The runner should now appear under
|
||||||
|
${INSTANCE}/org/android-stuff/settings/actions/runners
|
||||||
|
with label '$LABEL'. The queued run for this repo should start on its own.
|
||||||
|
|
||||||
|
First Android build will be slow: this account's Gradle and pub caches
|
||||||
|
start empty by design. Subsequent runs reuse them.
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user