| name | fdroid-reproducible-build |
| description | Make a Flutter Android app build reproducibly for F-Droid (split-per-ABI APKs with upstream developer signature verification). |
F-Droid Reproducible Build — Flutter Android
Goal
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.
Architecture overview
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.
Reproducibility mechanisms
1. Native library build-id removal (--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).
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 --release once, then look at the Gradle output or inspect android/.gradle/ and build/ directories. Native plugins appear as Gradle subprojects. Or check each dependency's source: if it contains CMakeLists.txt or .cpp/.c files 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.
2. Source path normalization (-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:
- Gradle evaluates
project.projectDir.absolutePath → e.g. /home/runner/.pub-cache/hosted/pub.dev/some_plugin-1.0.0/android
- Kotlin string becomes:
-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
- The compiler maps any source path starting with that absolute prefix to
.
- Result: all embedded paths become relative and identical on any build machine
⚠️ 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.
3. PUB_CACHE location
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.
4. Toolchain version pinning
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.
5. Build path mirroring
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
- popd
- mv $repo <original_checkout_dir>
build:
- export repo=/home/runner/work/<RepoName>/<RepoName>
- cd ..
- mv <original_checkout_dir> $repo
- pushd $repo
- 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.
6. APK signing and verification flow
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
How to discover and handle native Flutter plugin dependencies
This is the hardest part of Flutter reproducible builds. Here's the systematic approach:
Step 1: Identify ALL native plugins
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/
Step 2: Find the Gradle project name
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
- Or check the Android build output:
./gradlew projects from android/ directory
The Gradle project name is what you put in the nativeLibraryModules set.
Step 3: Verify the plugin uses CMake
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).
Step 4: Add to the subprojects block
val nativeLibraryModules = setOf(
"plugin_gradle_name_1",
"plugin_gradle_name_2",
)
Step 5: Validate
Build on two different machines (or CI + local) and compare the .so files:
diffoscope machine1.apk machine2.apk
F-Droid YAML recipe structure
The recipe defines one build entry per ABI (typically armeabi-v7a, arm64-v8a, x86_64):
- versionName: <app_version>
versionCode: <computed_code>
commit: <full_git_sha>
sudo:
- 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>
Version code scheme (VercodeOperation)
Split-per-ABI APKs need unique version codes so they can coexist in the F-Droid repo:
VercodeOperation:
- '%c * 10 + 1'
- '%c * 10 + 2'
- '%c * 10 + 3'
Where %c is the version code from pubspec.yaml (version: X.Y.Z+CODE).
Auto-update
AutoUpdateMode: Version
UpdateCheckMode: Tags
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
Required fields for upstream signature verification
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
Step-by-step: adding a new release
Step 1: Verify the CI workflow uses the same Flutter version as pubspec.yaml
Check .github/workflows/release-build-android.yml:
flutter-version: equals pubspec.yaml → environment.flutter
java-version: hasn't changed unexpectedly
Step 2: Verify Gradle reproducibility config
Check 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 present
- Newly added Flutter plugins that compile native code → added to
nativeLibraryModules
Step 3: Update pubspec.yaml version
version: X.Y.Z+CODE
Step 4: Commit pubspec.lock
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"
Step 5: Tag and push
git tag vX.Y.Z
git push origin vX.Y.Z
This triggers the CI workflow that builds and uploads APKs.
Step 6: Wait for CI, verify APKs are uploaded to the release
Step 7: Update the F-Droid recipe
For each ABI build entry:
- Update
versionName:, versionCode:, and commit: (full SHA of the tagged commit)
- Verify
ndk: hasn't changed (check CI logs if unsure)
- Update
CurrentVersion: and CurrentVersionCode: at the bottom
Step 8: Test locally (recommended)
cd fdroiddata
fdroid build <app.id> --verbose --latest
fdroid publish --verbose
Step 9: Commit and push F-Droid metadata
Common pitfalls
❌ arguments with ${CMAKE_SOURCE_DIR} — build fails
Gradle's Kotlin DSL interprets ${CMAKE_SOURCE_DIR} as a Gradle property. Fix: use cFlags/cppFlags with project.projectDir.absolutePath.
❌ arguments with hardcoded path — not reproducible
arguments += "-DCMAKE_C_FLAGS=-ffile-prefix-map=/home/runner/..." embeds a CI-specific path. Fix: use project.projectDir.absolutePath.
❌ Missing a native plugin in nativeLibraryModules
If 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.
❌ Flutter version drift between CI and F-Droid recipe
Hardcoding the Flutter version in two places guarantees drift. Let the F-Droid recipe read it from the CI workflow.
❌ NDK version mismatch
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.
❌ Dirty build tree
F-Droid builds from the exact commit: SHA. Uncommitted changes, leftover build artifacts, or generated files not in .gitignore will cause mismatches.
❌ Non-deterministic DEX / R8 output
If classes.dex differs, try pinning the Java version, disabling R8 optimizations for affected classes, or upgrading R8/AGP.
⚠️ pubspec.lock not committed
F-Droid's --enforce-lockfile will fail. Always commit pubspec.lock.
⚠️ Line endings (CRLF vs LF)
Use LF across the project. Configure .gitattributes: * text=auto eol=lf.
⚠️ Android Gradle Plugin version changes
Flutter upgrades change AGP. Verify the F-Droid server supports the new AGP version.
⚠️ baseline.prof non-determinism
If baseline.prof differs, disable the ArtProfile task:
tasks.whenTaskAdded {
if (name.contains("ArtProfile")) {
enabled = false
}
}
⚠️ NDK .comment section (r26+)
NDK r26+ embeds a Clang version string that differs between macOS and Linux. Remove it:
objcopy --remove-section .comment <file>.so
Debugging non-reproducible builds
Get both APKs
- Reference: Download from GitHub Releases (developer-signed)
- Rebuilt: From F-Droid build server or local
fdroid build
Use diffoscope
diffoscope reference.apk rebuilt-unsigned.apk
Common differences and their causes
| 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) |
Check toolchain versions match exactly
flutter --version
java -version
Checklist for a new Flutter project going on F-Droid
References