TrackerControl builds release APK payloads on Linux so that external rebuilders can reproduce the native Rust libraries. The long-lived Android signing key stays on the release maintainer's Mac and is never uploaded to GitHub Actions.
The release has two stages:
- A numeric tag triggers
.github/workflows/release-build.yml. It performs a clean, unsignedgithubReleasebuild on Ubuntu 22.04 with pinned Android, Rust, cargo-ndk, and Java toolchains. scripts/build_and_sign.shdownloads that exact workflow artifact, checks that its commit matches the tag, signs it locally, verifies the certificate, and creates a draft GitHub release.
Install and authenticate the GitHub CLI, and ensure Android SDK Build Tools are installed:
gh auth login
"$HOME/Library/Android/sdk/cmdline-tools/latest/bin/sdkmanager" \
'build-tools;36.1.0'Place the release keystore at trackercontrol.jks in the repository root. The
path is ignored by Git. A different path can be supplied with
RELEASE_STORE_FILE.
The script expects the alias trackercontrol. Override it with
RELEASE_KEY_ALIAS if necessary.
Store both passwords in macOS Keychain under the service names used by the existing release process:
security add-generic-password -U -a "$USER" \
-s android_keystore_password -w
security add-generic-password -U -a "$USER" \
-s android_key_password -wKeep an offline backup of the keystore and its passwords. The expected public certificate SHA-256 is hard-coded in the script so that the wrong keystore cannot accidentally sign a release.
First update and commit versionCode and versionName in app/build.gradle.
Create and push the numeric release tag:
git tag 2026071301
git push origin 2026071301Wait for Build unsigned GitHub release APK to finish successfully. Then run:
./scripts/build_and_sign.sh 2026071301The script writes the signed APK and verification metadata to
output/release-2026071301/ and creates a draft GitHub release containing:
TrackerControl-githubRelease-latest.apkTrackerControl-githubRelease-latest.apk.sha256sumsTrackerControl-githubRelease-latest.apk.build-info.txt
The two metadata files are named with the APK filename as a prefix so they
always sort alphabetically after it. The GitHub API returns release assets
ordered by name, and the app's in-app update checker always downloads
assets[0]; without this ordering it can fetch BUILD-INFO.txt instead of
the APK (#681).
Smoke-test the signed APK (next section), then publish the release in GitHub.
If more than one successful build exists for the same commit, pass the desired Actions run ID explicitly:
./scripts/build_and_sign.sh 2026071301 1234567890Test the artifact that will actually ship — the signed APK in
output/release-<tag>/, not a local debug build. It installs as
net.kollnig.missioncontrol, which is a different package from both the
.fdroid and .test debug installs, so it lands side by side and touches no
existing app data.
The release build is not debuggable, so the AGENTS.md popup-skipping recipe
only half applies: run-as is refused (package not debuggable), so the
onboarding preferences cannot be seeded and onboarding must be tapped through.
appops and pm grant still work, because those are enforced by the framework
rather than by the app's debuggable flag.
TAG=2026071301
adb install -r output/release-$TAG/TrackerControl-githubRelease-latest.apk
# Confirm the artifact under test is the one that was just built and signed.
adb shell dumpsys package net.kollnig.missioncontrol | grep -E 'versionCode|versionName'
# Optional: pre-grant VPN consent so VpnService.prepare() returns null and the
# consent dialog never appears. Works on the release package; run-as does not.
adb shell appops set net.kollnig.missioncontrol ACTIVATE_VPN allow
adb logcat -c
adb shell monkey -p net.kollnig.missioncontrol -c android.intent.category.LAUNCHER 1Announce the VPN side effect before enabling the tunnel. Starting this
build's VPN calls VpnService.prepare(), which revokes whichever package
currently holds VPN consent — typically the maintainer's own .fdroid or
.test build, or their real VPN app. Those will show the consent dialog again
next time. Check the current holder and consent state first, and ask before
proceeding:
adb shell dumpsys connectivity | grep -oE 'VPN:[a-zA-Z0-9._]+' | sort -u
for p in net.kollnig.missioncontrol{,.fdroid,.test}; do
printf '%s: ' "$p"; adb shell appops get "$p" ACTIVATE_VPN
doneTap through onboarding. ActivityOnboarding.setupSlides() builds the list once
from device state, so pre-grant first (install -g plus the appops line
above) and the two permission slides drop out, leaving a fixed five-slide run:
welcome → blocking mode → VPN lockdown → Disable Private DNS → timeline.
Without pre-granting you also get the Enable Protection slide (third) and
the Stay Informed notification slide, and the VPN slide raises the system
consent dialog.
Three of these slides raise an "Are you sure?" confirmation on Next, and the
dialog's OK button moves vertically with the length of the message — so resolve
it rather than hardcoding it. Coordinates below are for 1080×2400; check
with adb shell wm size.
# Tap the OK button of the current AlertDialog, wherever it happens to sit.
ok() {
adb shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1
adb shell cat /sdcard/ui.xml \
| grep -oE '<node[^>]*text="OK"[^>]*bounds="\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]"' \
| grep -oE 'bounds="[^"]*"' | head -1 \
| sed -E 's/bounds="\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\]"/\1 \2 \3 \4/' \
| awk '{ printf "%d %d\n", ($1+$3)/2, ($2+$4)/2 }' \
| xargs adb shell input tap
}
# Print the current slide's title and buttons.
where() {
adb shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1
adb shell cat /sdcard/ui.xml | grep -oE 'text="[^"]{3,60}"' \
| sed 's/text="//;s/"$//' | head -4
}
adb shell input tap 922 2256 # Next — welcome
adb shell input tap 922 2256 # Next — blocking mode (leave Minimal)
adb shell input tap 922 2256 # Next — VPN lockdown → confirmation
ok
adb shell input tap 922 2256 # Next — Disable Private DNS → confirmation
ok
adb shell input tap 878 2256 # Get Started — timeline, last slide
adb shell input tap 178 2256 # Timeline tab
adb shell input tap 540 2256 # Apps tabCall where between steps if anything desyncs; a slide whose bottom-right
button reads Get Started instead of Next is the last one, which is the
cheap check that the run ended where it should. Screenshot the result with
adb exec-out screencap -p > shot.png.
Two slides here are worth reading rather than clicking past, because both fire
on a device where the underlying setting is not actually a problem — measured
on a Pixel 8 (Android 17) with always_on_vpn_lockdown=0 and
private_dns_mode=opportunistic:
- VPN Lockdown Setting offers "Open VPN Settings" and warns on Next even
though lockdown is off. Per
refreshSlides()it should read "Disabled" with no warning on Q+. - Disable Private DNS appears for any mode other than
off(Util.isPrivateDns), soopportunistic— Android's default — triggers a slide telling the user their setup is broken when DoT is not actually in use. #711 removes this slide; once that lands, the sequence above loses its secondokstep.
Checks that must all pass before publishing:
- Correct build installed —
versionCode/versionNamematch the tag. - Onboarding completes without a crash, and lands on the main screen.
- Tunnel establishes —
tun0exists and connectivity names this package:adb shell ip -o addr show | grep tun adb shell dumpsys connectivity | grep -oE 'VPN:[a-zA-Z0-9._]+' | sort -u
- Detection works end to end — generate traffic
(
adb shell am start -a android.intent.action.VIEW -d https://example.com, or open a tracker-heavy app), then confirm the Timeline tab lists real detections attributed to the right apps. An empty Timeline after real traffic means DNS parsing or attribution regressed. - Apps tab renders the full app list with icons.
- No crashes across the whole run:
adb logcat -d | grep -E 'FATAL EXCEPTION|E/AndroidRuntime'
Then verify the draft's asset order — the in-app updater takes assets[0], so
the APK must sort first (#681):
gh release view "$TAG" --json assets --jq '.assets[].name'Publish, and confirm the release is no longer a draft and is served as latest:
gh release edit "$TAG" --draft=false --latest
gh api repos/TrackerControl/tracker-control-android/releases/latest \
--jq '.tag_name + " assets[0]=" + .assets[0].name'(gh release view --json isLatest is not a valid field; query the API's
releases/latest as above instead.)
Afterwards, tell the maintainer what the test changed on their device: the VPN
consent now sits with net.kollnig.missioncontrol and its tunnel is running.
Leave the package installed unless they ask for it to be removed — uninstalling
is state destruction and needs a yes, like everything else in AGENTS.md §3.
The workflow can also be started from the Actions UI with Run workflow and
a tag or commit in the ref input. It still produces only an unsigned artifact;
the signing key remains local.
The local script supports these environment variables:
GH_REPORELEASE_WORKFLOWRELEASE_STORE_FILERELEASE_STORE_PASSWORDRELEASE_KEY_ALIASRELEASE_KEY_PASSWORDRELEASE_CERT_SHA256RELEASE_BUILD_TOOLS_VERSIONRELEASE_OUTPUT_DIR