droid: build, sign and publish the APK to the forge's F-Droid registry

12bf40f · Gabriel Morell · 2026-09-29 23:20

7 files +307 -0
Message
{commit_body(@commit)}

Files changed

added .github/workflows/android.yml
+140 −0
@@ -0,0 +1,140 @@
1 +name: Android
2 +
3 +# The Android live wallpaper in droid/. Adapted from Ova's workflow.
4 +#
5 +# Kept in .github/workflows beside package.yml: Forgejo reads
6 +# .forgejo/workflows *instead of* .github/workflows when it exists, so
7 +# putting this there would quietly switch the deb/rpm builds off.
8 +on:
9 + push:
10 + branches: [master]
11 + paths:
12 + - 'droid/**'
13 + # The app packages the desktop wallpaper's shader.
14 + - 'moire/shaders/**'
15 + - '.github/workflows/android.yml'
16 +
17 +jobs:
18 + # Builds, signs, and publishes the APK to this repository's own F-Droid
19 + # registry.
20 + #
21 + # Needs two secrets, and skips cleanly without them:
22 + #
23 + # ANDROID_KEYSTORE base64 of the release keystore (.jks)
24 + # ANDROID_KEYSTORE_PASS its password
25 + #
26 + # Generate one once, and keep it: the signing certificate is what
27 + # Android checks on every update, so losing it means every install
28 + # has to be uninstalled and redone by hand.
29 + #
30 + # keytool -genkeypair -v -keystore release.jks -alias moire \
31 + # -keyalg RSA -keysize 4096 -validity 10000 \
32 + # -dname "CN=neiam moire, O=neiam, C=US"
33 + # base64 -w0 release.jks
34 + android:
35 + runs-on: self-hosted
36 + steps:
37 + - uses: actions/checkout@v4
38 +
39 + - uses: docker/setup-buildx-action@v3
40 +
41 + - name: Resolve the forge URL
42 + env:
43 + CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
44 + run: |
45 + set -euo pipefail
46 + forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}
47 +
48 + if [ -z "$forge_url" ]; then
49 + echo "No forge URL available." >&2
50 + exit 1
51 + fi
52 +
53 + echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
54 +
55 + # A secret can't be read in a job-level `if`, so the gate is a
56 + # step output that everything after checks.
57 + - name: Unpack the signing keystore
58 + id: keystore
59 + env:
60 + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
61 + KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
62 + run: |
63 + set -euo pipefail
64 +
65 + if [ -z "${KEYSTORE:-}" ] || [ -z "${KEYSTORE_PASS:-}" ]; then
66 + echo "No ANDROID_KEYSTORE / ANDROID_KEYSTORE_PASS set — skipping."
67 + echo "An unsigned or debug-signed APK is worse than none: the"
68 + echo "debug key is shared by every developer, so publishing one"
69 + echo "would let anyone ship an 'update' to it."
70 + echo "skip=true" >> "$GITHUB_OUTPUT"
71 + exit 0
72 + fi
73 +
74 + umask 077
75 + printf '%s' "$KEYSTORE" | base64 -d > release.jks
76 + printf '%s' "$KEYSTORE_PASS" > keystore.pass
77 + echo "skip=false" >> "$GITHUB_OUTPUT"
78 +
79 + # The context is the repository root, not droid/: the build takes
80 + # the shader from moire/shaders. droid/Dockerfile.dockerignore keeps
81 + # the rest of the repository, stand-in images and all, out of it.
82 + - name: Build and sign the APK
83 + if: steps.keystore.outputs.skip != 'true'
84 + env:
85 + RUN_NUMBER: ${{ github.run_number }}
86 + run: |
87 + set -euo pipefail
88 +
89 + # One stamp for the whole run, computed here rather than
90 + # inside Gradle, so every artifact a run produces agrees on
91 + # its version even if the build is retried a step at a time.
92 + stamp=$(date -u +%s)
93 + echo "version: 0.1.0-dev-$stamp (versionCode $RUN_NUMBER)"
94 +
95 + docker buildx build \
96 + --target android-export \
97 + --build-arg GITHUB_RUN_NUMBER="$RUN_NUMBER" \
98 + --build-arg SOURCE_DATE_EPOCH="$stamp" \
99 + --secret id=keystore,src=./release.jks \
100 + --secret id=keystore_password,src=./keystore.pass \
101 + --output type=local,dest=./apk \
102 + --file droid/Dockerfile .
103 +
104 + # The keystore is gone before anything else runs, so a later step
105 + # or a failure mid-job can't leave it on a persistent runner.
106 + - name: Shred the keystore
107 + if: always()
108 + run: shred -u release.jks keystore.pass 2>/dev/null || rm -f release.jks keystore.pass
109 +
110 + - name: Publish to the F-Droid repository
111 + if: steps.keystore.outputs.skip != 'true'
112 + env:
113 + REPOSITORY: ${{ github.repository }}
114 + TOKEN: ${{ github.token }}
115 + run: |
116 + set -euo pipefail
117 + curl --fail-with-body -sS \
118 + -u "gitgud-actions:$TOKEN" \
119 + -H "Content-Type: application/vnd.android.package-archive" \
120 + --data-binary "@apk/app-release.apk" \
121 + "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"
122 + echo
123 +
124 + - name: How to install it
125 + if: steps.keystore.outputs.skip != 'true'
126 + env:
127 + OWNER: ${{ github.repository_owner }}
128 + run: |
129 + set -euo pipefail
130 + fingerprint=$(curl -fsSL "$FORGE_URL/pkg/_keys/fdroid/$OWNER")
131 +
132 + cat <<EOF
133 + Add this repository in F-Droid (Settings -> Repositories -> +):
134 +
135 + $FORGE_URL/pkg/fdroid/$OWNER/_all?fingerprint=$fingerprint
136 +
137 + One entry covers every Android app $OWNER publishes, Ova
138 + included. The fingerprint pins the index signing certificate;
139 + F-Droid refuses an index signed by anything else.
140 + EOF
modified README.md
+23 −0
@@ -97,6 +97,29 @@ Offscreen rendering needs the GPU path, or the shader is skipped:
97 97
98 98 QT_QUICK_BACKEND=rhi QSG_RHI_BACKEND=opengl QT_QPA_PLATFORM=offscreen qml6 ...
99 99
100 +## Android
101 +
102 +`droid/` is the animated wallpaper as an Android live wallpaper, with
103 +the same patterns, effects, themes, time-of-day schedule and wordmark.
104 +Its launcher entry is the settings screen, which also sets it as the
105 +wallpaper and can fill in the location for following the sun.
106 +
107 +It draws the same picture as the desktop because it runs the same
108 +shader: the app packages `moire/shaders/field.frag` unchanged and
109 +adapts its Qt header to OpenGL ES 3.0 at load time, and
110 +`tools/themes.py` writes the palette for both. The motion and schedule
111 +are Kotlin ports of `Field.qml` and `schedule.js`; keep them in step.
112 +It draws only while visible, at the configured frame rate, and reports
113 +its colours to Android for Material You.
114 +
115 + cd droid && ./gradlew :app:testDebugUnitTest :app:assembleDebug
116 +
117 +CI (`.github/workflows/android.yml`) builds it in a container
118 +(`droid/Dockerfile`, context the repository root), signs it and
119 +publishes it to the forge's F-Droid repository, as Ova does. It needs
120 +the `ANDROID_KEYSTORE` and `ANDROID_KEYSTORE_PASS` secrets and skips
121 +without them; the workflow says how to make the keystore.
122 +
100 123 ## Enabling
101 124
102 125 Installing a package does not switch to its theme:
added droid/Dockerfile
+131 −0
@@ -0,0 +1,131 @@
1 +# Builds and signs the release APK. Adapted from Ova's, itself from
2 +# GitGud's android/Dockerfile.
3 +#
4 +# The context is the repository root, not droid/: the app packages the
5 +# desktop wallpaper's shader from moire/shaders/.
6 +#
7 +# podman build (or docker buildx build) \
8 +# --target android-export \
9 +# --secret id=keystore,src=./release.jks \
10 +# --secret id=keystore_password,src=./keystore.pass \
11 +# --output type=local,dest=./apk \
12 +# --file droid/Dockerfile .
13 +
14 +# 21 to match gradle/gradle-daemon-jvm.properties; anything else and
15 +# Gradle goes off to download a second JDK.
16 +ARG JDK_IMAGE=docker.io/library/eclipse-temurin:21-jdk
17 +
18 +FROM ${JDK_IMAGE} AS sdk
19 +
20 +# Pinned. `…_latest.zip` would make the toolchain drift under a cache
21 +# that has no way to notice.
22 +ARG CMDLINE_TOOLS=commandlinetools-linux-14742923_latest.zip
23 +# compileSdk is release(36) { minorApiLevel = 1 }, and AGP 9.2 builds
24 +# with build-tools 36.0.0.
25 +ARG ANDROID_PLATFORM=36.1
26 +ARG BUILD_TOOLS=36.0.0
27 +
28 +ENV ANDROID_HOME=/opt/android-sdk \
29 + DEBIAN_FRONTEND=noninteractive
30 +
31 +RUN apt-get update \
32 + && apt-get install -y --no-install-recommends unzip curl ca-certificates \
33 + && rm -rf /var/lib/apt/lists/*
34 +
35 +RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
36 + && curl -fsSL "https://dl.google.com/android/repository/${CMDLINE_TOOLS}" -o /tmp/tools.zip \
37 + && unzip -q /tmp/tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
38 + && mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
39 + && rm /tmp/tools.zip
40 +
41 +ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}"
42 +
43 +# `yes |` because sdkmanager prompts per licence and there is no
44 +# non-interactive flag that covers all of them.
45 +RUN yes | sdkmanager --licenses > /dev/null \
46 + && sdkmanager --install \
47 + "platforms;android-${ANDROID_PLATFORM}" \
48 + "build-tools;${BUILD_TOOLS}" \
49 + "platform-tools" > /dev/null
50 +
51 +# ── dependencies ─────────────────────────────────────────────────────
52 +#
53 +# Build files only, so the Gradle and Maven downloads cache as one layer
54 +# that survives every change to the Kotlin sources.
55 +
56 +FROM sdk AS deps
57 +
58 +WORKDIR /src/droid
59 +COPY droid/gradle ./gradle
60 +COPY droid/gradlew droid/settings.gradle.kts droid/build.gradle.kts droid/gradle.properties ./
61 +COPY droid/app/build.gradle.kts ./app/
62 +
63 +RUN echo "sdk.dir=${ANDROID_HOME}" > local.properties
64 +
65 +# A task that resolves the whole dependency graph without needing the
66 +# sources. `--no-daemon` because a daemon outliving the RUN is just a
67 +# process the image has to reap.
68 +RUN ./gradlew --no-daemon :app:dependencies --configuration releaseRuntimeClasspath > /dev/null \
69 + || echo "dependency warm-up failed; the build below will fetch them"
70 +
71 +# ── build ────────────────────────────────────────────────────────────
72 +
73 +FROM deps AS build
74 +
75 +ARG BUILD_TOOLS=36.0.0
76 +
77 +# A container build inherits nothing from the runner's environment, so
78 +# the version has to be handed in explicitly. Without these every run
79 +# builds versionCode 1 -- the same filename with different bytes, which
80 +# the registry refuses with a 409.
81 +#
82 +# Declared in this stage so they don't invalidate the SDK and
83 +# dependency layers, which are most of the build time.
84 +ARG GITHUB_RUN_NUMBER
85 +ARG SOURCE_DATE_EPOCH
86 +ARG RELEASE_VERSION
87 +ENV GITHUB_RUN_NUMBER=${GITHUB_RUN_NUMBER} \
88 + SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} \
89 + RELEASE_VERSION=${RELEASE_VERSION}
90 +
91 +# The shader lives with the desktop wallpaper, at the path the app's
92 +# build.gradle.kts expects relative to droid/.
93 +COPY moire/shaders /src/moire/shaders
94 +COPY droid/app ./app
95 +
96 +# AGP 9 only wires unit tests to the debug variant; they are pure JVM
97 +# logic, so that still gates what gets signed.
98 +RUN echo "building versionCode=${GITHUB_RUN_NUMBER:-1}" \
99 + && ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleRelease
100 +
101 +# Sign as a separate step from Gradle so the keystore never reaches a
102 +# build script, a Gradle cache, or an image layer. BuildKit secrets are
103 +# mounted for the life of one RUN and are not committed.
104 +#
105 +# zipalign before apksigner, not after: aligning rewrites offsets, and
106 +# doing it to an already-signed APK invalidates the v2 signature.
107 +#
108 +# No --key-pass: apksigner reads each `file:` password sequentially, so
109 +# passing the same one-line file twice hits EOF on the second read.
110 +# Omitted, the key password defaults to the store password, which is
111 +# what PKCS12 keystores use anyway.
112 +RUN --mount=type=secret,id=keystore,target=/tmp/release.jks \
113 + --mount=type=secret,id=keystore_password,target=/tmp/keystore.pass \
114 + set -eu; \
115 + tools="${ANDROID_HOME}/build-tools/${BUILD_TOOLS}"; \
116 + unsigned=app/build/outputs/apk/release/app-release-unsigned.apk; \
117 + mkdir -p /out; \
118 + "${tools}/zipalign" -p -f 4 "$unsigned" /tmp/aligned.apk; \
119 + "${tools}/apksigner" sign \
120 + --ks /tmp/release.jks \
121 + --ks-pass "file:/tmp/keystore.pass" \
122 + --out /out/app-release.apk \
123 + /tmp/aligned.apk; \
124 + "${tools}/apksigner" verify --print-certs /out/app-release.apk
125 +
126 +# ── export ───────────────────────────────────────────────────────────
127 +#
128 +# FROM scratch, so `--output type=local` lands the APK and nothing else.
129 +
130 +FROM scratch AS android-export
131 +COPY --from=build /out/app-release.apk /
added droid/Dockerfile.dockerignore
+13 −0
@@ -0,0 +1,13 @@
1 +# The context is the repository root; only droid/ and the shader are
2 +# needed, and the stand-in images elsewhere are tens of megabytes.
3 +*
4 +!droid
5 +!moire/shaders
6 +droid/.gradle
7 +droid/.kotlin
8 +droid/.idea
9 +droid/**/build
10 +droid/local.properties
11 +droid/*.jks
12 +droid/*.pass
13 +droid/apk

Parents: 3f7bd79