#!/usr/bin/env bash # Updates the F-Droid repository at https://halkyon.app/fdroid/repo and # uploads it: adds an APK if one is given, signs the index with the repository's # key, and uploads everything to the halkyon.app server as the user fdroid, whose key can only # write into the folder Caddy serves (rrsync). tool/release.dart runs it with each # release's APK; run alone, it publishes the repository as it is, e.g. after its # settings changed. # # tool/fdroid_repo.sh [APK] # tool/fdroid_repo.sh --check (the tools, the key and its password only) # # The repository's working folder, outside Git, keeps every APK released so far: # $HALKYON_FDROID_DIR (default below). Its settings and the apps' listings come # from tool/release/fdroid/, copied in at every run. The index key is a PKCS #12 # keystore at $HALKYON_FDROID_KEYSTORE, alias halkyon-fdroid, whose certificate's # SHA-256 is pinned in tool/release/fdroid-index-certificate.sha256: an index # signed otherwise would make the F-Droid app refuse the repository. Its password # comes from $HALKYON_FDROID_KEYSTORE_PASSWORD, or is asked for; it's never # written down. Needs fdroidserver v2.4.5 (pipx), rsync, curl, jq, a JDK, the Android # SDK, and the ssh host ramiel-fdroid. APKs released before that the working folder # lacks come from the repository first (restore, below). set -euo pipefail cd "$(git rev-parse --show-toplevel)" readonly working="${HALKYON_FDROID_DIR:-$HOME/.local/share/halkyon-learn/fdroid}" keystore="${HALKYON_FDROID_KEYSTORE:-$HOME/.local/share/halkyon-learn/fdroid-index.p12}" readonly key_alias='halkyon-fdroid' readonly pinned_file='tool/release/fdroid-index-certificate.sha256' readonly upload_host='ramiel-fdroid' readonly repo_url='https://halkyon.app/fdroid/repo' fail() { echo "✗ $*" >&2 exit 1 } step() { echo "▸ $*"; } [ $# -le 1 ] || fail 'Usage: tool/fdroid_repo.sh [APK | --check]' check_only=false apk='' case "${1:-}" in --check) check_only=true ;; '') ;; *) apk="$1" ;; esac for tool in fdroid rsync curl jq; do command -v "$tool" >/dev/null || fail "$tool is missing." done # fdroid takes jarsigner and keytool from the PATH, apksigner from the SDK. jdk_bin=$(dirname "$(command -v java || echo /snap/android-studio/current/jbr/bin/java)") [ -x "$jdk_bin/jarsigner" ] && [ -x "$jdk_bin/keytool" ] || fail 'No JDK found (jarsigner, keytool).' export PATH="$jdk_bin:$PATH" export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" [ -f "$keystore" ] || fail "No keystore at $keystore (HALKYON_FDROID_KEYSTORE)." keystore=$(realpath "$keystore") export HALKYON_FDROID_KEYSTORE="$keystore" [ -f "$pinned_file" ] || fail "$pinned_file is missing." pinned=$(grep -v '^#' "$pinned_file" | tr -d ' :\n' | tr 'a-f' 'A-F' || true) [[ "$pinned" =~ ^[0-9A-F]{64}$ ]] || fail "$pinned_file holds no SHA-256." if [ -z "${HALKYON_FDROID_KEYSTORE_PASSWORD:-}" ]; then read -rsp "Password of $keystore: " HALKYON_FDROID_KEYSTORE_PASSWORD echo fi export HALKYON_FDROID_KEYSTORE_PASSWORD # The F-Droid app knows the repository by this fingerprint: the certificate's # SHA-256 in capitals, without colons. fingerprint=$(keytool -list -v -keystore "$keystore" -alias "$key_alias" \ -storepass:env HALKYON_FDROID_KEYSTORE_PASSWORD 2>/dev/null | sed -n 's/^.*SHA256: *//p' | head -n 1 | tr -d ':' | tr 'a-f' 'A-F' || true) [ -n "$fingerprint" ] || fail "Wrong password, or no key $key_alias in $keystore." [ "$fingerprint" = "$pinned" ] || fail "The key $key_alias in $keystore isn't the one in $pinned_file." if $check_only; then exit 0 fi step 'Updating the F-Droid repository' mkdir -p "$working/repo" # A working folder on another laptop, or a new one, holds none of the APKs released # before, and the upload below mirrors it with --delete-after: so every APK the # published indexes name (repo and archive) comes from the repository first, unless the # folder has it, its SHA-256 checked against the index's either way. The index is the # repository's own, over HTTPS; the check guards against a broken download, not # against the halkyon.app server. restore() { local dir="$1" status name hash type mkdir -p "$working/$dir" status=$(curl -sSL -o "$working/$dir/.published-index" -w '%{http_code}' \ "${repo_url%/repo}/$dir/index-v1.json") || fail "Can't reach the published $dir index." case "$status" in 200) ;; 404) return 0 ;; # nothing published there yet *) fail "The published $dir index answered $status." ;; esac jq -r '.packages[][] | [.apkName, .hash, .hashType] | @tsv' "$working/$dir/.published-index" | while IFS=$'\t' read -r name hash type; do [ "$type" = sha256 ] || fail "$name: the index gives no SHA-256." [[ "$name" =~ ^[A-Za-z0-9._-]+\.apk$ ]] || fail "An APK named $name in the index." if [ ! -f "$working/$dir/$name" ]; then echo " $dir/$name from the repository" curl -sSfL -o "$working/$dir/$name.part" "${repo_url%/repo}/$dir/$name" mv "$working/$dir/$name.part" "$working/$dir/$name" fi echo "$hash $working/$dir/$name" | sha256sum --check --quiet || fail "$dir/$name isn't the APK the index names." done rm "$working/$dir/.published-index" } restore repo restore archive cp -r tool/release/fdroid/. "$working/" # What's new in the release as each language's changelog of its build # (metadata///changelogs/.txt), which tool/release.dart # writes from the app's texts and names here; kept in the working folder after. if [ -n "${HALKYON_FDROID_METADATA:-}" ]; then cp -r "$HALKYON_FDROID_METADATA/." "$working/metadata/" fi # fdroid refuses a config.yml others can read. chmod 600 "$working/config.yml" # Each app's own icon in the F-Droid app (the repository's is the kingfisher, # halkyon.png in tool/release/fdroid/): Learn's and Todo's their snaps', Train's its # launcher's source. fdroid can't find the launcher icon in a release APK, whose build shortens # the resources' paths (res/9w.png rather than res/mipmap-hdpi/ic_launcher.png; # fdroidserver issue 885). for icon in app.halkyon.learn:snap/gui/halkyon-learn.png \ app.halkyon.train:apps/train/halkyon_train_flutter/assets/icon/halkyon_train_icon.png \ app.halkyon.todo:apps/todo/snap/gui/halkyon-todo.png; do mkdir -p "$working/metadata/${icon%%:*}/en-US" cp "${icon#*:}" "$working/metadata/${icon%%:*}/en-US/icon.png" done if [ -n "$apk" ]; then [ -f "$apk" ] || fail "No APK at $apk." # Once in the working folder (an earlier run whose upload failed), the same APK # again would be a second file of the same build, which fdroid refuses. apk_hash=$(sha256sum "$apk" | cut -d' ' -f1) if sha256sum "$working"/repo/*.apk "$working"/archive/*.apk 2>/dev/null | grep -q "^$apk_hash "; then echo " $(basename "$apk") is in the repository already" else cp "$apk" "$working/repo/" fi fi # Names every APK as F-Droid does (_.apk) and signs the # indexes with the repository's key. (cd "$working" && fdroid update --rename-apks) step "Uploading it to $upload_host" # The APKs and icons first, the indexes last, so the F-Droid app never reads an # index naming a file that isn't there yet. By their contents (--checksum), not their # times: a working folder restored on another laptop has every APK with a new time, # and would send them all again (1.9 GB in October 2026, on a disk with less free). for dir in repo archive; do [ -d "$working/$dir" ] || continue rsync -a --checksum --chmod=D755,F644 --exclude='index*' --exclude='entry.*' \ "$working/$dir/" "$upload_host:$dir/" done for dir in repo archive; do [ -d "$working/$dir" ] || continue rsync -a --checksum --chmod=D755,F644 --delete-after "$working/$dir/" "$upload_host:$dir/" done echo " $repo_url?fingerprint=$fingerprint"