Add a standalone Android app with the same daily reading and config page
build / build (push) Successful in 4m8s

android/ links engine/src/ and interpreter/src/ via JNI/CMake (real
astronomy.c, not the watch's size-constrained substitute), rendering
through Jetpack Compose with a burger-menu config page mirroring
WatchConfig and a WorkManager-driven daily notification. Independent
of the watch app - neither requires the other.

build-image now bundles the Android SDK/NDK alongside the Pebble SDK
(VERSION bumped to 2), and .gitea/workflows/build.yml/Makefile/
run-image.sh build and publish the APK the same way as the CLI
tarball and .pbw.
This commit is contained in:
ml
2026-07-18 22:41:51 +02:00
parent a3e437277f
commit 3bc1263294
49 changed files with 2530 additions and 43 deletions
+78 -14
View File
@@ -123,30 +123,94 @@ push/PR, alongside `make test`/`make package` - the whole job runs
inside a container image (`build-image/`, built and pushed with
`build-image.sh`/`upload-image.sh`, see "Build image" below) that has
the Pebble SDK baked in, so the CI runner itself needs no toolchain
beyond Docker access to pull that image. Both the CLI tarball and the
`.pbw` are uploaded as build artifacts and published to
beyond Docker access to pull that image. The CLI tarball, the `.pbw`,
and the Android `.apk` (see "Android app packaging" below) are all
uploaded as build artifacts and published to
`dl.ladkau.de/files/deck-in-a-dash/` on push.
### Android app packaging (`make android`)
A separate target, deliberately **not** a prerequisite of `all` - it
shells out to `./gradlew assembleDebug` inside `android/` (via that
directory's own Gradle/AGP/NDK toolchain, not this Makefile's plain-`cc`
rules) and copies the resulting
`android/app/build/outputs/apk/debug/app-debug.apk` to
`dist/deck-in-a-dash-<version>.apk`, using the exact same version
resolution as `make package`/`make watchapp` above (current git tag, or
`make android VERSION=1.2.3` to override). Requires the Android SDK/NDK
(`ANDROID_HOME`/`ANDROID_NDK_HOME` set, plus the Python 3 + Pillow
`android/app/build.gradle.kts`'s `preBuild` task needs for its own
resource-generation scripts - see `android/README.md`) - a machine
without it can still build/test/package the CLI and the watch app via
every other target in this file.
See `android/README.md` for the app's own architecture summary; in
short, it's a Jetpack Compose Material3 app whose native layer links the
same `engine/src/*.c`/`interpreter/src/*.c` sources the watch links (via
JNI/CMake, `android/app/src/main/jni/CMakeLists.txt`) - **the real,
full-precision `engine/third_party/astronomy/astronomy.c`, not the
watch's `lowprec_ephemeris.c` substitute**, since a phone has no
equivalent of Pebble's 64KB code+data+bss budget forcing that
substitution. Like the watch, the interpreter's real `.c` implementations
are linked in directly (`significance.c`/`narrative.c`/`guidance.c`),
not `json.c`/`reading_io.c` - there's no JSON pipe between separate
binaries here either, just one process computing and rendering its own
reading. Unlike the watch (which walks the `DailyReading`/
`DailyInterpretation` structs directly across the same C/UI boundary),
the JNI bridge (`android/app/src/main/jni/reading_json.c`, a structural
port of `interpreter/src/main.c`'s `interp_print_json()`) serializes one
complete reading to a JSON string per call - a single call, not
fine-grained per-field accessors, since the alternative would mean
either re-running real ephemeris trig per field access or pinning a live
native object across calls; Kotlin then decodes that JSON with
`kotlinx.serialization` rather than maintaining its own JNI accessor
surface. Every string in that JSON (titles, narrative, guidance,
position/card names and meanings) is already localized via `i18n_get()`,
so Kotlin never needs its own planet/aspect/sign display-name table -
only the `*_slug` fields exist for the UI's card-art drawable lookup.
Card art is regenerated by `android/scripts/gen_card_images.py`
(structurally a sibling of `watch/scripts/gen_card_images.py`, same slug
table) at 400px wide, lossy WebP - not the watch's 72px PNG, since a
phone has no hardware-decoder budget forcing that size, but 21MB of
source JPEGs would otherwise bloat the APK; config (birth data, gender,
language, notification settings - the same fields as `WatchConfig`) is
persisted via Jetpack Preferences DataStore instead of Pebble's
key-value persist API. The daily notification uses WorkManager (a
self-rescheduling one-shot chain, not `AlarmManager`, since a "roughly
this time daily" reminder doesn't need `AlarmManager`'s exact-alarm
permission burden) - the Android analog of `watch/src/c/notify.c`'s own
daily-wakeup contract.
**CI needs network access for this target specifically** - unlike the
CLI/Pebble build path above (deliberately zero network access needed
once `build-image` is pulled), Gradle/AGP/androidx dependency resolution
against Google's Maven + Maven Central happens at build time, every
build. This is a deliberate, accepted deviation from this image's
otherwise-offline build philosophy, not an oversight.
### Build image
`build-image/Dockerfile` pins the exact toolchain versions (Pebble
Tool, Pebble SDK core, Node.js) needed for `make test`, `make package`,
and `make watchapp`, so builds are reproducible independent of whatever
happens to be installed on a given machine. Three root-level scripts
drive it, each reading registry settings from a gitignored
`registry.env` (copy `registry.env.example` to create it - never commit
the real file, since it holds registry credentials) and the image tag
from `build-image/VERSION`:
Tool, Pebble SDK core, Node.js, and the Android SDK/NDK) needed for
`make test`, `make package`, `make watchapp`, and `make android`, so
builds are reproducible independent of whatever happens to be installed
on a given machine. Three root-level scripts drive it, each reading
registry settings from a gitignored `registry.env` (copy
`registry.env.example` to create it - never commit the real file, since
it holds registry credentials) and the image tag from
`build-image/VERSION`:
- `build-image.sh` - builds and locally tags the image (`:VERSION` and
`:latest`), no push.
- `run-image.sh [VERSION]` - runs `make test package watchapp` inside
the locally-built image against a live bind-mount of the repo, wiping
generated build artifacts first for CI parity. Use this to validate a
`build-image/Dockerfile` change before pushing the image anywhere.
- `run-image.sh [VERSION]` - runs `make test package watchapp android`
inside the locally-built image against a live bind-mount of the repo,
wiping generated build artifacts first for CI parity. Use this to
validate a `build-image/Dockerfile` change before pushing the image
anywhere.
- `upload-image.sh` - logs in and pushes the image (`:VERSION` and
`:latest`) to the registry; `.gitea/workflows/build.yml` pulls
`:latest` to run its build job (see "Watch app packaging" above).
`:latest` to run its build job (see "Watch app packaging"/"Android app
packaging" above).
## Architecture