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
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Downsizes ../../res/img/*.jpeg into app/src/main/res/drawable-nodpi/*.webp
for the Android app.
Structurally a sibling of ../../watch/scripts/gen_card_images.py (same
CARDS slug/filename table - matching engine/src/tarot_data.c's own
k_card_slug, so app/src/main/res/drawable-nodpi/<slug>.webp resolves via
plain resource-name lookup, see app/src/main/java/.../ui/CardArt.kt -
and the same per-file mtime skip-if-up-to-date logic), but sized and
encoded for a phone screen instead of Pebble's hardware-forced 72px PNG:
400px wide, lossy WebP (quality 85) - these are painted illustrations,
not line art, so WebP meaningfully beats PNG here, and 21MB of source
JPEGs would otherwise bloat the APK.
Generated output, not committed (see the repo's root .gitignore's
/android/app/src/main/res/drawable-nodpi entry) - regenerate by running
this script directly or via Gradle's generateCardArt task (wired into
app's preBuild, see ../app/build.gradle.kts). Requires Pillow (`pip
install Pillow`), already part of build-image's toolchain.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "img"))
OUT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res", "drawable-nodpi"))
CARD_WIDTH = 400
WEBP_QUALITY = 85
# (source file in res/img/, slug matching engine/src/tarot_data.c's
# k_card_slug table) - in TarotCard enum order (CARD_FOOL = 0 ... CARD_WORLD).
CARDS = [
("RWS_Tarot_00_Fool.jpeg", "fool"),
("RWS_Tarot_01_Magician.jpeg", "magician"),
("RWS_Tarot_02_High_Priestess.jpeg", "high_priestess"),
("RWS_Tarot_03_Empress.jpeg", "empress"),
("RWS_Tarot_04_Emperor.jpeg", "emperor"),
("RWS_Tarot_05_Hierophant.jpeg", "hierophant"),
("RWS_Tarot_06_Lovers.jpeg", "lovers"),
("RWS_Tarot_07_Chariot.jpeg", "chariot"),
("RWS_Tarot_08_Strength.jpeg", "strength"),
("RWS_Tarot_09_Hermit.jpeg", "hermit"),
("RWS_Tarot_10_Wheel_of_Fortune.jpeg", "wheel_of_fortune"),
("RWS_Tarot_11_Justice.jpeg", "justice"),
("RWS_Tarot_12_Hanged_Man.jpeg", "hanged_man"),
("RWS_Tarot_13_Death.jpeg", "death"),
("RWS_Tarot_14_Temperance.jpeg", "temperance"),
("RWS_Tarot_15_Devil.jpeg", "devil"),
("RWS_Tarot_16_Tower.jpeg", "tower"),
("RWS_Tarot_17_Star.jpeg", "star"),
("RWS_Tarot_18_Moon.jpeg", "moon"),
("RWS_Tarot_19_Sun.jpeg", "sun"),
("RWS_Tarot_20_Judgement.jpeg", "judgement"),
("RWS_Tarot_21_World.jpeg", "world"),
]
def main():
os.makedirs(OUT_DIR, exist_ok=True)
written = 0
for src_name, slug in CARDS:
src_path = os.path.join(SRC_DIR, src_name)
out_path = os.path.join(OUT_DIR, f"{slug}.webp")
if os.path.exists(out_path) and os.path.getmtime(out_path) >= os.path.getmtime(src_path):
continue
with Image.open(src_path) as img:
ratio = CARD_WIDTH / img.width
height = round(img.height * ratio)
resized = img.convert("RGB").resize((CARD_WIDTH, height), Image.LANCZOS)
resized.save(out_path, "WEBP", quality=WEBP_QUALITY)
written += 1
print(f"wrote {written}/{len(CARDS)} card images to {OUT_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Generates the Android launcher icon from ../../res/logo.png.
Produces:
- mipmap-<density>/ic_launcher_foreground.png - the logo centered,
scaled to fit the adaptive-icon safe zone (66% of a 108dp canvas),
transparent background - paired at runtime with
@color/ic_launcher_background (see mipmap-anydpi-v26/ic_launcher.xml,
committed - a static reference, not generated) for API 26+.
- mipmap-<density>/ic_launcher.png / ic_launcher_round.png - the same
logo flattened onto ic_launcher_background, for API <26 devices that
can't use the adaptive-icon XML at all.
Generated output, not committed (see the repo's root .gitignore's
/android/app/src/main/res/mipmap-* entries) - regenerate by running this
script directly or via Gradle's generateLauncherIcon task (wired into
app's preBuild, see ../app/build.gradle.kts). Requires Pillow, already
part of build-image's toolchain.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LOGO_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "logo.png"))
RES_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res"))
BACKGROUND_RGBA = (27, 27, 47, 255) # matches @color/ic_launcher_background
# (density qualifier, adaptive-icon canvas px, legacy launcher px)
DENSITIES = [
("mdpi", 108, 48),
("hdpi", 162, 72),
("xhdpi", 216, 96),
("xxhdpi", 324, 144),
("xxxhdpi", 432, 192),
]
# Adaptive icons render only the inner ~66% "safe zone" reliably across
# launchers that apply their own mask shape.
SAFE_ZONE_RATIO = 0.66
def resized_logo(logo, target_px):
ratio = target_px / max(logo.width, logo.height)
size = (round(logo.width * ratio), round(logo.height * ratio))
return logo.resize(size, Image.LANCZOS)
def main():
logo = Image.open(LOGO_PATH).convert("RGBA")
for density, canvas_px, legacy_px in DENSITIES:
mipmap_dir = os.path.join(RES_DIR, f"mipmap-{density}")
os.makedirs(mipmap_dir, exist_ok=True)
# Adaptive-icon foreground: transparent canvas, logo scaled to the
# safe zone and centered.
foreground = Image.new("RGBA", (canvas_px, canvas_px), (0, 0, 0, 0))
fg_logo = resized_logo(logo, round(canvas_px * SAFE_ZONE_RATIO))
offset = ((canvas_px - fg_logo.width) // 2, (canvas_px - fg_logo.height) // 2)
foreground.paste(fg_logo, offset, fg_logo)
foreground.save(os.path.join(mipmap_dir, "ic_launcher_foreground.png"))
# Legacy (<API26) flattened fallback, same for round and square -
# launchers on those versions apply their own circular crop.
legacy = Image.new("RGBA", (legacy_px, legacy_px), BACKGROUND_RGBA)
legacy_logo = resized_logo(logo, round(legacy_px * SAFE_ZONE_RATIO))
legacy_offset = ((legacy_px - legacy_logo.width) // 2, (legacy_px - legacy_logo.height) // 2)
legacy.paste(legacy_logo, legacy_offset, legacy_logo)
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher.png"))
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher_round.png"))
print(f"wrote launcher icons for {len(DENSITIES)} densities to {RES_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()