feat(android-local): vendored Docker build for APK with no host SDK (#163)
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:
parent
33a75c2493
commit
9c99c0b43c
4 changed files with 314 additions and 2 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -316,3 +316,6 @@ test-results/
|
|||
|
||||
# Superpowers brainstorming mockups
|
||||
.superpowers/
|
||||
|
||||
# Local Docker build caches (see scripts/build-apk-local.sh)
|
||||
.docker-cache/
|
||||
|
|
|
|||
107
docker/android-builder/Dockerfile
Normal file
107
docker/android-builder/Dockerfile
Normal 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"
|
||||
|
|
@ -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
161
scripts/build-apk-local.sh
Executable 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 "$@"
|
||||
Loading…
Add table
Reference in a new issue