neiam
/neiam-devices
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
droid: build, sign and publish the APK to the forge's F-Droid registry
12bf40f · Gabriel Morell · 2026-09-29 23:20
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