一键导入
fdroid-reproducible-build
Make a Flutter Android app build reproducibly for F-Droid (split-per-ABI APKs with upstream developer signature verification).
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Make a Flutter Android app build reproducibly for F-Droid (split-per-ABI APKs with upstream developer signature verification).
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | fdroid-reproducible-build |
| description | Make a Flutter Android app build reproducibly for F-Droid (split-per-ABI APKs with upstream developer signature verification). |
Ensure the APK built by the F-Droid build server is byte-identical to the APK uploaded by the upstream developer. F-Droid verifies this by copying the upstream APK signature onto the unsigned rebuild and checking that the result is identical.
This skill covers the upstream developer signature verification approach (AllowedAPKSigningKeys + Binaries/binary), NOT F-Droid-signed APKs.
Three components must stay aligned:
| Component | File | Role |
|---|---|---|
| Gradle config | android/build.gradle.kts (root) | Reproducibility flags for native .so compilation |
| CI workflow | .github/workflows/release-build-android.yml | Produces the reference APK the developer uploads |
| F-Droid recipe | fdroiddata/metadata/<app.id>.yml | Tells the F-Droid build server how to rebuild the same APK |
One rule governs everything: the CI workflow and the F-Droid recipe must use the same toolchain versions and produce byte-identical native libraries.
--build-id=none)Problem: Different build machines embed different build-ids in ELF shared libraries (.so files). Build-ids depend on the absolute path of source files on disk. When the F-Droid server rebuilds on a different machine with different paths, the build-id changes → byte-difference.
Fix: Pass --build-id=none to the linker for every native CMake dependency (Flutter plugins that contain C/C++ code compiled to .so).
Location: android/build.gradle.kts — in the root-level subprojects block (NOT android/app/build.gradle.kts).
// Reproducible builds for native CMake-based Flutter plugin dependencies.
// Add every Flutter plugin that compiles native .so files via CMake.
val nativeLibraryModules = setOf(
"plugin_name_1",
"plugin_name_2"
)
subprojects {
plugins.withId("com.android.library") {
if (name in nativeLibraryModules) {
extensions.configure<com.android.build.gradle.LibraryExtension>("android") {
defaultConfig {
externalNativeBuild {
cmake {
arguments += "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,--build-id=none"
}
}
}
}
}
}
}
Why the root build.gradle.kts and not android/app/build.gradle.kts? Because subprojects scopes to subprojects of the current Gradle project. Flutter plugin native modules are subprojects of the root Gradle project (defined in android/build.gradle.kts), NOT subprojects of :app (defined in android/app/build.gradle.kts).
How to identify native Flutter plugin modules: Run
flutter build apk --releaseonce, then look at the Gradle output or inspectandroid/.gradle/andbuild/directories. Native plugins appear as Gradle subprojects. Or check each dependency's source: if it containsCMakeLists.txtor.cpp/.cfiles under its Android source directory, it needs to be listed. Common examples:flutter_zxing,jni,path_provider_android,shared_preferences_android,image_picker_android,camera_android,mobile_scanner.
-ffile-prefix-map)Problem: Native .so files embed absolute source file paths in debug info (even stripped binaries retain some path references). Different build machines have different absolute paths (e.g., /home/runner/work/... on GitHub Actions vs /home/vagrant/build/... on F-Droid), causing byte-differences. This is the #1 cause of Flutter APK reproducibility failures.
Fix: Use -ffile-prefix-map=OLD=NEW to remap absolute paths to relative ones. The compiler flag replaces any path starting with OLD with NEW in all debug sections and embedded strings.
Location: Same Gradle block as #1, sibling to the arguments line:
cFlags += "-ffile-prefix-map=${project.projectDir.absolutePath}=."
cppFlags += "-ffile-prefix-map=${project.projectDir.absolutePath}=."
Full combined block example:
val nativeLibraryModules = setOf("plugin_name_1", "plugin_name_2")
subprojects {
plugins.withId("com.android.library") {
if (name in nativeLibraryModules) {
extensions.configure<com.android.build.gradle.LibraryExtension>("android") {
defaultConfig {
externalNativeBuild {
cmake {
arguments += "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,--build-id=none"
cFlags += "-ffile-prefix-map=${project.projectDir.absolutePath}=."
cppFlags += "-ffile-prefix-map=${project.projectDir.absolutePath}=."
}
}
}
}
}
}
}
Critical: why cFlags/cppFlags and NOT arguments:
| Approach | What happens | Works? |
|---|---|---|
arguments += "-DCMAKE_C_FLAGS=-ffile-prefix-map=\${CMAKE_SOURCE_DIR}=." | Gradle's Kotlin DSL interprets ${CMAKE_SOURCE_DIR} as a Gradle macro/property → fails with "Unrecognized macro" | ❌ |
arguments += "-DCMAKE_C_FLAGS=-ffile-prefix-map=/some/path=." | Hardcoded absolute path; different on every machine → byte-difference | ❌ |
cFlags += "-ffile-prefix-map=${project.projectDir.absolutePath}=." | Gradle resolves the Kotlin expression at config time. cFlags passes the literal flag directly to the C compiler — no CMake variable expansion involved. | ✅ |
How it works step by step:
project.projectDir.absolutePath → e.g. /home/runner/.pub-cache/hosted/pub.dev/some_plugin-1.0.0/android-ffile-prefix-map=/home/runner/.../some_plugin-1.0.0/android=.cFlags passes this literal flag to the C/C++ compiler (gcc/clang) — bypasses CMake's variable system entirely.⚠️ Do NOT use \${CMAKE_SOURCE_DIR} inside arguments strings. Even if you correctly escape the $ for Kotlin/Gradle, CMake does not expand CMake variables inside -D definition values — they are stored as literals.
⚠️ Do NOT use Gradle property expansion for CMake variables. -DCMAKE_C_FLAGS="-ffile-prefix-map=${project.projectDir}=." in arguments also fails because the resolved path is still the build machine's absolute path, which differs on F-Droid.
CI workflow: Set PUB_CACHE to a location inside the workspace:
env:
PUB_CACHE: ${{ github.workspace }}/.pub-cache
F-Droid recipe: Set PUB_CACHE to a location inside the build directory:
prebuild:
- export PUB_CACHE=$(pwd)/.pub-cache
build:
- export PUB_CACHE=$(pwd)/.pub-cache
Both builds place .pub-cache at a consistent relative location. While -ffile-prefix-map handles path normalization in native code, keeping PUB_CACHE consistent avoids other potential path-related differences in Flutter's own tooling.
Every tool that affects binary output must have the same version in CI and F-Droid:
| Tool | Where to pin | Notes |
|---|---|---|
| Flutter | CI workflow (flutter-version:) AND pubspec.yaml (environment.flutter) | Must be exact same version. Even patch-level differences change the Flutter engine hash embedded in the APK |
| Java | CI workflow (actions/setup-java) | F-Droid uses its own JDK; pin the same major version |
| NDK | F-Droid recipe (ndk: field per build entry) | Flutter bundles a specific NDK version; extract it from CI logs or flutter doctor -v |
Pro tip — Flutter version as single source of truth: Have the F-Droid recipe read the Flutter version from the CI workflow rather than hardcoding it:
prebuild:
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release-build-android.yml)
- git -C $$flutter$$ checkout -f $flutterVersion
This way, when you update Flutter in the CI workflow, the F-Droid recipe automatically picks it up. No version drift.
Problem: F-Droid's build server uses a specific working directory structure that may differ from GitHub Actions. If absolute paths from the build environment leak into the APK (outside of .so files), reproducibility fails.
Fix: Mirror the CI's working directory structure in the F-Droid recipe using sudo and mv:
sudo:
- mkdir -p /home/runner/work/<RepoName>/<RepoName>/
- chown vagrant /home/runner/work/<RepoName>/<RepoName>/
prebuild:
- export repo=/home/runner/work/<RepoName>/<RepoName>
- cd ..
- mv <original_checkout_dir> $repo
- pushd $repo
# ... build steps ...
- popd
- mv $repo <original_checkout_dir>
build:
- export repo=/home/runner/work/<RepoName>/<RepoName>
- cd ..
- mv <original_checkout_dir> $repo
- pushd $repo
# ... flutter build ...
- popd
- mv $repo <original_checkout_dir>
This is optional — only needed if you find path-related differences that -ffile-prefix-map doesn't cover. Start without it; add only if diffoscope shows path differences in non-.so files.
Developer's CI
└─ flutter build apk --release --split-per-abi
└─ signs with developer keystore → signed APK
└─ uploads to GitHub Releases
F-Droid build server
└─ Same source + same toolchain
└─ flutter build apk --release --split-per-abi
└─ produces UNSIGNED APK
└─ apksigcopier copies signature from upstream signed APK
└─ if (unsigned + copied sig) == upstream signed APK:
└─ REPRODUCIBLE ✅ → publish upstream APK
└─ else:
└─ NOT REPRODUCIBLE ❌ → skip publishing
This is the hardest part of Flutter reproducible builds. Here's the systematic approach:
Run a release build once, then check which native .so files were produced:
flutter build apk --release
unzip -l build/app/outputs/flutter-apk/app-release.apk | grep '\.so$'
Each .so file comes from a Flutter plugin. The library name (without lib prefix and .so suffix) often matches the plugin's Gradle project name.
Alternatively, grep the pub cache for CMakeLists.txt in your dependencies:
grep -rl "add_library" ~/.pub-cache/hosted/pub.dev/*/android/
For each native plugin, find its Gradle project name. Look at:
~/.pub-cache/hosted/pub.dev/<plugin>-<version>/android/build.gradle → the group or project name./gradlew projects from android/ directoryThe Gradle project name is what you put in the nativeLibraryModules set.
Check if the plugin has a CMakeLists.txt file referenced in its build.gradle:
externalNativeBuild {
cmake {
path "CMakeLists.txt" // or "src/main/cpp/CMakeLists.txt"
}
}
Only CMake-based plugins need the subprojects block treatment. ndk-build plugins (rare) need a different approach (use ndkBuild instead of cmake in the DSL).
val nativeLibraryModules = setOf(
"plugin_gradle_name_1",
"plugin_gradle_name_2",
// Add newly discovered plugins here
)
Build on two different machines (or CI + local) and compare the .so files:
diffoscope machine1.apk machine2.apk
The recipe defines one build entry per ABI (typically armeabi-v7a, arm64-v8a, x86_64):
- versionName: <app_version> # From pubspec.yaml
versionCode: <computed_code> # Computed via VercodeOperation
commit: <full_git_sha> # The exact tagged release commit
sudo: # For path mirroring (optional)
- mkdir -p /home/runner/work/<Repo>/<Repo>/
- chown vagrant /home/runner/work/<Repo>/<Repo>/
output: build/app/outputs/flutter-apk/app-<abi>-release.apk
binary: https://github.com/<owner>/<repo>/releases/download/%v/app-<abi>-release.apk
srclibs:
- flutter@stable
prebuild:
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release-build-android.yml)
- '[[ $flutterVersion ]]'
- git -C $$flutter$$ checkout -f $flutterVersion
- export PUB_CACHE=$(pwd)/.pub-cache
- $$flutter$$/bin/flutter config --no-analytics
- $$flutter$$/bin/flutter pub get --enforce-lockfile
scanignore:
- .pub-cache
build:
- export PUB_CACHE=$(pwd)/.pub-cache
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform=<platform>
ndk: <ndk_version> # From flutter doctor -v or CI NDK installation logs
VercodeOperation)Split-per-ABI APKs need unique version codes so they can coexist in the F-Droid repo:
VercodeOperation:
- '%c * 10 + 1' # armeabi-v7a
- '%c * 10 + 2' # arm64-v8a
- '%c * 10 + 3' # x86_64
Where %c is the version code from pubspec.yaml (version: X.Y.Z+CODE).
AutoUpdateMode: Version
UpdateCheckMode: Tags
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
AllowedAPKSigningKeys: <sha256_of_signing_cert>
Extract the signing key fingerprint:
keytool -list -v -keystore <keystore>.jks | grep SHA256
Or from an existing APK:
apksigner verify --print-certs app-release.apk
Check .github/workflows/release-build-android.yml:
flutter-version: equals pubspec.yaml → environment.flutterjava-version: hasn't changed unexpectedlyCheck android/build.gradle.kts:
nativeLibraryModules includes ALL native pub packages (run the discovery steps)cFlags/cppFlags use project.projectDir.absolutePath (NOT \${CMAKE_SOURCE_DIR})arguments += "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,--build-id=none" is presentnativeLibraryModulesversion: X.Y.Z+CODE
F-Droid uses --enforce-lockfile. The lockfile MUST be current and committed.
flutter pub get
git add pubspec.lock pubspec.yaml
git commit -m "chore: bump version to X.Y.Z+CODE"
git tag vX.Y.Z
git push origin vX.Y.Z
This triggers the CI workflow that builds and uploads APKs.
For each ABI build entry:
versionName:, versionCode:, and commit: (full SHA of the tagged commit)ndk: hasn't changed (check CI logs if unsure)CurrentVersion: and CurrentVersionCode: at the bottomcd fdroiddata
fdroid build <app.id> --verbose --latest
fdroid publish --verbose
arguments with ${CMAKE_SOURCE_DIR} — build failsGradle's Kotlin DSL interprets ${CMAKE_SOURCE_DIR} as a Gradle property. Fix: use cFlags/cppFlags with project.projectDir.absolutePath.
arguments with hardcoded path — not reproduciblearguments += "-DCMAKE_C_FLAGS=-ffile-prefix-map=/home/runner/..." embeds a CI-specific path. Fix: use project.projectDir.absolutePath.
nativeLibraryModulesIf a plugin compiles native code but isn't listed, its .so files won't get reproducibility flags. Re-run the discovery steps when adding/updating Flutter dependencies.
Hardcoding the Flutter version in two places guarantees drift. Let the F-Droid recipe read it from the CI workflow.
The ndk: field in each build entry must match the NDK that CI uses. Flutter upgrades change the bundled NDK — always verify with flutter doctor -v or from CI logs.
F-Droid builds from the exact commit: SHA. Uncommitted changes, leftover build artifacts, or generated files not in .gitignore will cause mismatches.
If classes.dex differs, try pinning the Java version, disabling R8 optimizations for affected classes, or upgrading R8/AGP.
pubspec.lock not committedF-Droid's --enforce-lockfile will fail. Always commit pubspec.lock.
Use LF across the project. Configure .gitattributes: * text=auto eol=lf.
Flutter upgrades change AGP. Verify the F-Droid server supports the new AGP version.
If baseline.prof differs, disable the ArtProfile task:
tasks.whenTaskAdded {
if (name.contains("ArtProfile")) {
enabled = false
}
}
.comment section (r26+)NDK r26+ embeds a Clang version string that differs between macOS and Linux. Remove it:
objcopy --remove-section .comment <file>.so
fdroid builddiffoscope reference.apk rebuilt-unsigned.apk
| Symptom | Likely cause | Fix |
|---|---|---|
.so files differ (ELF build-id) | Missing --build-id=none | Add to Gradle subprojects block |
.so files differ (embedded paths) | Missing -ffile-prefix-map | Add cFlags/cppFlags to Gradle subprojects block |
.so files differ (.comment section) | NDK Clang version varies by platform (r26+) | objcopy --remove-section .comment |
classes.dex differs | JDK version mismatch | Pin Java version |
classes.dex differs (method ordering) | R8 non-determinism | Update R8/AGP or disable specific optimizations |
AndroidManifest.xml differs | Different Android platform revision | Usually unavoidable; F-Droid may still accept |
resources.arsc differs | Non-deterministic aapt/aapt2 | Check AGP version |
| ZIP metadata differs | Different OS / filesystem | Use apksigner (build-tools ≤34) |
baseline.prof differs | Non-deterministic profile generation | Disable ArtProfile Gradle task |
| Entire APK differs slightly | Flutter engine version mismatch | Verify Flutter version is identical (± patch) |
# CI side
flutter --version
java -version
# Check CI logs for NDK version
# F-Droid recipe side
# Verify ndk: field matches CI
# Verify flutter version extraction works
pubspec.lock committed and up to dateflutter-version: in CI workflow equals environment.flutter in pubspec.yamlnativeLibraryModules in root android/build.gradle.kts--build-id=none configured for each native plugin via Gradle subprojects-ffile-prefix-map configured via cFlags/cppFlags (NOT arguments)PUB_CACHE set to workspace-relative path in CI and F-Droid recipendk: pinned in each F-Droid build entryAllowedAPKSigningKeys: configured with correct signing cert fingerprintVercodeOperation: set for split-per-ABI unique version codes.gitattributes enforces LF line endingsapksigner from build-tools ≤34