How to Publish an Android App on F-Droid
Table of Contents
- What F-Droid actually requires
- Install the tools first
- Split your build so F-Droid only sees what it needs
- The one reproducibility trap I hit
- Build inside the same container F-Droid uses
- Prove it's reproducible before you ask anyone else to trust it
- Automate the release with Fastlane
- Generate a signing key first
- Submit the first version to F-Droid
- What changes after you're in
- Doing it twice
I've now taken two Android apps through the F-Droid submission process: C2K and Jargon. The second time was faster because I had already solved the parts that aren't obvious from F-Droid's documentation: making a Gradle build reproducible, proving that in CI, and automating the release so I don't have to remember eleven steps every time I tag a version.
This is that process, laid out so you can repeat it. I'll use C2K as the running example, since its pipeline is the more built-out of the two.
What F-Droid actually requires
F-Droid doesn't host your APK. It builds your app from source, on its own servers, and distributes the binary it produced. That's the entire trust model: users aren't trusting your build machine, they're trusting that F-Droid's build matches your source.
That means some things have to be true before you submit anything:
- Your app builds from a clean checkout with no manual steps.
- The build doesn't depend on proprietary SDKs your users can't get (no Google Play Services, Firebase, or closed ad/analytics libraries in the build that F-Droid ships).
- Your license permits redistribution. C2K and Jargon are both GPL-3.0-only.
If your app already meets those, the rest of this post is about the parts that are easy to get wrong: getting the same bytes out of two separate builds, and proving it.
F-Droid also runs your dependency tree through fdroid scanner, a check against a known list of non-free and tracking libraries: ad SDKs, analytics, Firebase, and similar. It's not just about your own code; a leftover dependency you never call can still get the build flagged under one of F-Droid's antifeature categories (Ads, Tracking, NonFreeDep, NonFreeNet, and others). Worth a look at your build.gradle dependencies before you fork anything.
Two decisions are also worth getting right before you write a line of metadata. Your applicationId is effectively permanent: once F-Droid and Play both know your app by that name, renaming it means starting over as a new listing everywhere. And versionCode has to increase, forever, across every place you ship the app; F-Droid, Play, and your GitHub releases all have to agree on the same ascending sequence.
Install the tools first
None of the commands below do anything until these are in place:
- JDK 21, via
sdk install java 21.0.5-temif you use SDKMAN, or your platform's package manager. - An Android SDK with
platform-toolsand a recentbuild-toolsversion, installed through Android Studio or the standalonesdkmanager. This is whereapksignercomes from. - Docker, for the reproducible build container.
- Ruby and Bundler, for Fastlane. C2K's
Gemfileis one line,gem "fastlane"; runbundle installonce to pull it in. - The GitHub CLI, authenticated with
gh auth login. Thereleaselane shells out togh release createat the end, so it needs to already be able to talk to your account.
With those in place, everything from here on is running scripts that already exist in the repository.
Split your build so F-Droid only sees what it needs
C2K also ships on Google Play, so app/build.gradle.kts defines two product flavors:
flavorDimensions += "store"
productFlavors {
create("foss") {
dimension = "store"
}
create("play") {
dimension = "store"
if (localProps["storeFile"] != null) {
signingConfig = signingConfigs.getByName("release")
}
}
}
foss and play come from the same source and the same applicationId. Only the signing config differs. F-Droid signs foss itself, so that flavor never touches your keystore. If your app only ever ships on F-Droid, you don't need this split at all; a single default flavor is fine.
The one reproducibility trap I hit
Android Gradle Plugin generates an ART baseline profile as part of a release build, and the code that serializes it iterates a HashMap without sorting. Two clean builds of the identical commit can produce a byte-different profile purely from hash-order variance, which fails F-Droid's reproducibility check for a reason that has nothing to do with your code. The fix, in app/build.gradle.kts, is to disable it for the flavor F-Droid builds:
// ART baseline profile generation (assets/dexopt/baseline.prof(m)) is not reproducible:
// AGP's ArtProfile.kt iterates a HashMap<DexFile, DexFileData> without sorting when
// serializing some profile formats, so byte order can differ build-to-build even with
// otherwise identical output. Disabling it is the documented workaround —
// see https://gist.github.com/obfusk/61046e09cee352ae6dd109911534b12e
// Only F-Droid (foss flavor) needs reproducibility, so Play builds keep the
// profile for better startup performance.
tasks.configureEach {
if (name.contains("ArtProfile") && !name.contains("Play")) {
enabled = false
}
}
I found this by building twice and diffing the APKs, which is exactly the check you should automate before you ever open a merge request. More on that below.
Build inside the same container F-Droid uses
F-Droid's build servers run a known image. Building locally inside that same image, rather than against whatever JDK and SDK happen to be on your laptop, is what makes "reproducible" a testable claim instead of a hope.
docker-build.sh, in the root of the app repository, does that:
#!/bin/bash
set -e
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GRADLE_CACHE_DIR="${GRADLE_DOCKER_CACHE:-$HOME/.gradle-docker}"
# Preserve any existing local.properties (holds real signing credentials on dev
# machines); CI checkouts won't have one, so just leave it absent afterward.
HAD_LOCAL_PROPERTIES=0
if [ -f "$PROJECT_DIR/local.properties" ]; then
HAD_LOCAL_PROPERTIES=1
cp "$PROJECT_DIR/local.properties" /tmp/local.properties.bak
fi
printf 'sdk.dir=/opt/android-sdk\n' > "$PROJECT_DIR/local.properties"
docker run --rm \
-v "$PROJECT_DIR:/workspace:z" \
-v "$GRADLE_CACHE_DIR:/root/.gradle:z" \
-e JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
-w /workspace \
registry.gitlab.com/fdroid/fdroidserver:buildserver-trixie \
bash -c "apt-get install -y -q openjdk-21-jdk-headless 2>/dev/null && \
./gradlew --no-daemon -Dorg.gradle.java.home=/usr/lib/jvm/java-21-openjdk-amd64 \
--no-build-cache clean :app:assembleFossRelease; \
status=\$?; chown -R \$HOST_UID:\$HOST_GID /workspace /root/.gradle; exit \$status"
# Restore (or remove) local.properties
if [ "$HAD_LOCAL_PROPERTIES" -eq 1 ]; then
cp /tmp/local.properties.bak "$PROJECT_DIR/local.properties"
else
rm -f "$PROJECT_DIR/local.properties"
fi
ls -la "$PROJECT_DIR/app/build/outputs/apk/foss/release/"
The image is fdroidserver:buildserver-trixie, F-Droid's own build server container, pulled straight from their GitLab registry. Whatever JDK and toolchain quirks their infrastructure has, you're building against the same ones. The script mounts the Gradle cache from a persistent host directory (~/.gradle-docker by default) so every run doesn't re-download the world, but it bind-mounts the workspace itself read-write and discards it; nothing about the build depends on container state surviving between runs. It writes a throwaway local.properties with only an SDK path, builds, then restores whatever was there before, so your signing credentials never enter the container. The output is app-foss-release-unsigned.apk. F-Droid applies its own signature; you never hand them a signed artifact.
Run it yourself with bash docker-build.sh. That's the same command both my release automation and CI use. There's exactly one way this build happens, whether it's on my machine or GitHub's.
Prove it's reproducible before you ask anyone else to trust it
A reproducible-build job in .github/workflows/ci.yml runs that script twice on every push and fails the build if the two APKs don't match byte for byte:
reproducible-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Cache Gradle Docker dependencies
uses: actions/cache@v5
with:
path: ~/.gradle-docker
key: gradle-docker-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'build.gradle.kts') }}
restore-keys: gradle-docker-
- name: First build
run: bash docker-build.sh
- name: Save first build artifact
run: cp app/build/outputs/apk/foss/release/app-foss-release-unsigned.apk /tmp/build1.apk
- name: Second build
run: bash docker-build.sh
- name: Compare build hashes
run: |
h1=$(sha256sum /tmp/build1.apk | cut -d' ' -f1)
h2=$(sha256sum app/build/outputs/apk/foss/release/app-foss-release-unsigned.apk | cut -d' ' -f1)
echo "Build 1: $h1"
echo "Build 2: $h2"
if [ "$h1" != "$h2" ]; then
echo "::error::Build is not reproducible — two clean builds of the same commit produced different output."
exit 1
fi
echo "Build is reproducible."
This job would have caught the ART baseline profile problem immediately, instead of me finding it after F-Droid's build didn't match mine. If you're setting this pipeline up for the first time, add this check before you write a line of F-Droid metadata. It's the cheapest place to catch a reproducibility break: on your own CI, on every commit, instead of on a merge request months later.
The same ci.yml also runs plain unit tests and, in a separate instrumented-test job, instrumented tests against a real emulator:
instrumented-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
cache: gradle
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run Room DAO instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
target: google_apis
profile: pixel_6
script: ./gradlew connectedFossDebugAndroidTest --console=plain
The KVM step matters and is easy to skip past: without hardware acceleration enabled on the runner, the emulator boots slowly enough to time the job out. Unit tests, instrumented tests, and the reproducibility check all have to pass on the same push before I'd trust a build enough to tag it.
Automate the release with Fastlane
I wrote about why I use Fastlane for C2K in more detail elsewhere. For F-Droid specifically, the part that matters is the release lane in fastlane/Fastfile, because it's the one place everything above comes together:
lane :release do
project_dir = File.expand_path("..", __dir__)
ensure_git_status_clean
gradle_kts = File.read(File.join(project_dir, "app/build.gradle.kts"))
version_name = gradle_kts[/versionName\s*=\s*"([^"]+)"/, 1]
version_code = gradle_kts[/versionCode\s*=\s*(\d+)/, 1]
tag = "v#{version_name}"
add_git_tag(tag: tag)
UI.message("Running reproducible docker build (this takes a few minutes)...")
sh("bash '#{project_dir}/docker-build.sh'")
UI.message("Signing...")
# ...reads storePassword/keyPassword from local.properties, signs with apksigner...
sha256 = sh("sha256sum '#{release_apk}' | cut -d' ' -f1").strip
UI.important("Built and signed #{tag}. SHA-256: #{sha256}")
UI.important("Local tag created but NOT pushed yet — nothing public has happened.")
UI.user_error!("Aborted — tag left local, nothing pushed.") unless UI.confirm("Push #{tag} to origin and create the GitHub release now?")
sh("git push origin #{tag.shellescape}")
sh("gh release create #{tag.shellescape} #{release_apk.shellescape} --title #{tag.shellescape} --notes #{body.shellescape}")
end
The order is deliberate. The lane creates the tag locally, then runs the reproducible build, then signs a copy of that same APK for the GitHub release, then shows me the real SHA-256 and stops. Only after I confirm does anything reach origin or GitHub. Everything before that confirmation is local and can be undone by deleting the tag. Nothing announces itself to F-Droid, GitHub, or anyone else until I've looked at the hash and said yes.
Generate a signing key first
The release lane expects a keystore to already exist, and it reads its passwords from local.properties. If you've never signed an app before, this is a one-time setup, not something F-Droid asks for: F-Droid builds and signs foss itself. You only need a key at all because something has to sign the GitHub release copy, and Google Play's play flavor needs one too.
Generate it once with keytool, which ships with the JDK:
keytool -genkeypair -v \
-keystore ~/c2k-release.jks \
-alias c2k \
-keyalg RSA -keysize 4096 \
-validity 10000
It'll prompt for a store password, a key password, and the certificate's distinguished name (your name, organization, and so on). None of that data leaves your machine. Then point local.properties at it:
sdk.dir=/home/YOUR_USER/Android/Sdk
storeFile=/home/YOUR_USER/c2k-release.jks
storePassword=YOUR_STORE_PASS
keyAlias=c2k
keyPassword=YOUR_KEY_PASS
Git ignores local.properties; never commit it. This keystore is what the release lane signs the GitHub release APK with, what the play flavor's signing config in app/build.gradle.kts uses for Google Play, and the certificate whose fingerprint you'll pin as AllowedAPKSigningKeys in the F-Droid metadata below.
A full release from a clean working tree looks like this:
- Bump
versionCodeandversionNameinapp/build.gradle.kts. - Add a changelog at
fastlane/metadata/android/en-US/changelogs/<versionCode>.txt, and the other locale folders if you translate them. - Commit that version bump.
- Run
bundle exec fastlane releasefrom an interactive terminal (the confirmation prompt aborts in a non-interactive shell).
F-Droid picks up the new tag automatically once it's pushed. No merge request, no waiting. That part only happens once, for your first submission.
Submit the first version to F-Droid
This is the one-time step. Everything before it happens on your own repository; this happens on F-Droid's.
F-Droid's app definitions live in a single repository, fdroiddata, on GitLab. Fork it, clone your fork, and add a metadata file for your app at metadata/<your.package.id>.yml. metadata/com.hackerapps.c2k.yml below is the real file I submitted for C2K, not a template: the exact version from the merge request that got accepted.
Categories:
- Timer
- Workout
License: GPL-3.0-only
AuthorName: Matt Brown
AuthorEmail: c2k@hackerapps.com
AuthorWebSite: https://hackerapps.com
SourceCode: https://github.com/AnalogGhost/c2k
IssueTracker: https://github.com/AnalogGhost/c2k/issues
AutoName: C2K
RepoType: git
Repo: https://github.com/AnalogGhost/c2k
Binaries: https://github.com/AnalogGhost/c2k/releases/download/v%v/app-release.apk
Builds:
- versionName: 1.0.3
versionCode: 4
commit: 5f567bb547e9188ed0c829f1f656002d86802868
subdir: app
gradle:
- yes
AllowedAPKSigningKeys: a4d8f51c09620c0bcbe2c3ed97958d1785c6c2ac7ab9fbf504650f6945407896
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: 1.0.3
CurrentVersionCode: 4
Most of the guides you'll find online are out of date on a few points here.
There's no Summary or Description field. F-Droid reads your store listing straight out of fastlane/metadata/android/en-US/short_description.txt and full_description.txt. Your Fastlane metadata already needs to exist for the changelogs your release lane reads anyway, so you get the F-Droid listing without doing anything extra.
If you've never created that metadata, it's just plain text files in a directory tree, one folder per locale:
fastlane/metadata/android/en-US/
├── title.txt
├── short_description.txt
├── full_description.txt
├── changelogs/
│ └── 1.txt
└── images/
└── phoneScreenshots/
└── 01.png
title.txt and short_description.txt are one line each; full_description.txt can run to a few paragraphs. changelogs/1.txt holds the release notes for versionCode: 1, matching the number in your first Builds entry. A screenshot isn't strictly required to pass review, but a listing with none looks unfinished next to everything else on F-Droid, so it's worth having at least one before you submit.
gradle: - yes looks like a typo. It isn't. It's F-Droid's literal keyword for "no product flavor, just build the module," from before I added the foss/play flavor split. Once that split existed, later entries switched to gradle: - foss.
AllowedAPKSigningKeys pins the exact signing certificate F-Droid will trust if it ever cross-checks against a binary you publish yourself: the fingerprint of the keystore from the signing setup above. It's a public value, not a secret. Get it with keytool -list -v -keystore your.jks and take the SHA-256.
Binaries points at your own GitHub release APK, built by the same release lane above and signed with the same key as AllowedAPKSigningKeys. F-Droid can build from source and diff the result against that binary as an extra reproducibility check, on top of building it themselves.
If you have fdroidserver installed (pip install fdroidserver), lint the file before you push anything:
fdroid lint metadata/com.hackerapps.c2k.yml
fdroid readmeta
Push your branch to your fork and open a merge request against fdroid/fdroiddata. A bot runs a real build check against your metadata automatically. For C2K, the MR went up on a Sunday evening and merged Tuesday, two days later, after a few small pushes to fix schema mistakes: a missing CurrentVersion/CurrentVersionCode pair, a wrong field order, and a build entry that needed the full 40-character commit hash instead of a short one. None of that took long to fix once the bot pointed at it. Two days isn't a promise; review time depends on their maintainer queue and how much your first submission needs fixing. The multi-week estimate you'll see quoted elsewhere isn't always what actually happens.
What changes after you're in
The merge request is the only time you touch F-Droid's repository. After that, UpdateCheckMode: Tags means their bot watches your GitHub tags and queues a new build the moment you push one, which is exactly what the release lane's last step does. No further MRs, no further review, just tag and wait.
The metadata file itself keeps evolving on F-Droid's side, though, and watching that evolution taught me things I wouldn't have known to ask about up front. A few versions in, a build entry in metadata/com.hackerapps.c2k.yml picked up this:
srclibs:
- reproducible-apk-tools@v0.3.0
postbuild:
- mv $$OUT$$ unaligned.apk
- $$reproducible-apk-tools$$/zipalign.py --page-size 16 --pad-like-apksigner
--replace unaligned.apk $$OUT$$
Google now requires 16 KB page-aligned native libraries, and apksigner pads for that during signing. F-Droid's own build doesn't sign with apksigner, so without this step its output wouldn't byte-match an APK built and signed the normal way. The reproducible-apk-tools srclib re-aligns F-Droid's build to match what apksigner would have produced.
Later entries in the same file also added this, which looks unrelated to Android entirely:
sudo:
- export CPUS_MAX=16
- export CPUS=$(getconf _NPROCESSORS_ONLN)
- for (( c=$CPUS_MAX; c<$CPUS; c++ )) ; do echo 0 > /sys/devices/system/cpu/cpu$c/online ; done
Some build steps in the Android toolchain parallelize based on available CPU count, and F-Droid's build servers don't necessarily have the same core count your Docker container does. Capping the visible CPUs keeps that parallelism, and the output it produces, consistent between environments. None of this was in F-Droid's onboarding documentation when I started. I found both fixes by watching what F-Droid's bot changed in my own metadata file after a build broke, which is honestly the most reliable source of truth for this whole process.
Doing it twice
Jargon went through the identical shape about two months after C2K: same docker-build.sh, unchanged, same reproducibility CI job, same fork-and-MR submission, its own signing key pinned with its own AllowedAPKSigningKeys. The only real difference was that I already knew where the sharp edges were.
That's the actual payoff of writing this pipeline down as code instead of as a memory of what I did last time: the second app didn't require me to remember anything. The Docker script, the CI job, and the release lane just worked, and the only new file I had to write was the metadata YAML.
- ← Previous
Read matbrown.dev courses offline - Next →
Book a Discovery Call