Add a standalone Android app with the same daily reading and config page
build / build (push) Successful in 4m8s
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:
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user