diff --git a/README.md b/README.md
index 0efa6bc..8046b62 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/package.json b/package.json
index 95f3478..0a82b90 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/fdroid-publish.sh b/scripts/fdroid-publish.sh
new file mode 100755
index 0000000..8fcc0a6
--- /dev/null
+++ b/scripts/fdroid-publish.sh
@@ -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
/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 /repo (and /archive if you like), never 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" <-
+ 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 <
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+