feat(android-local): vendored Docker build for APK with no host SDK (#163)
All checks were successful
CI / Lint, Typecheck & Unit Tests (push) Successful in 29s
CI / E2E (Standalone single-file) (push) Successful in 1m13s
CI / E2E (Web) (push) Successful in 2m12s

Adds a self-contained local APK build path. Only Docker is required on the host — no JDK, no Android SDK, no Node. Mirrors .github/workflows/build-android.yml inside a vendored image so anyone who clones the repo can build the APK with one command.

- docker/android-builder/Dockerfile — node:22-bookworm-slim (pinned by digest) + Temurin JDK 21 + Android SDK 35 + build-tools 35.0.0 and 34.0.0 (AGP 8.7.x's silent internal minimum). JAVA_HOME symlinked via dpkg --print-architecture so the image works on amd64 and arm64.
- scripts/build-apk-local.sh — orchestrator that builds the image on first run, then runs the container as the host uid:gid with the repo bind-mounted and three repo-local cache dirs. Uses --network host (Docker Desktop 4.34+ on macOS/Windows) to dodge a default-bridge apt-download failure.
- docs/android-apk.md — splits Prerequisites into Docker (Path A, recommended) and Native (Path B), adds a Local Docker build section.
- .gitignore — adds .docker-cache/.
This commit is contained in:
forgejo_admin 2026-05-20 19:10:27 +04:00
parent 33a75c2493
commit 9c99c0b43c
4 changed files with 314 additions and 2 deletions

3
.gitignore vendored
View file

@ -316,3 +316,6 @@ test-results/
# Superpowers brainstorming mockups
.superpowers/
# Local Docker build caches (see scripts/build-apk-local.sh)
.docker-cache/

View file

@ -0,0 +1,107 @@
# Self-contained Android build image for MetaScrub APK builds.
#
# This is the local-dev counterpart to forgejo-stack/job-android:latest:
# same JDK 21 + Android SDK 35 toolchain, but based on the public
# node:22-bookworm-slim image so it works on any host with Docker —
# no forgejo-stack, no host JDK, no host Android SDK required.
#
# Built and run by scripts/build-apk-local.sh. Tagged
# metascrub-android-builder:local locally.
#
# Pinning rationale (kept in sync with the CI image; bump together):
# - JDK 21: Capacitor 7.6+ requires Java 21 source level (its
# capacitor-android library sets sourceCompatibility = VERSION_21).
# JDK 17 fails javac with "invalid source release: 21".
# - Android API 35 + build-tools 35.0.0: Capacitor 7 default compileSdk.
# - cmdline-tools 11076708: pinned for reproducibility; bump together
# with ANDROID_API_LEVEL when refreshing the toolchain.
# Pinned by digest for byte-reproducible rebuilds. Bump together with the
# CI image; verify the digest with:
# docker pull node:22-bookworm-slim && \
# docker inspect --format='{{index .RepoDigests 0}}' node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732
# JDK 21 (Temurin) via the Eclipse Adoptium apt repo, plus the system
# packages the SDK installer + Gradle wrapper need (unzip, curl, git, file).
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
wget curl gnupg ca-certificates apt-transport-https git file unzip \
&& wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public \
| gpg --dearmor -o /usr/share/keyrings/adoptium.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" \
> /etc/apt/sources.list.d/adoptium.list \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
temurin-21-jdk \
&& rm -rf /var/lib/apt/lists/*
# Adoptium's Debian package installs to /usr/lib/jvm/temurin-21-jdk-<arch>
# (amd64 or arm64). Symlink to an arch-independent canonical name so the
# image works on both x86_64 Linux hosts and Apple Silicon (arm64) under
# Docker Desktop. The CI Dockerfile hardcodes -amd64 because GitHub-hosted
# runners are always x86_64; the local image can't make that assumption.
RUN ARCH=$(dpkg --print-architecture) \
&& test -d "/usr/lib/jvm/temurin-21-jdk-${ARCH}" \
&& ln -s "/usr/lib/jvm/temurin-21-jdk-${ARCH}" /usr/lib/jvm/temurin-21-jdk
ENV JAVA_HOME=/usr/lib/jvm/temurin-21-jdk
ENV PATH=$JAVA_HOME/bin:$PATH
# Sanity check — fail the build if JDK isn't reachable.
RUN java -version && javac -version
# Android SDK. Path matches GitHub-hosted runner convention so the same
# workflow steps work without modification.
ENV ANDROID_HOME=/usr/local/lib/android/sdk
ENV ANDROID_SDK_ROOT=$ANDROID_HOME
ARG CMDLINE_TOOLS_VERSION=11076708
ARG ANDROID_API_LEVEL=35
ARG ANDROID_BUILD_TOOLS_VERSION=35.0.0
# AGP 8.7.x (Capacitor 7.6's default) silently requires build-tools 34.0.0
# internally even when compileSdk is 35. Without 34.0.0 installed AND the SDK
# dir writable, `assembleDebug` aborts with "Failed to install the following
# SDK components: build-tools;34.0.0" early in dependency resolution. CI
# tolerates this because Forgejo runs the job container as root (writable
# SDK); the local script runs as the host user, so we pre-install both
# versions instead of leaving runtime to sdkmanager. Bump together with
# ANDROID_BUILD_TOOLS_VERSION when AGP's internal minimum changes.
ARG ANDROID_BUILD_TOOLS_AGP_MIN_VERSION=34.0.0
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
&& cd /tmp \
&& wget -q "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" -O cmdline-tools.zip \
&& unzip -q cmdline-tools.zip -d "$ANDROID_HOME/cmdline-tools" \
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
&& rm cmdline-tools.zip
ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH
# Accept all SDK licenses non-interactively, then install the platform +
# build-tools the Capacitor 7 default targets (compileSdk 35).
#
# `{ yes || true; }` swallows the SIGPIPE that bare `yes` gets when
# sdkmanager closes the pipe — Docker's RUN shell uses -o pipefail and
# would otherwise propagate yes's 141 exit code.
RUN { yes || true; } | sdkmanager --licenses >/dev/null \
&& sdkmanager --update >/dev/null \
&& sdkmanager \
"platform-tools" \
"platforms;android-${ANDROID_API_LEVEL}" \
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
"build-tools;${ANDROID_BUILD_TOOLS_AGP_MIN_VERSION}" \
&& chmod -R a+rX "$ANDROID_HOME"
# Sanity check — fail the build if any of the four Android components
# aren't reachable.
RUN sdkmanager --list_installed | grep -E "platforms;android-${ANDROID_API_LEVEL}|build-tools;${ANDROID_BUILD_TOOLS_VERSION}|build-tools;${ANDROID_BUILD_TOOLS_AGP_MIN_VERSION}|platform-tools"
# Enable yarn via corepack (ships with Node 22). The exact yarn version is
# resolved at runtime from the repo's package.json "packageManager" field
# (currently yarn 1.22.22) — pinning here would drift if the repo bumps.
RUN corepack enable
LABEL org.metascrub.builder.jdk="21"
LABEL org.metascrub.builder.node="22"
LABEL org.metascrub.builder.android-api-level="35"
LABEL org.metascrub.builder.android-build-tools="35.0.0,34.0.0"
LABEL org.metascrub.builder.cmdline-tools="11076708"

View file

@ -77,12 +77,24 @@ There is no offline TWA mode. The verification step is architectural, not option
## Prerequisites
One-time setup on the developer machine that builds the APK:
There are two supported build paths. Pick whichever fits your machine:
### Path A — Docker only (recommended)
| Tool | Why | Install |
| --- | --- | --- |
| Docker | Runs the build inside a containerised JDK 21 + Android SDK 35 image | [Docker Engine](https://docs.docker.com/engine/install/) (Linux) or [Docker Desktop](https://www.docker.com/products/docker-desktop/) 4.34+ (macOS / Windows) |
That's it. No host JDK, no host Android SDK, no host Node — everything lives inside the image. The first-run image build takes ~5 min (downloads JDK + SDK); subsequent runs reuse it. See [Local Docker build](#local-docker-build) below for the per-build flow.
**Docker Desktop version note.** The script uses `--network host` for `docker build` and `docker run` to dodge a default-bridge failure mode that breaks apt downloads on some hosts. `--network host` is fully supported on Linux Docker Engine and on Docker Desktop **4.34+** (released Aug 2024). On older Docker Desktop, the flag is silently ignored and apt downloads may hang for ~7 min before failing with `E: Unable to locate package wget`. If you hit this on macOS or Windows, upgrade Docker Desktop.
### Path B — Native host toolchain
| Tool | Why | Install |
| --- | --- | --- |
| Node 22 + yarn | Build `dist/web/` and run Capacitor CLI | Already a project requirement |
| JDK 17 | Android Gradle Plugin requires it | `apt install openjdk-17-jdk` (Linux), `brew install openjdk@17` (macOS), or installed via Android Studio |
| JDK 21 | Capacitor 7.6+'s `capacitor-android` library compiles against Java 21 (`sourceCompatibility = VERSION_21`); JDK 17 fails javac with `invalid source release: 21` | `apt install temurin-21-jdk` (after adding the Adoptium apt repo), `brew install openjdk@21` (macOS), or installed via Android Studio |
| Android SDK + Platform-Tools | Compile the APK | Easiest path: install [Android Studio](https://developer.android.com/studio) (~3 GB), which bundles SDK + Platform-Tools + an Android emulator. Headless alternative: `cmdline-tools` + `sdkmanager` if you don't want the GUI. |
**Minimum Android version:** Capacitor 7 requires Android API 23 (Android 6.0 Marshmallow). This covers 99%+ of active devices as of 2025. WASM support itself requires Chromium 61+ (2017), well within range on any supported Android version.
@ -91,6 +103,35 @@ The recipient needs **nothing** — just the APK file and the OS-level "Install
---
## Local Docker build
The end-to-end flow for someone who only has Docker:
```bash
./scripts/build-apk-local.sh # build APK
./scripts/build-apk-local.sh --rebuild # force image rebuild (e.g. after Dockerfile change)
./scripts/build-apk-local.sh --clean # remove .docker-cache/ (gradle + yarn caches)
```
What the script does, in order:
1. **Build the image** (first run only). Tagged `metascrub-android-builder:local`. Pulls `node:22-bookworm-slim` (pinned by digest), installs Temurin JDK 21, downloads Android cmdline-tools, installs `platforms;android-35` + `build-tools;35.0.0` + `build-tools;34.0.0` (AGP 8.7.x's silent internal minimum), enables corepack. ~5 min cold; idempotent on later runs.
2. **Run the build** inside the container as the host user's UID:GID so output files are user-owned. Bind-mounts the repo at `/workspace` and three repo-local cache dirs (`.docker-cache/{gradle,yarn,home}`) so Gradle and yarn deps persist across invocations.
3. **Executes the same steps as `.github/workflows/build-android.yml`**: `yarn install --frozen-lockfile``yarn build:web``npx cap sync android``./gradlew assembleDebug --no-daemon --stacktrace`.
4. **Reports the APK path**`android/app/build/outputs/apk/debug/app-debug.apk`.
Timings (measured during PR #163 verification on a Linux Docker Engine host with `--network host`):
| Run | Wall-clock |
| --- | --- |
| Cold (image not built, no caches) | ~7-10 min |
| Warm image, cold yarn + gradle caches | ~3-5 min |
| Warm image + warm caches | ~1-2 min |
The Dockerfile is in [`docker/android-builder/`](../docker/android-builder/) — it mirrors the CI image's toolchain (JDK 21 + Android SDK 35 + cmdline-tools 11076708). Bump together with the CI image when refreshing the toolchain.
---
## One-time project setup
These steps add Capacitor to the repo. Run them once, commit the result, then `cap sync` is all you do on subsequent builds.

161
scripts/build-apk-local.sh Executable file
View file

@ -0,0 +1,161 @@
#!/usr/bin/env bash
# Build the MetaScrub Android debug APK locally using Docker only — no
# Android SDK, JDK, or Node required on the host.
#
# Mirrors the steps in .github/workflows/build-android.yml. The Dockerfile
# at docker/android-builder/Dockerfile is the local-dev counterpart to
# forgejo-stack/job-android:latest — same JDK 21 + Android SDK 35
# toolchain, but based on the public node:22-bookworm-slim image so it
# works on any host with Docker.
#
# Usage:
# ./scripts/build-apk-local.sh # build APK
# ./scripts/build-apk-local.sh --rebuild # force image rebuild
# ./scripts/build-apk-local.sh --clean # remove cache dirs and exit
#
# Output:
# android/app/build/outputs/apk/debug/app-debug.apk
#
# Caches (gitignored, repo-local):
# .docker-cache/gradle Gradle dep cache (~/.gradle inside container)
# .docker-cache/yarn Yarn dep cache
# .docker-cache/home Generic HOME for the container user
set -euo pipefail
IMAGE_TAG="metascrub-android-builder:local"
DOCKERFILE_DIR="docker/android-builder"
# Resolve repo root from the script location so the script works from any cwd.
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m!!\033[0m %s\n' "$*" >&2; }
cmd_clean() {
log "Removing .docker-cache/"
rm -rf "$REPO_ROOT/.docker-cache"
log "Done. Image '$IMAGE_TAG' left intact (use 'docker rmi $IMAGE_TAG' to remove)."
}
cmd_check_docker() {
if ! command -v docker >/dev/null 2>&1; then
err "Docker not found. Install Docker Engine or Docker Desktop, then retry."
exit 1
fi
if ! docker info >/dev/null 2>&1; then
err "Docker is installed but the daemon is not reachable. Start Docker, then retry."
exit 1
fi
}
cmd_build_image() {
local force="${1:-}"
if [[ "$force" != "--force" ]] && docker image inspect "$IMAGE_TAG" >/dev/null 2>&1; then
log "Image '$IMAGE_TAG' already present. Skipping build (use --rebuild to force)."
return 0
fi
log "Building '$IMAGE_TAG' (first run takes ~5-10 min: downloads JDK 21 + Android SDK)..."
# --network host: bypasses Docker's default bridge network for apt-get and
# the cmdline-tools download. The default bridge fails on some hosts where
# co-resident bridge networks (e.g. forgejo-stack's internal bridge) or
# MTU/iptables interaction cause apt connections to deb.debian.org to time
# out. Host networking sidesteps the whole issue. Supported on Linux and
# Docker Desktop 4.34+; harmless elsewhere because no service ports are
# bound during the build.
docker build --network host -t "$IMAGE_TAG" "$DOCKERFILE_DIR"
}
cmd_run_build() {
mkdir -p "$REPO_ROOT/.docker-cache/gradle" \
"$REPO_ROOT/.docker-cache/yarn" \
"$REPO_ROOT/.docker-cache/home"
local host_uid host_gid
host_uid="$(id -u)"
host_gid="$(id -g)"
log "Running build container (uid=$host_uid gid=$host_gid)..."
# Container shell script: mirror the workflow steps verbatim.
#
# --no-daemon: Gradle daemon outlives `docker run`; harmless but pointless
# for one-shot builds. Matches the workflow.
# --stacktrace: surfaces Gradle internal errors on failure. Matches the workflow.
# --network host: same rationale as in cmd_build_image — Gradle and yarn
# both pull large dependencies (~150 MB) over HTTPS during the build.
docker run --rm \
--network host \
--user "$host_uid:$host_gid" \
-v "$REPO_ROOT:/workspace" \
-v "$REPO_ROOT/.docker-cache/gradle:/cache/gradle" \
-v "$REPO_ROOT/.docker-cache/yarn:/cache/yarn" \
-v "$REPO_ROOT/.docker-cache/home:/home/builder" \
-e HOME=/home/builder \
-e GRADLE_USER_HOME=/cache/gradle \
-e YARN_CACHE_FOLDER=/cache/yarn \
-w /workspace \
"$IMAGE_TAG" \
bash -eu -o pipefail -c '
echo "==> yarn install"
yarn install --frozen-lockfile
echo "==> yarn build:web"
yarn build:web
echo "==> npx cap sync android"
npx cap sync android
echo "==> ./gradlew assembleDebug"
cd android
./gradlew assembleDebug --no-daemon --stacktrace
'
}
cmd_report() {
local apk="android/app/build/outputs/apk/debug/app-debug.apk"
if [[ -f "$apk" ]]; then
log "APK built: $apk ($(du -h "$apk" | cut -f1))"
file "$apk" 2>/dev/null || true
else
err "Expected APK not found at $apk"
exit 1
fi
}
main() {
local rebuild=0
for arg in "$@"; do
case "$arg" in
--clean)
cmd_clean
exit 0
;;
--rebuild)
rebuild=1
;;
-h | --help)
# Print the leading comment block (drops the shebang, stops at
# the first non-comment line). Sentinel-driven so growing the
# header doesn't silently truncate --help output.
awk 'NR==1 && /^#!/ {next} /^[^#]/ {exit} {sub(/^# ?/,""); print}' "$0"
exit 0
;;
*)
err "Unknown argument: $arg"
exit 2
;;
esac
done
cmd_check_docker
if [[ "$rebuild" -eq 1 ]]; then
cmd_build_image --force
else
cmd_build_image
fi
cmd_run_build
cmd_report
}
main "$@"