Publish Android releases to a self-hosted F-Droid repo

scripts/fdroid-publish.sh builds a signed arm64 APK and adds it to an
F-Droid repo in a given directory, creating the app signing key and the
repo on first use. The versionCode is the commit count, so any commit can
ship without a manual version bump.

Release builds are signed when src-tauri/gen/android/keystore.properties
exists and stay unsigned otherwise. The manifest picks up the deep-link
plugin's intent filter, which every build regenerates and was never
committed.
This commit is contained in:
2026-10-13 21:20:28 -05:00
parent ece21c1acf
commit 3a72780027
5 changed files with 305 additions and 1 deletions

View File

@@ -346,6 +346,55 @@ With the dev session running, Studio's device dropdown, Device Manager
To build without Studio and without a dev server, use `cargo tauri android
build` — that path needs no addr file.
### Distributing to phones (F-Droid)
Releases go out through a small F-Droid repo you host yourself: add it to the
F-Droid app once, and every new build then shows up there as an update.
`scripts/fdroid-publish.sh` builds a signed arm64 APK and adds it to the repo
in a directory you choose. It needs fdroidserver:
```bash
pipx install fdroidserver
```
The first run also makes the app's signing key and the repo. Give it the
address the repo will be served at:
```bash
FDROID_REPO_URL=https://fdroid.example.com/repo ./scripts/fdroid-publish.sh --init-keystore ~/fdroid-mtgc
```
After that, publishing is just:
```bash
./scripts/fdroid-publish.sh ~/fdroid-mtgc # or: npm run android:publish -- ~/fdroid-mtgc
```
It ends by printing the repo address with its fingerprint
(`https://…/repo?fingerprint=…`); add that in F-Droid under **Settings →
Repositories**.
- **Serve only `~/fdroid-mtgc/repo`** (and `archive/`, which holds older
versions, if you like) — any static file server will do. Never serve the
directory itself: it holds the repo signing key (`keystore.p12`) and its
password (`config.yml`).
- **Back up both keys**: `~/.local/share/mtgc/android-release.jks` with
`src-tauri/gen/android/keystore.properties` (the app key and its password),
and the repo directory's `keystore.p12` with `config.yml`. Android only
installs an update signed with the same key as the installed app, so
without the app key every phone would have to uninstall and reinstall.
- **Versions are automatic.** The Android versionCode is the number of commits
on `HEAD`, and the version shown is e.g. `0.1.0-44-gece21c1`, so any commit
can ship without a bump. That's also why pushed history shouldn't be
rewritten. The script won't publish uncommitted changes without
`--allow-dirty`, or a versionCode the repo already has.
- A phone with a debug build, or anything signed with another key, has to
uninstall it before F-Droid can install the release, and uninstalling
deletes the app's data. Sync or **Export library…** first.
Without `keystore.properties`, `cargo tauri android build` still produces an
unsigned APK as before.
### Card data on a phone
Downloading card data streams ~740MB of JSON to build a ~73MB database, which

View File

@@ -9,7 +9,8 @@
"preview": "vite preview",
"tauri": "tauri",
"sync-server:build": "scripts/sync-server-image.sh build",
"sync-server:push": "scripts/sync-server-image.sh push"
"sync-server:push": "scripts/sync-server-image.sh push",
"android:publish": "scripts/fdroid-publish.sh"
},
"dependencies": {
"@tauri-apps/api": "^2",

218
scripts/fdroid-publish.sh Executable file
View File

@@ -0,0 +1,218 @@
#!/usr/bin/env bash
#
# fdroid-publish.sh — build a signed release APK and add it to an F-Droid repo.
#
# Usage:
# FDROID_REPO_URL=https://fdroid.example.com/repo \
# ./scripts/fdroid-publish.sh --init-keystore ~/fdroid-mtgc # first time
# ./scripts/fdroid-publish.sh ~/fdroid-mtgc # every release after
#
# Or through npm: `npm run android:publish -- ~/fdroid-mtgc`.
#
# Options:
# --init-keystore generate the app signing key (once, ever) and keystore.properties
# --allow-dirty publish uncommitted changes; the version gets a -dirty suffix
#
# Environment:
# MTGC_FDROID_DIR repo directory, if not given as an argument
# FDROID_REPO_URL public address of <dir>/repo; only needed the first time, to create the repo
# MTGC_ANDROID_KEYSTORE where --init-keystore puts the key (default ~/.local/share/mtgc/android-release.jks)
#
# Serve <dir>/repo (and <dir>/archive if you like), never <dir> itself: it holds
# the repo signing key and its password. Needs fdroidserver (`pipx install
# fdroidserver`) and the Android setup from ./scripts/android-setup.sh.
#
set -euo pipefail
usage() {
sed -n '3,23p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[err]\033[0m %s\n' "$*" >&2; }
APP_ID="com.garrettmills.mtgc"
APP_KEYSTORE="${MTGC_ANDROID_KEYSTORE:-$HOME/.local/share/mtgc/android-release.jks}"
APP_KEY_ALIAS="mtgc"
init_keystore=false
allow_dirty=false
dir="${MTGC_FDROID_DIR:-}"
while [ $# -gt 0 ]; do
case "$1" in
--init-keystore) init_keystore=true ;;
--allow-dirty) allow_dirty=true ;;
-h | --help) usage ;;
-*) usage 1 ;;
*) dir="$1" ;;
esac
shift
done
[ -n "$dir" ] || usage 1
mkdir -p "$dir"
dir="$(cd "$dir" && pwd)"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
key_props="src-tauri/gen/android/keystore.properties"
# --- 1. Tools ---------------------------------------------------------------
source scripts/android-env.sh >/dev/null
if ! command -v fdroid >/dev/null 2>&1; then
err "fdroid not found. Install fdroidserver: pipx install fdroidserver"
exit 1
fi
build_tools="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)"
apksigner="$build_tools/apksigner"
if [ ! -x "$apksigner" ]; then
err "apksigner not found under $ANDROID_HOME/build-tools (install a build-tools package)"
exit 1
fi
# --- 2. App signing key -----------------------------------------------------
#
# Every update must be signed with the same key as the installed app, so this
# key is made once and kept forever. Lose it, and each phone has to uninstall
# the app before it will take a newer build.
if $init_keystore; then
if [ -e "$APP_KEYSTORE" ] || [ -e "$key_props" ]; then
err "a signing key already exists ($APP_KEYSTORE or $key_props); not overwriting it"
exit 1
fi
info "generating the app signing key at $APP_KEYSTORE"
mkdir -p "$(dirname "$APP_KEYSTORE")"
password="$(openssl rand -hex 24)"
keytool -genkeypair -keystore "$APP_KEYSTORE" -storetype PKCS12 \
-storepass "$password" -alias "$APP_KEY_ALIAS" \
-keyalg RSA -keysize 4096 -validity 10000 -dname "CN=mtgc" >/dev/null
chmod 600 "$APP_KEYSTORE"
(
umask 077
printf 'storeFile=%s\npassword=%s\nkeyAlias=%s\n' "$APP_KEYSTORE" "$password" "$APP_KEY_ALIAS" >"$key_props"
)
warn "Back up $APP_KEYSTORE and $key_props (it holds the password)."
warn "Without them, phones can't update the app without uninstalling it first."
elif [ ! -f "$key_props" ]; then
err "no app signing key ($key_props). Run once with --init-keystore."
exit 1
fi
# --- 3. Version -------------------------------------------------------------
#
# The versionCode is the commit count, so every commit on main can ship with no
# manual bump and F-Droid sees each one as newer than the last.
version_code="$(git rev-list --count HEAD)"
base_version="$(node -p "require('./src-tauri/tauri.conf.json').version")"
version_name="$base_version-$version_code-g$(git rev-parse --short HEAD)"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
if ! $allow_dirty; then
err "the working tree has uncommitted changes; commit them or pass --allow-dirty"
exit 1
fi
version_name="$version_name-dirty"
fi
# --- 4. The repo, the first time --------------------------------------------
if [ ! -f "$dir/config.yml" ]; then
if [ -z "${FDROID_REPO_URL:-}" ]; then
err "no F-Droid repo in $dir yet; set FDROID_REPO_URL to the address $dir/repo will be served at"
exit 1
fi
info "creating the F-Droid repo in $dir"
(cd "$dir" && fdroid init --android-home "$ANDROID_HOME" --no-prompt -q)
cat >>"$dir/config.yml" <<EOF
repo_url: ${FDROID_REPO_URL%/}
repo_name: mtgc
repo_description: >-
Releases of mtgc, a personal Magic: The Gathering library catalog.
archive_older: 3
EOF
# `fdroid update` copies this into repo/icons/ itself.
cp src-tauri/icons/icon.png "$dir/icon.png"
fi
if [ ! -f "$dir/metadata/$APP_ID.yml" ]; then
mkdir -p "$dir/metadata"
cat >"$dir/metadata/$APP_ID.yml" <<'EOF'
Categories:
- Games
License: MIT
AuthorName: Garrett Mills
SourceCode: https://code.garrettmills.dev/garrettmills/mtgc
Name: mtgc
Summary: 'Personal Magic: The Gathering library catalog'
Description: |-
Catalog your Magic: The Gathering collection: rapid fuzzy card entry, rich
search (colors, commander color identity, mana value, type, price, format
legality) and deck organization. Works offline, and syncs between devices
through an mtgc sync server.
EOF
fi
# Release builds shorten resource paths, so `fdroid update` can't find the
# launcher icon inside the APK; hand it over as metadata instead.
if [ ! -f "$dir/metadata/$APP_ID/en-US/icon.png" ]; then
mkdir -p "$dir/metadata/$APP_ID/en-US"
cp src-tauri/icons/icon.png "$dir/metadata/$APP_ID/en-US/icon.png"
fi
# --- 5. Only ever move forward ----------------------------------------------
newest=0
for apk in "$dir"/repo/"${APP_ID}"_*.apk "$dir"/archive/"${APP_ID}"_*.apk; do
[ -e "$apk" ] || continue
code="${apk##*_}"
code="${code%.apk}"
[ "$code" -gt "$newest" ] && newest="$code"
done
if [ "$version_code" -le "$newest" ]; then
err "versionCode $version_code is not newer than $newest, already in the repo; commit something first"
exit 1
fi
# --- 6. Build ---------------------------------------------------------------
info "building $version_name (versionCode $version_code)"
outputs="src-tauri/gen/android/app/build/outputs/apk/universal/release"
rm -rf "$outputs"
cargo tauri android build --apk --target aarch64 --ci \
--config "{\"version\":\"$version_name\",\"bundle\":{\"android\":{\"versionCode\":$version_code}}}"
apk="$(find "$outputs" -name '*.apk' | head -1)"
if [ -z "$apk" ]; then
err "the build produced no APK under $outputs"
exit 1
fi
case "$apk" in
*unsigned*) err "the APK came out unsigned; check $key_props"; exit 1 ;;
esac
"$apksigner" verify "$apk"
# --- 7. Publish -------------------------------------------------------------
cp "$apk" "$dir/repo/${APP_ID}_${version_code}.apk"
info "updating the repo index"
(cd "$dir" && fdroid update -q)
# --- 8. Where to point F-Droid ----------------------------------------------
cfg() { sed -n "s/^$1: *//p" "$dir/config.yml" | head -1; }
fingerprint="$(keytool -list -v -keystore "$dir/$(cfg keystore)" -storepass "$(cfg keystorepass)" \
-alias "$(cfg repo_keyalias)" | sed -n 's/^[[:space:]]*SHA256: *//p' | tr -d ':')"
info "published $version_name"
cat <<EOF
Serve $dir/repo at $(cfg repo_url)
(not $dir itself — it holds the repo signing key). Add this to F-Droid:
$(cfg repo_url)?fingerprint=$fingerprint
EOF

View File

@@ -1,3 +1,4 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
@@ -13,6 +14,15 @@ val tauriProperties = Properties().apply {
}
}
// Release signing key, written by `scripts/fdroid-publish.sh --init-keystore`.
// Without it, release builds come out unsigned as before.
val keystoreProperties = Properties().apply {
val propFile = rootProject.file("keystore.properties")
if (propFile.exists()) {
FileInputStream(propFile).use { load(it) }
}
}
android {
compileSdk = 36
namespace = "com.garrettmills.mtgc"
@@ -24,6 +34,16 @@ android {
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
signingConfigs {
if (!keystoreProperties.isEmpty) {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("password")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("password")
}
}
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
@@ -37,6 +57,7 @@ android {
}
}
getByName("release") {
signingConfigs.findByName("release")?.let { signingConfig = it }
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }

View File

@@ -28,6 +28,21 @@
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<intent-filter >
<action android:name="android.intent.action.VIEW" />
<!-- ChromeOS ARC++ uses a different action for deep links -->
<action android:name="org.chromium.arc.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mtgc" />
<data android:host="auth" />
<data android:path="/callback" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<provider