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
+22 -7
View File
@@ -1,11 +1,14 @@
name: build name: build
# Build a versioned Linux CLI release tarball (see `make package`) and the # Build a versioned Linux CLI release tarball (see `make package`), the
# Pebble watchapp (see `make watchapp`) on every push/PR, plus on-demand # Pebble watchapp (see `make watchapp`), and the Android app (see `make
# via the Gitea "Run workflow" button. Runs inside build-image (see # android`) on every push/PR, plus on-demand via the Gitea "Run workflow"
# ../../build-image/Dockerfile, built/pushed via ../../build-image.sh and # button. Runs inside build-image (see ../../build-image/Dockerfile,
# ../../upload-image.sh), which bundles the Pebble SDK alongside the C # built/pushed via ../../build-image.sh and ../../upload-image.sh), which
# toolchain. # bundles the Pebble SDK and the Android SDK/NDK alongside the C
# toolchain. Unlike the CLI/Pebble build path, the Android build still
# needs network access at job runtime (Gradle/AGP/androidx dependency
# resolution) - see the root CLAUDE.md's Android section.
on: on:
push: push:
pull_request: pull_request:
@@ -41,6 +44,9 @@ jobs:
command -v cc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: no C compiler (cc) in PATH" >&2; exit 1; } command -v cc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: no C compiler (cc) in PATH" >&2; exit 1; }
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; } command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
command -v pebble >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: pebble not in PATH" >&2; exit 1; } command -v pebble >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: pebble not in PATH" >&2; exit 1; }
command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; }
[ -n "${ANDROID_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_HOME not set" >&2; exit 1; }
[ -n "${ANDROID_NDK_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_NDK_HOME not set" >&2; exit 1; }
- name: Build and test - name: Build and test
run: | run: |
@@ -48,6 +54,7 @@ jobs:
make test make test
make package make package
make watchapp make watchapp
make android
- name: Upload build artifact - name: Upload build artifact
# v4 uses the newer @actions/artifact backend, which this Gitea # v4 uses the newer @actions/artifact backend, which this Gitea
@@ -63,8 +70,14 @@ jobs:
name: deck-in-a-dash-watchapp name: deck-in-a-dash-watchapp
path: dist/deck-in-a-dash-*.pbw path: dist/deck-in-a-dash-*.pbw
- name: Upload android artifact
uses: actions/upload-artifact@v3
with:
name: deck-in-a-dash-android
path: dist/deck-in-a-dash-*.apk
- name: Publish to dl.ladkau.de - name: Publish to dl.ladkau.de
# Uploads the tarball and .pbw over SFTP instead of using # Uploads the tarball, .pbw, and .apk over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled). # actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish. # Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
@@ -72,6 +85,7 @@ jobs:
set -euo pipefail set -euo pipefail
TARBALL="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)" TARBALL="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
PBW="$(ls dist/deck-in-a-dash-*.pbw)" PBW="$(ls dist/deck-in-a-dash-*.pbw)"
APK="$(ls dist/deck-in-a-dash-*.apk)"
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key chmod 600 ~/.ssh/dl_sftp_key
@@ -81,4 +95,5 @@ jobs:
-mkdir files/deck-in-a-dash -mkdir files/deck-in-a-dash
put $TARBALL files/deck-in-a-dash/$(basename "$TARBALL") put $TARBALL files/deck-in-a-dash/$(basename "$TARBALL")
put $PBW files/deck-in-a-dash/$(basename "$PBW") put $PBW files/deck-in-a-dash/$(basename "$PBW")
put $APK files/deck-in-a-dash/$(basename "$APK")
EOF EOF
+17
View File
@@ -13,6 +13,23 @@
# added this .gitignore line for the git rm --cached that untracked it. # added this .gitignore line for the git rm --cached that untracked it.
/watch/.lock-waf_linux_build /watch/.lock-waf_linux_build
# Android app build output/local config and generated resources (card
# art, launcher icon, synced i18n files - see android/app/build.gradle.kts's
# generateCardArt/generateLauncherIcon/syncI18n tasks and their own
# source scripts' doc comments).
/android/local.properties
/android/.gradle
/android/build
/android/app/build
/android/app/.cxx
/android/app/src/main/assets/i18n
/android/app/src/main/res/drawable-nodpi
/android/app/src/main/res/mipmap-mdpi
/android/app/src/main/res/mipmap-hdpi
/android/app/src/main/res/mipmap-xhdpi
/android/app/src/main/res/mipmap-xxhdpi
/android/app/src/main/res/mipmap-xxxhdpi
# Durable backup of the same real birth data, outside dist/ so a dist/ # Durable backup of the same real birth data, outside dist/ so a dist/
# wipe doesn't lose it — see the Makefile's `scripts` target. Never # wipe doesn't lose it — see the Makefile's `scripts` target. Never
# commit this either. # commit this either.
+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 inside a container image (`build-image/`, built and pushed with
`build-image.sh`/`upload-image.sh`, see "Build image" below) that has `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 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 beyond Docker access to pull that image. The CLI tarball, the `.pbw`,
`.pbw` are uploaded as build artifacts and published to 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. `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
`build-image/Dockerfile` pins the exact toolchain versions (Pebble `build-image/Dockerfile` pins the exact toolchain versions (Pebble
Tool, Pebble SDK core, Node.js) needed for `make test`, `make package`, Tool, Pebble SDK core, Node.js, and the Android SDK/NDK) needed for
and `make watchapp`, so builds are reproducible independent of whatever `make test`, `make package`, `make watchapp`, and `make android`, so
happens to be installed on a given machine. Three root-level scripts builds are reproducible independent of whatever happens to be installed
drive it, each reading registry settings from a gitignored on a given machine. Three root-level scripts drive it, each reading
`registry.env` (copy `registry.env.example` to create it - never commit registry settings from a gitignored `registry.env` (copy
the real file, since it holds registry credentials) and the image tag `registry.env.example` to create it - never commit the real file, since
from `build-image/VERSION`: it holds registry credentials) and the image tag from
`build-image/VERSION`:
- `build-image.sh` - builds and locally tags the image (`:VERSION` and - `build-image.sh` - builds and locally tags the image (`:VERSION` and
`:latest`), no push. `:latest`), no push.
- `run-image.sh [VERSION]` - runs `make test package watchapp` inside - `run-image.sh [VERSION]` - runs `make test package watchapp android`
the locally-built image against a live bind-mount of the repo, wiping inside the locally-built image against a live bind-mount of the repo,
generated build artifacts first for CI parity. Use this to validate a wiping generated build artifacts first for CI parity. Use this to
`build-image/Dockerfile` change before pushing the image anywhere. validate a `build-image/Dockerfile` change before pushing the image
anywhere.
- `upload-image.sh` - logs in and pushes the image (`:VERSION` and - `upload-image.sh` - logs in and pushes the image (`:VERSION` and
`:latest`) to the registry; `.gitea/workflows/build.yml` pulls `: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 ## Architecture
+37 -1
View File
@@ -92,7 +92,12 @@ PACKAGE_DIR := $(BUILD_DIR)/package
WATCH_DIR := watch WATCH_DIR := watch
WATCH_PBW := $(WATCH_DIR)/build/watch.pbw WATCH_PBW := $(WATCH_DIR)/build/watch.pbw
.PHONY: all test clean images i18n scripts package watchapp # ---- Android app ----
ANDROID_DIR := android
ANDROID_APK := $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk
.PHONY: all test clean images i18n scripts package watchapp android
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
@@ -242,3 +247,34 @@ watchapp:
PBW_NAME="deck-in-a-dash-$$V.pbw"; \ PBW_NAME="deck-in-a-dash-$$V.pbw"; \
cp $(WATCH_PBW) "$(DIST_DIR)/$$PBW_NAME"; \ cp $(WATCH_PBW) "$(DIST_DIR)/$$PBW_NAME"; \
echo "Wrote $(DIST_DIR)/$$PBW_NAME" echo "Wrote $(DIST_DIR)/$$PBW_NAME"
# Builds the Android app (`./gradlew assembleDebug` in android/, via its
# own Gradle/AGP/NDK toolchain - not a plain C toolchain, so this is a
# separate target from `all` rather than one of its prerequisites) and
# copies the resulting debug APK into dist/, versioned the same way
# `package`/`watchapp` above version their own artifacts (current git
# tag, or `make android VERSION=1.2.3` to override). Ships the
# auto-generated debug keystore, not a release signing config - see the
# root CLAUDE.md's Android section for why. Requires the Android SDK/NDK
# (ANDROID_HOME/ANDROID_NDK_HOME set, see android/README.md) - this is
# NOT checked by the root `make`/`make test`, so a machine without them
# can still build/test the CLI (and, separately, the watch app).
android:
cd $(ANDROID_DIR) && ./gradlew assembleDebug
@mkdir -p $(DIST_DIR)
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
APK_NAME="deck-in-a-dash-$$V.apk"; \
cp $(ANDROID_APK) "$(DIST_DIR)/$$APK_NAME"; \
echo "Wrote $(DIST_DIR)/$$APK_NAME"
+21 -3
View File
@@ -59,11 +59,19 @@ watch/ The actual Pebble watchapp - links engine/src/
notification, and config page. Built separately notification, and config page. Built separately
from the rest of this repo (`make watchapp`, from the rest of this repo (`make watchapp`,
below) - requires the Pebble SDK. below) - requires the Pebble SDK.
android/ A standalone Android app (independent of the
watch app - neither requires the other) with the
same daily reading + config page, Jetpack Compose
UI, links engine/src/ and interpreter/src/ via
JNI/CMake (see CLAUDE.md's "Android app
packaging"). Built separately (`make android`,
below) - requires the Android SDK/NDK.
docs/ Architecture/dataflow diagrams, input/output format reference. docs/ Architecture/dataflow diagrams, input/output format reference.
build-image/ Dockerfile for the reproducible build environment build-image/ Dockerfile for the reproducible build environment
(C toolchain + Pebble SDK) used by CI and by the (C toolchain + Pebble SDK + Android SDK/NDK) used
build-image.sh/run-image.sh/upload-image.sh scripts by CI and by the build-image.sh/run-image.sh/
at the repo root (see "Building" below). upload-image.sh scripts at the repo root (see
"Building" below).
Makefile Single root Makefile, builds both engine/ and Makefile Single root Makefile, builds both engine/ and
interpreter/, and packages a release (`make package`). interpreter/, and packages a release (`make package`).
``` ```
@@ -98,6 +106,16 @@ make watchapp # -> dist/deck-in-a-dash-<version>.pbw, same version
# resolution as `make package` # resolution as `make package`
``` ```
**Building the Android app** (`android/`, a standalone app independent
of the watch app) needs the separate Android SDK/NDK
(`ANDROID_HOME`/`ANDROID_NDK_HOME` set - see `android/README.md`), so
it's likewise a separate target:
```bash
make android # -> dist/deck-in-a-dash-<version>.apk, same version
# resolution as `make package`
```
**CI runs `make watchapp` too**`.gitea/workflows/build.yml` builds **CI runs `make watchapp` too**`.gitea/workflows/build.yml` builds
and tests everything inside a container built from `build-image/`, and tests everything inside a container built from `build-image/`,
which bundles the Pebble SDK alongside the C toolchain so the runner which bundles the Pebble SDK alongside the C toolchain so the runner
+43
View File
@@ -0,0 +1,43 @@
# Deck in a Dash — Android app
A standalone Android app showing the same daily tarot (Celtic Cross) +
astrology reading as the Pebble watch app (`../watch/`), with a burger
menu leading to a config page. Independent of the watch app - neither
requires the other to be installed. See the root
[`CLAUDE.md`](../CLAUDE.md)'s "Android app" section for the full
architecture (JNI-linked engine/interpreter, JSON bridge schema,
WorkManager notification, card art pipeline).
## Building
Requires:
- A JDK (17+; the project's own `compileOptions`/toolchain target Java
17). `JAVA_HOME` should point at it, or a `java` matching that version
must be first on `PATH`.
- The Android SDK, with `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) set -
needs at minimum `platforms;android-36`, `build-tools;36.0.0`,
`ndk;30.0.14904198`, and `cmake;3.22.1` installed (see
`app/build.gradle.kts`'s `compileSdk`/`ndkVersion` and
`app/src/main/jni/CMakeLists.txt`'s `cmake_minimum_required` for the
exact versions this project pins - `../build-image/Dockerfile`
installs the same ones for CI).
- `ANDROID_NDK_HOME` set to that installed NDK's directory.
- Python 3 + Pillow (`pip install Pillow`) - needed by
`scripts/gen_card_images.py`/`gen_launcher_icon.py`, which Gradle's
`preBuild` task runs automatically (see `app/build.gradle.kts`).
```bash
./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
```
Or, from the repo root: `make android`, which wraps the above and
copies the APK into `dist/deck-in-a-dash-<version>.pbw`'s sibling,
`dist/deck-in-a-dash-<version>.apk` (same version resolution as `make
package`/`make watchapp` - see the root `Makefile`).
The Gradle wrapper (`./gradlew`) resolves its own pinned Gradle version
on first run - no separate Gradle install is needed.
Ships the Android auto-generated debug keystore, not a release-signed
build - see the root `CLAUDE.md`'s Android section for why.
+92
View File
@@ -0,0 +1,92 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "de.ladkau.deckinadash"
compileSdk = 36
ndkVersion = "30.0.14904198"
defaultConfig {
applicationId = "de.ladkau.deckinadash"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "0.0.0-dev"
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
externalNativeBuild {
cmake {
path = file("src/main/jni/CMakeLists.txt")
version = "3.22.1"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
// Regenerates app/src/main/res/drawable-nodpi/<slug>.webp from
// ../../res/img/*.jpeg (see ../../scripts/gen_card_images.py) and syncs
// engine/i18n/*.lang into app/src/main/assets/i18n/ - both are gitignored
// generated output, so a fresh checkout needs them produced before any
// resource-merge/asset-packaging task runs. Mirrors watch/wscript's own
// "generate before the SDK tooling packs resources" fix - see that
// file's own comment for the ordering bug this exact pattern avoids.
val generateCardArt = tasks.register<Exec>("generateCardArt") {
workingDir = rootDir
commandLine("python3", "scripts/gen_card_images.py")
}
val generateLauncherIcon = tasks.register<Exec>("generateLauncherIcon") {
workingDir = rootDir
commandLine("python3", "scripts/gen_launcher_icon.py")
}
val syncI18n = tasks.register<Copy>("syncI18n") {
from("${rootDir}/../engine/i18n")
include("*.lang")
into("src/main/assets/i18n")
}
tasks.named("preBuild") {
dependsOn(generateCardArt, generateLauncherIcon, syncI18n)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
debugImplementation(libs.androidx.ui.tooling)
}
+9
View File
@@ -0,0 +1,9 @@
# kotlinx.serialization needs its generated serializer classes kept.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclasseswithmembers class de.ladkau.deckinadash.data.** {
*** Companion;
}
-keepclasseswithmembers class de.ladkau.deckinadash.data.** {
kotlinx.serialization.KSerializer serializer(...);
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".DeckInADashApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.DeckInADash">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.DeckInADash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,43 @@
package de.ladkau.deckinadash
import android.app.Application
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.notify.NotificationScheduler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import java.io.File
/**
* Copies assets/i18n's .lang files (synced at build time from engine/i18n/ -
* see app/build.gradle.kts's syncI18n task) into filesDir/i18n/ once
* per process start, since i18n_load() (linked into the native library
* unchanged, see reading_json.c) needs real filesystem paths, not APK
* assets. Also reschedules the daily notification on launch - one of
* the two required call sites for NotificationScheduler.reschedule(),
* see its own doc comment for the other (every config save).
*/
class DeckInADashApplication : Application() {
override fun onCreate() {
super.onCreate()
syncI18nAssets()
CoroutineScope(Dispatchers.Default).launch {
val config = ConfigRepository(this@DeckInADashApplication).config.first()
if (config.configured) {
NotificationScheduler.reschedule(this@DeckInADashApplication, config)
}
}
}
private fun syncI18nAssets() {
val outDir = File(filesDir, "i18n").apply { mkdirs() }
val files = assets.list("i18n") ?: return
for (name in files) {
assets.open("i18n/$name").use { input ->
File(outDir, name).outputStream().use { output -> input.copyTo(output) }
}
}
}
}
@@ -0,0 +1,20 @@
package de.ladkau.deckinadash
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import de.ladkau.deckinadash.ui.DeckInADashApp
import de.ladkau.deckinadash.ui.theme.DeckInADashTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DeckInADashTheme {
DeckInADashApp()
}
}
}
}
@@ -0,0 +1,75 @@
package de.ladkau.deckinadash.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(name = "deck_config")
/** Preferences DataStore-backed persistence for [DeckConfig] - see that
* class's own doc comment for why DataStore over SharedPreferences/Proto
* DataStore. */
class ConfigRepository(private val context: Context) {
private object Keys {
val CONFIGURED = booleanPreferencesKey("configured")
val BIRTH_YEAR = intPreferencesKey("birth_year")
val BIRTH_MONTH = intPreferencesKey("birth_month")
val BIRTH_DAY = intPreferencesKey("birth_day")
val BIRTH_HOUR = intPreferencesKey("birth_hour")
val BIRTH_MINUTE = intPreferencesKey("birth_minute")
val UTC_OFFSET_HOURS = doublePreferencesKey("utc_offset_hours")
val LATITUDE = doublePreferencesKey("latitude")
val LONGITUDE = doublePreferencesKey("longitude")
val GENDER = stringPreferencesKey("gender")
val LANG = stringPreferencesKey("lang")
val NOTIFY_ENABLED = booleanPreferencesKey("notify_enabled")
val NOTIFY_HOUR = intPreferencesKey("notify_hour")
val NOTIFY_MINUTE = intPreferencesKey("notify_minute")
}
val config: Flow<DeckConfig> = context.configDataStore.data.map { prefs ->
DeckConfig(
configured = prefs[Keys.CONFIGURED] ?: false,
birthYear = prefs[Keys.BIRTH_YEAR] ?: 0,
birthMonth = prefs[Keys.BIRTH_MONTH] ?: 0,
birthDay = prefs[Keys.BIRTH_DAY] ?: 0,
birthHour = prefs[Keys.BIRTH_HOUR] ?: 0,
birthMinute = prefs[Keys.BIRTH_MINUTE] ?: 0,
utcOffsetHours = prefs[Keys.UTC_OFFSET_HOURS] ?: 0.0,
latitude = prefs[Keys.LATITUDE] ?: 0.0,
longitude = prefs[Keys.LONGITUDE] ?: 0.0,
gender = prefs[Keys.GENDER]?.let { Gender.valueOf(it) } ?: Gender.UNSPECIFIED,
lang = prefs[Keys.LANG] ?: "en",
notifyEnabled = prefs[Keys.NOTIFY_ENABLED] ?: false,
notifyHour = prefs[Keys.NOTIFY_HOUR] ?: 8,
notifyMinute = prefs[Keys.NOTIFY_MINUTE] ?: 0,
)
}
suspend fun save(config: DeckConfig) {
context.configDataStore.edit { prefs ->
prefs[Keys.CONFIGURED] = true
prefs[Keys.BIRTH_YEAR] = config.birthYear
prefs[Keys.BIRTH_MONTH] = config.birthMonth
prefs[Keys.BIRTH_DAY] = config.birthDay
prefs[Keys.BIRTH_HOUR] = config.birthHour
prefs[Keys.BIRTH_MINUTE] = config.birthMinute
prefs[Keys.UTC_OFFSET_HOURS] = config.utcOffsetHours
prefs[Keys.LATITUDE] = config.latitude
prefs[Keys.LONGITUDE] = config.longitude
prefs[Keys.GENDER] = config.gender.name
prefs[Keys.LANG] = config.lang
prefs[Keys.NOTIFY_ENABLED] = config.notifyEnabled
prefs[Keys.NOTIFY_HOUR] = config.notifyHour
prefs[Keys.NOTIFY_MINUTE] = config.notifyMinute
}
}
}
@@ -0,0 +1,32 @@
package de.ladkau.deckinadash.data
/** Mirrors engine/src/tarot.h's Gender enum - ordinal must match. */
enum class Gender {
UNSPECIFIED,
MALE,
FEMALE,
}
/**
* Everything the app needs that isn't computed fresh each day: birth
* data, display language, and daily notification settings. Mirrors
* watch/src/c/config.h's WatchConfig field-for-field - same data, same
* defaults, persisted here via Preferences DataStore (see
* ConfigRepository) instead of Pebble's key-value persist API.
*/
data class DeckConfig(
val configured: Boolean = false,
val birthYear: Int = 0,
val birthMonth: Int = 0,
val birthDay: Int = 0,
val birthHour: Int = 0,
val birthMinute: Int = 0,
val utcOffsetHours: Double = 0.0,
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val gender: Gender = Gender.UNSPECIFIED,
val lang: String = "en",
val notifyEnabled: Boolean = false,
val notifyHour: Int = 8,
val notifyMinute: Int = 0,
)
@@ -0,0 +1,51 @@
package de.ladkau.deckinadash.data
import kotlinx.serialization.Serializable
/**
* Mirrors the JSON schema app/src/main/jni/reading_json.c emits - see
* that file's own header comment. Deserialized with a snake_case naming
* strategy (see ReadingRepository's Json instance), so these property
* names stay idiomatic Kotlin camelCase without needing @SerialName on
* every field.
*/
@Serializable
data class ReadingDto(
val date: String,
val gender: String,
val daySignificance: DaySignificanceDto,
val transits: List<TransitDto>,
val guidance: String,
val spread: List<SpreadPositionDto>,
)
@Serializable
data class DaySignificanceDto(
val levelSlug: String,
val levelLabel: String,
val rank: Int,
val count: Int,
val deservesFraming: Boolean,
)
@Serializable
data class TransitDto(
val title: String,
val transitingPlanetSlug: String,
val natalPlanetSlug: String,
val aspectSlug: String,
val orb: Double,
val score: Double,
val narrative: String,
)
@Serializable
data class SpreadPositionDto(
val positionSlug: String,
val positionName: String,
val positionDescription: String,
val cardSlug: String,
val cardName: String,
val reversed: Boolean,
val cardMeaning: String,
)
@@ -0,0 +1,44 @@
package de.ladkau.deckinadash.data
import android.content.Context
import de.ladkau.deckinadash.nativebridge.DeckNative
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
/**
* Computes a [ReadingDto] via [DeckNative], the single entry point both
* the reading screen (ReadingViewModel) and the daily notification
* (ReadingNotificationWorker) call - see NotificationScheduler's own
* comment on why the notification must reuse this rather than
* duplicating reading logic.
*/
class ReadingRepository(private val context: Context) {
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
}
/** Directory DeckInADashApplication.onCreate() synced engine/i18n's .lang files into. */
private val i18nDir: String
get() = "${context.filesDir}/i18n"
fun compute(config: DeckConfig, utcMomentEpochSeconds: Long): ReadingDto {
val jsonText = DeckNative.generateReadingJson(
birthYear = config.birthYear,
birthMonth = config.birthMonth,
birthDay = config.birthDay,
birthHour = config.birthHour,
birthMinute = config.birthMinute,
utcOffsetHours = config.utcOffsetHours,
latitude = config.latitude,
longitude = config.longitude,
gender = config.gender.ordinal,
utcMomentEpochSeconds = utcMomentEpochSeconds,
i18nDir = i18nDir,
lang = config.lang,
)
return json.decodeFromString(ReadingDto.serializer(), jsonText)
}
}
@@ -0,0 +1,37 @@
package de.ladkau.deckinadash.nativebridge
/**
* JNI bridge to the native `deckengine` shared library (built from
* app/src/main/jni/CMakeLists.txt, which links the same engine +
* interpreter C sources the watch app links - see that CMakeLists.txt's
* own comment). One call returns one complete JSON snapshot of a day's
* reading; see app/src/main/jni/reading_json.h for the exact schema and
* why this is a single call rather than many fine-grained accessors.
*/
object DeckNative {
init {
System.loadLibrary("deckengine")
}
/**
* @param gender 0 = unspecified, 1 = male, 2 = female - matches
* engine/src/tarot.h's Gender enum order.
* @param i18nDir directory containing `<lang>.lang` files (see
* engine/i18n/), synced from assets by DeckInADashApp.onCreate().
* @throws IllegalStateException if native JSON generation fails.
*/
external fun generateReadingJson(
birthYear: Int,
birthMonth: Int,
birthDay: Int,
birthHour: Int,
birthMinute: Int,
utcOffsetHours: Double,
latitude: Double,
longitude: Double,
gender: Int,
utcMomentEpochSeconds: Long,
i18nDir: String,
lang: String,
): String
}
@@ -0,0 +1,53 @@
package de.ladkau.deckinadash.notify
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import de.ladkau.deckinadash.data.DeckConfig
import java.util.Calendar
import java.util.concurrent.TimeUnit
/**
* WorkManager analog of watch/src/c/notify.c's notify_reschedule(): (re)schedules
* a one-shot wakeup for the next occurrence of `config.notifyHour:notifyMinute`
* in the device's local time (today if not yet passed, tomorrow otherwise),
* or only cancels if notifications are disabled. See ReadingNotificationWorker
* for the self-rescheduling chain this kicks off.
*
* Call once per app launch (DeckInADashApplication.onCreate()) and on every
* config save (ConfigViewModel.save()) - same two call sites notify_reschedule()
* itself requires, for the same reason (settings may have changed).
*/
object NotificationScheduler {
private const val WORK_NAME = "daily_reading_notification"
fun reschedule(context: Context, config: DeckConfig) {
val workManager = WorkManager.getInstance(context)
if (!config.notifyEnabled) {
workManager.cancelUniqueWork(WORK_NAME)
return
}
val delayMillis = millisUntilNext(config.notifyHour, config.notifyMinute)
val request = OneTimeWorkRequestBuilder<ReadingNotificationWorker>()
.setInitialDelay(delayMillis, TimeUnit.MILLISECONDS)
.build()
// REPLACE is the WorkManager analog of notify_reschedule()'s own
// cancel-then-reschedule - guarantees no duplicate chains.
workManager.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
private fun millisUntilNext(hour: Int, minute: Int): Long {
val now = Calendar.getInstance()
val target = (now.clone() as Calendar).apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (!target.after(now)) {
target.add(Calendar.DAY_OF_YEAR, 1)
}
return target.timeInMillis - now.timeInMillis
}
}
@@ -0,0 +1,116 @@
package de.ladkau.deckinadash.notify
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import de.ladkau.deckinadash.MainActivity
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.ReadingRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.time.Instant
private const val CHANNEL_ID = "daily_reading"
private const val NOTIFICATION_ID = 1
/**
* Fires once (scheduled by NotificationScheduler.reschedule()), computes
* today's reading via the same ReadingRepository the UI uses (no
* duplicated reading logic - see that class's own comment), posts a
* "quick digest" notification from the day's significance + top
* transit (the same lead-in watch/src/c/ui_report_window.c's own
* comment describes the watch's wakeup-vibrate leading into), then
* reschedules itself for tomorrow - WorkManager has no "repeat forever
* at an exact wall-clock time" primitive, so this self-chaining step is
* how notify_reschedule()'s own daily-repeat contract is reproduced.
*/
class ReadingNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val configRepository = ConfigRepository(applicationContext)
val config = configRepository.config.first()
// Mirrors notify_reschedule()'s own "if disabled, only cancel" branch -
// a defensive no-op, since reschedule() should already have cancelled
// this work item when notifications were turned off.
if (!config.notifyEnabled || !config.configured) {
return Result.success()
}
val reading = try {
withContext(Dispatchers.Default) {
ReadingRepository(applicationContext).compute(config, Instant.now().epochSecond)
}
} catch (t: Throwable) {
NotificationScheduler.reschedule(applicationContext, config)
return Result.failure()
}
postNotification(reading)
NotificationScheduler.reschedule(applicationContext, config)
return Result.success()
}
private fun postNotification(reading: ReadingDto) {
ensureChannel()
val body = reading.transits.firstOrNull()?.let { "${it.title}. ${it.narrative}" }
?: reading.guidance
val openAppIntent = PendingIntent.getActivity(
applicationContext,
0,
Intent(applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(
applicationContext.getString(R.string.notification_title, reading.daySignificance.levelLabel),
)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(openAppIntent)
.build()
if (ActivityCompat.checkSelfPermission(
applicationContext,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
NotificationManagerCompat.from(applicationContext).notify(NOTIFICATION_ID, notification)
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = applicationContext.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = applicationContext.getString(R.string.notification_channel_description)
}
manager.createNotificationChannel(channel)
}
}
@@ -0,0 +1,20 @@
package de.ladkau.deckinadash.ui
import android.content.Context
import androidx.annotation.DrawableRes
/**
* Resolves a card slug (e.g. "fool", "wheel_of_fortune" - the same
* strings engine/src/tarot_data.c's tarot_card_slug() returns) to its
* generated drawable resource, produced by ../../scripts/gen_card_images.py
* into res/drawable-nodpi/<slug>.webp at build time - see that script's
* own comment. Slugs are already valid Android resource-name identifiers,
* so no separate mapping table is needed, only a runtime resource lookup
* (the resource ID isn't known at compile time from this module, since
* the drawables are generated, not committed).
*/
@DrawableRes
fun cardDrawableRes(context: Context, cardSlug: String): Int {
val id = context.resources.getIdentifier(cardSlug, "drawable", context.packageName)
return if (id != 0) id else android.R.drawable.ic_menu_report_image
}
@@ -0,0 +1,107 @@
package de.ladkau.deckinadash.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.ui.about.AboutScreen
import de.ladkau.deckinadash.ui.config.ConfigScreen
import de.ladkau.deckinadash.ui.reading.ReadingScreen
import kotlinx.coroutines.launch
private object Destinations {
const val READING = "reading"
const val SETTINGS = "settings"
const val ABOUT = "about"
}
/** Burger menu = ModalNavigationDrawer, leading to Reading/Settings/About -
* the app's single navigation host, per the plan's UI decision. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeckInADashApp() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_reading)) },
selected = currentRoute == Destinations.READING,
onClick = { navigateTo(navController, Destinations.READING); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_settings)) },
selected = currentRoute == Destinations.SETTINGS,
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_about)) },
selected = currentRoute == Destinations.ABOUT,
onClick = { navigateTo(navController, Destinations.ABOUT); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
},
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
}
},
)
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Destinations.READING,
modifier = Modifier.padding(innerPadding),
) {
composable(Destinations.READING) {
ReadingScreen(onNavigateToSettings = { navigateTo(navController, Destinations.SETTINGS) })
}
composable(Destinations.SETTINGS) { ConfigScreen() }
composable(Destinations.ABOUT) { AboutScreen() }
}
}
}
}
private fun navigateTo(navController: NavHostController, route: String) {
navController.navigate(route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
@@ -0,0 +1,26 @@
package de.ladkau.deckinadash.ui.about
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.ladkau.deckinadash.R
/** Static Waite/Sepharial public-domain attribution, carried forward
* from the root CLAUDE.md's own "Licensing" section, since this app's
* reading screen displays that same card/narrative text verbatim. */
@Composable
fun AboutScreen(modifier: Modifier = Modifier) {
Text(
text = stringResource(R.string.about_body),
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
)
}
@@ -0,0 +1,258 @@
package de.ladkau.deckinadash.ui.config
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.material3.AlertDialog
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.Gender
import java.time.Instant
import java.time.ZoneOffset
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfigScreen(
modifier: Modifier = Modifier,
viewModel: ConfigViewModel = viewModel(),
) {
val config by viewModel.config.collectAsState()
val context = LocalContext.current
var showDatePicker by remember { mutableStateOf(false) }
var showBirthTimePicker by remember { mutableStateOf(false) }
var showNotifyTimePicker by remember { mutableStateOf(false) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
viewModel.update { it.copy(notifyEnabled = granted) }
}
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(R.string.config_birth_date), style = MaterialTheme.typography.titleSmall)
Button(onClick = { showDatePicker = true }) {
val label = if (config.birthYear > 0) {
"%04d-%02d-%02d".format(config.birthYear, config.birthMonth, config.birthDay)
} else {
stringResource(R.string.config_birth_date)
}
Text(label)
}
Text(stringResource(R.string.config_birth_time), style = MaterialTheme.typography.titleSmall)
Button(onClick = { showBirthTimePicker = true }) {
Text("%02d:%02d".format(config.birthHour, config.birthMinute))
}
OutlinedTextField(
value = config.utcOffsetHours.toString(),
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(utcOffsetHours = value) } }
},
label = { Text(stringResource(R.string.config_utc_offset)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = config.latitude.toString(),
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(latitude = value) } }
},
label = { Text(stringResource(R.string.config_latitude)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = config.longitude.toString(),
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(longitude = value) } }
},
label = { Text(stringResource(R.string.config_longitude)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
Text(stringResource(R.string.config_gender), style = MaterialTheme.typography.titleSmall)
val genderOptions = listOf(
Gender.UNSPECIFIED to stringResource(R.string.config_gender_unspecified),
Gender.MALE to stringResource(R.string.config_gender_male),
Gender.FEMALE to stringResource(R.string.config_gender_female),
)
SingleChoiceSegmentedButtonRow {
genderOptions.forEachIndexed { index, (gender, label) ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = index, count = genderOptions.size),
selected = config.gender == gender,
onClick = { viewModel.update { it.copy(gender = gender) } },
) { Text(label) }
}
}
LanguageDropdown(
selected = config.lang,
onSelected = { lang -> viewModel.update { it.copy(lang = lang) } },
)
Row {
Text(stringResource(R.string.config_notify_enabled), style = MaterialTheme.typography.titleSmall)
Switch(
checked = config.notifyEnabled,
onCheckedChange = { enabled ->
if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.update { it.copy(notifyEnabled = enabled) }
}
},
)
}
if (config.notifyEnabled) {
Button(onClick = { showNotifyTimePicker = true }) {
Text("%02d:%02d".format(config.notifyHour, config.notifyMinute))
}
}
Button(onClick = { viewModel.save {} }) {
Text(stringResource(R.string.config_save))
}
}
if (showDatePicker) {
val initialMillis = runCatching {
java.time.LocalDate.of(
config.birthYear.takeIf { it > 0 } ?: 2000,
config.birthMonth.takeIf { it in 1..12 } ?: 1,
config.birthDay.takeIf { it in 1..31 } ?: 1,
).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
}.getOrDefault(Instant.now().toEpochMilli())
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val date = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
viewModel.update {
it.copy(birthYear = date.year, birthMonth = date.monthValue, birthDay = date.dayOfMonth)
}
}
showDatePicker = false
}) { Text(stringResource(R.string.config_save)) }
},
) { DatePicker(state = datePickerState) }
}
if (showBirthTimePicker) {
TimePickerDialogContent(
initialHour = config.birthHour,
initialMinute = config.birthMinute,
onDismiss = { showBirthTimePicker = false },
onConfirm = { hour, minute ->
viewModel.update { it.copy(birthHour = hour, birthMinute = minute) }
showBirthTimePicker = false
},
)
}
if (showNotifyTimePicker) {
TimePickerDialogContent(
initialHour = config.notifyHour,
initialMinute = config.notifyMinute,
onDismiss = { showNotifyTimePicker = false },
onConfirm = { hour, minute ->
viewModel.update { it.copy(notifyHour = hour, notifyMinute = minute) }
showNotifyTimePicker = false
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TimePickerDialogContent(
initialHour: Int,
initialMinute: Int,
onDismiss: () -> Unit,
onConfirm: (hour: Int, minute: Int) -> Unit,
) {
val state = rememberTimePickerState(initialHour = initialHour, initialMinute = initialMinute, is24Hour = true)
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onConfirm(state.hour, state.minute) }) {
Text(stringResource(R.string.config_save))
}
},
text = { TimePicker(state = state) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LanguageDropdown(selected: String, onSelected: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val languages = listOf("en", "de")
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selected,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.config_language)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
languages.forEach { lang ->
DropdownMenuItem(
text = { Text(lang) },
onClick = {
onSelected(lang)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,46 @@
package de.ladkau.deckinadash.ui.config
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.data.DeckConfig
import de.ladkau.deckinadash.notify.NotificationScheduler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
// A local editable draft, seeded once from the persisted config below -
// deliberately not a live collection of configRepository.config, so
// in-progress edits aren't clobbered by DataStore's own emission of the
// write this same screen is about to make on save().
private val _config = MutableStateFlow(DeckConfig())
val config: StateFlow<DeckConfig> = _config.asStateFlow()
init {
viewModelScope.launch {
_config.value = configRepository.config.first()
}
}
fun update(transform: (DeckConfig) -> DeckConfig) {
_config.value = transform(_config.value)
}
/** Persists the current draft and reschedules the daily notification -
* the other of NotificationScheduler.reschedule()'s two required call
* sites (see its own doc comment; the first is app launch). */
fun save(onSaved: () -> Unit) {
viewModelScope.launch {
val toSave = _config.value.copy(configured = true)
configRepository.save(toSave)
NotificationScheduler.reschedule(getApplication(), toSave)
onSaved()
}
}
}
@@ -0,0 +1,156 @@
package de.ladkau.deckinadash.ui.reading
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.SpreadPositionDto
import de.ladkau.deckinadash.data.TransitDto
import de.ladkau.deckinadash.ui.cardDrawableRes
@Composable
fun ReadingScreen(
onNavigateToSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ReadingViewModel = viewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
Box(modifier = modifier.fillMaxSize()) {
when (val state = uiState) {
is ReadingUiState.Loading -> CenteredMessage(stringResource(R.string.reading_loading))
is ReadingUiState.NotConfigured -> Column(
modifier = Modifier.wrapContentSize(Alignment.Center).padding(24.dp),
) {
Text(stringResource(R.string.reading_not_configured))
Button(onClick = onNavigateToSettings, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.nav_settings))
}
}
is ReadingUiState.Error -> CenteredMessage(stringResource(R.string.reading_error))
is ReadingUiState.Content -> ReadingContent(state.reading)
}
}
}
@Composable
private fun CenteredMessage(text: String) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (text == stringResource(R.string.reading_loading)) {
CircularProgressIndicator(modifier = Modifier.padding(bottom = 16.dp))
}
Text(text)
}
}
/** Single scrollable page, same content order as
* watch/src/c/ui_report_window.c's build_content(): date, day
* significance, top transits+narratives, full Celtic Cross spread,
* guidance last. */
@Composable
private fun ReadingContent(reading: ReadingDto) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(reading.date, style = MaterialTheme.typography.labelLarge)
Text(
reading.daySignificance.levelLabel,
style = MaterialTheme.typography.headlineSmall,
)
}
if (reading.transits.isNotEmpty()) {
item {
Text(
stringResource(R.string.reading_transits_heading),
style = MaterialTheme.typography.titleMedium,
)
}
items(reading.transits) { transit -> TransitItem(transit) }
}
item { HorizontalDivider() }
item {
Text(
stringResource(R.string.reading_spread_heading),
style = MaterialTheme.typography.titleMedium,
)
}
items(reading.spread) { position -> SpreadItem(position) }
item { HorizontalDivider() }
item {
Text(
stringResource(R.string.reading_guidance_heading),
style = MaterialTheme.typography.titleMedium,
)
Text(reading.guidance, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun TransitItem(transit: TransitDto) {
Column {
Text(transit.title, style = MaterialTheme.typography.bodyLarge)
if (transit.narrative.isNotBlank()) {
Text(transit.narrative, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun SpreadItem(position: SpreadPositionDto) {
val context = LocalContext.current
Column {
Text(position.positionName, style = MaterialTheme.typography.titleSmall)
Image(
painter = painterResource(cardDrawableRes(context, position.cardSlug)),
contentDescription = position.cardName,
modifier = Modifier
.width(160.dp)
.padding(vertical = 4.dp)
.graphicsLayer { rotationZ = if (position.reversed) 180f else 0f },
)
val cardTitle = if (position.reversed) {
"${position.cardName} ${stringResource(R.string.reading_reversed)}"
} else {
position.cardName
}
Text(cardTitle, style = MaterialTheme.typography.bodyLarge)
Text(position.positionDescription, style = MaterialTheme.typography.bodySmall)
Text(position.cardMeaning, style = MaterialTheme.typography.bodyMedium)
}
}
@@ -0,0 +1,58 @@
package de.ladkau.deckinadash.ui.reading
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.ReadingRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
sealed interface ReadingUiState {
data object Loading : ReadingUiState
data object NotConfigured : ReadingUiState
data class Content(val reading: ReadingDto) : ReadingUiState
data object Error : ReadingUiState
}
/**
* Recomputes today's reading whenever the persisted config changes
* (including the very first emission) - the Compose analog of
* watch/src/c/reading_state.c's reading_state_recompute() own doc
* comment ("safe to call again later... overwrites the previous result
* in place"), just reactive instead of imperatively re-invoked.
*/
class ReadingViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
private val readingRepository = ReadingRepository(application)
private val _uiState = MutableStateFlow<ReadingUiState>(ReadingUiState.Loading)
val uiState: StateFlow<ReadingUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
configRepository.config.collectLatest { config ->
if (!config.configured) {
_uiState.value = ReadingUiState.NotConfigured
return@collectLatest
}
_uiState.value = ReadingUiState.Loading
_uiState.value = try {
val reading = withContext(Dispatchers.Default) {
readingRepository.compute(config, Instant.now().epochSecond)
}
ReadingUiState.Content(reading)
} catch (t: Throwable) {
ReadingUiState.Error
}
}
}
}
}
@@ -0,0 +1,30 @@
package de.ladkau.deckinadash.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@Composable
fun DeckInADashTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
+34
View File
@@ -0,0 +1,34 @@
cmake_minimum_required(VERSION 3.22.1)
project(deckengine C)
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)
# Repo root, three levels up from android/app/src/main/jni/.
get_filename_component(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../.." ABSOLUTE)
# Same engine + interpreter source files the watch app links (see
# watch/wscript's ENGINE_SRCS/INTERP_SRCS and its own comment on why -
# except astro.c is paired with the real, full-precision
# engine/third_party/astronomy/astronomy.c here, not the watch's
# lowprec_ephemeris.c substitute: that substitute exists solely for
# Pebble's 64KB code+data+bss budget, which Android has no equivalent
# of. json.c/reading_io.c are not linked - like the watch, this is one
# process computing and rendering its own reading, no JSON pipe between
# separate binaries.
add_library(deckengine SHARED
deck_jni.c
reading_json.c
${REPO_ROOT}/engine/src/rng.c
${REPO_ROOT}/engine/src/tarot.c
${REPO_ROOT}/engine/src/tarot_data.c
${REPO_ROOT}/engine/src/astro.c
${REPO_ROOT}/engine/src/i18n.c
${REPO_ROOT}/engine/src/reading.c
${REPO_ROOT}/engine/third_party/astronomy/astronomy.c
${REPO_ROOT}/interpreter/src/significance.c
${REPO_ROOT}/interpreter/src/narrative.c
${REPO_ROOT}/interpreter/src/guidance.c
)
target_link_libraries(deckengine m)
+45
View File
@@ -0,0 +1,45 @@
/* Thin JNIEXPORT shim over reading_json.c - keeps all real logic (and
* every engine/interpreter header include) in plain, JNI-free C, same
* separation watch/src/c/notify.c keeps from reading_state.c. */
#include <jni.h>
#include <stdlib.h>
#include "reading_json.h"
JNIEXPORT jstring JNICALL
Java_de_ladkau_deckinadash_nativebridge_DeckNative_generateReadingJson(
JNIEnv *env, jobject thiz,
jint birth_year, jint birth_month, jint birth_day,
jint birth_hour, jint birth_minute, jdouble utc_offset_hours,
jdouble latitude, jdouble longitude,
jint gender, jlong utc_moment_epoch_seconds,
jstring i18n_dir, jstring lang) {
(void)thiz;
BirthData birth = {
.year = birth_year, .month = birth_month, .day = birth_day,
.hour = birth_hour, .minute = birth_minute,
.utc_offset_hours = utc_offset_hours,
.latitude = latitude, .longitude = longitude,
};
const char *i18n_dir_chars = (*env)->GetStringUTFChars(env, i18n_dir, NULL);
const char *lang_chars = (*env)->GetStringUTFChars(env, lang, NULL);
char *json = NULL;
int rc = reading_json_generate(&birth, (Gender)gender, (time_t)utc_moment_epoch_seconds,
i18n_dir_chars, lang_chars, &json);
(*env)->ReleaseStringUTFChars(env, i18n_dir, i18n_dir_chars);
(*env)->ReleaseStringUTFChars(env, lang, lang_chars);
if (rc != 0) {
jclass exc = (*env)->FindClass(env, "java/lang/IllegalStateException");
(*env)->ThrowNew(env, exc, "reading_json_generate failed");
return NULL;
}
jstring result = (*env)->NewStringUTF(env, json);
free(json);
return result;
}
+198
View File
@@ -0,0 +1,198 @@
/* open_memstream() is POSIX (2008), not C99 - needs this feature-test
* macro under -std=c99 (glibc and Android's bionic both support it from
* a plain #define, no libc version gate below Android API 23, well
* under this app's minSdk 24). */
#define _POSIX_C_SOURCE 200809L
#include "reading_json.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../../../../../engine/src/i18n.h"
#include "../../../../../engine/src/reading.h"
#include "../../../../../interpreter/src/guidance.h"
#include "../../../../../interpreter/src/narrative.h"
#include "../../../../../interpreter/src/significance.h"
/* Same convention as engine/src/main.c's own default_i18n_path() -
* duplicated rather than shared, same reasoning CLAUDE.md gives for why
* engine/src/main.c and interpreter/src/main.c each keep their own copy
* (the binaries - here, shared libraries - are never linked together). */
static void i18n_path(const char *dir, const char *lang, char *out, size_t out_size) {
snprintf(out, out_size, "%s/%s.lang", dir, lang);
}
static const char *const k_day_level_slug[DAY_SIGNIFICANCE_COUNT] = {
"quiet", "notable", "significant", "major",
};
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet");
case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable");
case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant");
case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major");
default: return "Unknown";
}
}
/* "Transiting Saturn Square natal Sun" - the same title text
* interpreter/src/main.c's --format text prints per transit item, built
* here (rather than left to Kotlin) since it's fully localized via
* astro_body_name()/astro_aspect_name() - Kotlin never needs its own
* planet/aspect display-name table. */
static char *build_transit_title(const Aspect *a) {
char buf[160];
snprintf(buf, sizeof buf, "%s %s %s %s %s", i18n_get("ui.transiting", "Transiting"),
astro_body_name(a->transiting_planet), astro_aspect_name(a->type),
i18n_get("ui.natal", "natal"), astro_body_name(a->natal_planet));
size_t len = strlen(buf);
char *out = malloc(len + 1);
memcpy(out, buf, len + 1);
return out;
}
static char *capture_narrative(const Aspect *aspect, int house) {
char stack_buf[NARRATIVE_TEXT_MAX];
narrative_write(stack_buf, sizeof stack_buf, "", aspect, house);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
char stack_buf[GUIDANCE_TEXT_MAX];
guidance_write(stack_buf, sizeof stack_buf, "", interp, spread);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
static void json_string(FILE *out, const char *s) {
fputc('"', out);
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
switch (c) {
case '"': fputs("\\\"", out); break;
case '\\': fputs("\\\\", out); break;
case '\n': fputs("\\n", out); break;
case '\r': fputs("\\r", out); break;
case '\t': fputs("\\t", out); break;
default:
if (c < 0x20) fprintf(out, "\\u%04x", c);
else fputc((char)c, out);
}
}
fputc('"', out);
}
static void format_date(time_t utc_moment, char *out, size_t out_size) {
struct tm *utc = gmtime(&utc_moment);
snprintf(out, out_size, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
}
int reading_json_generate(const BirthData *birth, Gender gender, time_t utc_moment,
const char *i18n_dir, const char *lang, char **out_json) {
char path[512];
i18n_path(i18n_dir, lang, path, sizeof path);
i18n_load(path); /* non-fatal on failure - falls back to built-in English, same as engine/src/main.c */
/* The tarot seed is today's UTC date, same convention
* dist/run-engine.sh and watch/src/c/reading_state.c use. */
char seed[16];
format_date(utc_moment, seed, sizeof seed);
DailyReading reading;
reading_generate(seed, birth, gender, utc_moment, &reading);
DailyInterpretation interp;
interpret_daily_reading(&reading, &interp);
size_t json_len = 0;
char *json_buf = NULL;
FILE *out = open_memstream(&json_buf, &json_len);
if (!out) return 1;
fprintf(out, "{\n");
char date_buf[32];
format_date(reading.utc_moment, date_buf, sizeof date_buf);
fprintf(out, " \"date\": ");
json_string(out, date_buf);
fprintf(out, ",\n \"gender\": ");
json_string(out, tarot_gender_slug(reading.gender));
fprintf(out, ",\n");
fprintf(out, " \"day_significance\": {\n");
fprintf(out, " \"level_slug\": \"%s\",\n", k_day_level_slug[interp.day_level]);
fprintf(out, " \"level_label\": ");
json_string(out, day_level_name(interp.day_level));
fprintf(out, ",\n \"rank\": %d,\n \"count\": %d,\n \"deserves_framing\": %s\n",
interp.day_level + 1, DAY_SIGNIFICANCE_COUNT,
interpretation_deserves_framing(&interp) ? "true" : "false");
fprintf(out, " },\n");
fprintf(out, " \"transits\": [\n");
for (int i = 0; i < interp.top_item_count; i++) {
const SignificantItem *item = &interp.top_items[i];
const Aspect *a = &item->aspect;
const PlanetPosition *tp = &reading.transits.bodies[a->transiting_planet];
char *title = build_transit_title(a);
char *narrative = capture_narrative(a, tp->house);
fprintf(out, " {\n");
fprintf(out, " \"title\": ");
json_string(out, title);
fprintf(out, ",\n \"transiting_planet_slug\": \"%s\",\n", astro_body_slug(a->transiting_planet));
fprintf(out, " \"natal_planet_slug\": \"%s\",\n", astro_body_slug(a->natal_planet));
fprintf(out, " \"aspect_slug\": \"%s\",\n", astro_aspect_slug(a->type));
fprintf(out, " \"orb\": %.4f,\n", a->orb);
fprintf(out, " \"score\": %.4f,\n", item->score);
fprintf(out, " \"narrative\": ");
json_string(out, narrative);
fprintf(out, "\n }%s\n", i + 1 < interp.top_item_count ? "," : "");
free(title);
free(narrative);
}
fprintf(out, " ],\n");
char *guidance = capture_guidance(&interp, &reading.spread);
fprintf(out, " \"guidance\": ");
json_string(out, guidance);
fprintf(out, ",\n");
free(guidance);
fprintf(out, " \"spread\": [\n");
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
CelticCrossPosition position = (CelticCrossPosition)i;
const TarotDraw *draw = &reading.spread.positions[i];
fprintf(out, " {\n");
fprintf(out, " \"position_slug\": \"%s\",\n", tarot_position_slug(position));
fprintf(out, " \"position_name\": ");
json_string(out, tarot_position_name(position, reading.gender));
fprintf(out, ",\n \"position_description\": ");
json_string(out, tarot_position_description(position, reading.gender));
fprintf(out, ",\n \"card_slug\": \"%s\",\n", tarot_card_slug(draw->card));
fprintf(out, " \"card_name\": ");
json_string(out, tarot_card_name(draw->card));
fprintf(out, ",\n \"reversed\": %s,\n", draw->reversed ? "true" : "false");
fprintf(out, " \"card_meaning\": ");
json_string(out, tarot_card_meaning(draw->card, draw->reversed));
fprintf(out, "\n }%s\n", i + 1 < TAROT_SPREAD_SIZE ? "," : "");
}
fprintf(out, " ]\n");
fprintf(out, "}\n");
fclose(out);
*out_json = json_buf;
return 0;
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef DECKINADASH_READING_JSON_H
#define DECKINADASH_READING_JSON_H
#include <time.h>
#include "../../../../../engine/src/astro.h"
#include "../../../../../engine/src/tarot.h"
/* Recomputes a full day's reading (astro + tarot + interpretation) from
* `birth`/`gender`/`utc_moment` - same inputs and same
* reading_generate()/interpret_daily_reading() calls
* watch/src/c/reading_state.c makes - and serializes everything a UI
* needs to render it (in watch/src/c/ui_report_window.c's own section
* order: date, day significance, top transits+narratives, full Celtic
* Cross spread, guidance last) as one JSON string, structurally a port
* of interpreter/src/main.c's interp_print_json() targeting a heap
* buffer instead of stdout.
*
* `i18n_dir`/`lang` locate `<i18n_dir>/<lang>.lang` (same convention as
* engine/src/main.c's default_i18n_path()); a missing/unreadable file is
* a non-fatal fallback to built-in English (i18n_load()'s own contract),
* not an error returned here.
*
* On success, returns a malloc'd, null-terminated JSON string in
* `*out_json` (caller must free()) and returns 0. Returns non-zero and
* leaves `*out_json` untouched only if the underlying open_memstream()
* allocation itself fails - not a condition this codebase's other
* callers (deck-engine, interpreter-cli) treat as reachable in practice. */
int reading_json_generate(const BirthData *birth, Gender gender, time_t utc_moment,
const char *i18n_dir, const char *lang, char **out_json);
#endif
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status-bar notification icon: must be a flat white silhouette on
transparent (the system applies its own tint) - a simple crescent
moon + star, evoking the astrology half of this app's reading. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FF000000"
android:pathData="M12.5,3 C8.4,3.6 5.3,7.1 5.3,11.4 C5.3,16.1 9.1,19.9 13.8,19.9 C16.9,19.9 19.6,18.2 21,15.7 C19.9,16.4 18.6,16.8 17.2,16.8 C12.9,16.8 9.4,13.3 9.4,9 C9.4,6.7 10.5,4.6 12.5,3 Z" />
<path
android:fillColor="#FF000000"
android:pathData="M18.5,3 L19.2,4.8 L21,5.5 L19.2,6.2 L18.5,8 L17.8,6.2 L16,5.5 L17.8,4.8 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B1B2F</color>
<color name="purple_primary">#6750A4</color>
</resources>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Deck in a Dash</string>
<string name="nav_reading">Today\'s Reading</string>
<string name="nav_settings">Settings</string>
<string name="nav_about">About</string>
<string name="notification_channel_name">Daily reading</string>
<string name="notification_channel_description">A daily notification with your tarot and astrology reading</string>
<string name="notification_title">Today\'s reading: %1$s</string>
<string name="config_title">Settings</string>
<string name="config_birth_date">Birth date</string>
<string name="config_birth_time">Birth time</string>
<string name="config_utc_offset">UTC offset (hours)</string>
<string name="config_latitude">Latitude</string>
<string name="config_longitude">Longitude</string>
<string name="config_gender">Reading for</string>
<string name="config_gender_unspecified">Unspecified</string>
<string name="config_gender_male">Male</string>
<string name="config_gender_female">Female</string>
<string name="config_language">Language</string>
<string name="config_notify_enabled">Daily notification</string>
<string name="config_notify_time">Notification time</string>
<string name="config_save">Save</string>
<string name="reading_not_configured">Set your birth details in Settings to see today\'s reading.</string>
<string name="reading_loading">Computing today\'s reading…</string>
<string name="reading_error">Could not compute today\'s reading.</string>
<string name="reading_guidance_heading">Guidance</string>
<string name="reading_transits_heading">Significant transits</string>
<string name="reading_spread_heading">Celtic Cross</string>
<string name="reading_reversed">(Reversed)</string>
<string name="about_body">Deck in a Dash shows a daily personalized tarot (Celtic Cross) and astrology (natal chart + transits) reading.\n\nCard and spread text is condensed from A. E. Waite\'s The Pictorial Key to the Tarot (1911, public domain). Transit narrative text is condensed from Sepharial\'s Transits and Planetary Periods (1920, public domain).</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DeckInADash" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
+35
View File
@@ -0,0 +1,35 @@
[versions]
agp = "9.2.1"
kotlin = "2.2.20"
coreKtx = "1.17.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.13.0"
composeBom = "2026.06.01"
navigationCompose = "2.9.8"
datastorePreferences = "1.2.1"
workRuntimeKtx = "2.11.2"
kotlinxSerializationJson = "1.11.0"
kotlinxCoroutines = "1.11.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+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()
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "deck-in-a-dash-android"
include(":app")
+50 -5
View File
@@ -1,7 +1,8 @@
# Build environment for deck_in_a_dash release artifacts: a plain C # Build environment for deck_in_a_dash release artifacts: a plain C
# toolchain for the desktop deck-engine/interpreter-cli binaries (`make # toolchain for the desktop deck-engine/interpreter-cli binaries (`make
# test`, `make package`) plus the Pebble SDK for the watch app (`make # test`, `make package`), the Pebble SDK for the watch app (`make
# watchapp`), pinned to the versions validated on the maintainer's dev # watchapp`), and the Android SDK/NDK for the Android app (`make
# android`), pinned to the versions validated on the maintainer's dev
# machine. Rebuild with ../build-image.sh whenever a version below # machine. Rebuild with ../build-image.sh whenever a version below
# changes. # changes.
# #
@@ -12,17 +13,28 @@ FROM ubuntu:22.04
ARG PEBBLE_TOOL_VERSION=5.0.39 ARG PEBBLE_TOOL_VERSION=5.0.39
ARG PEBBLE_SDK_CORE_VERSION=4.17 ARG PEBBLE_SDK_CORE_VERSION=4.17
ARG NODE_VERSION=24.16.0 ARG NODE_VERSION=24.16.0
ARG ANDROID_CMDLINE_TOOLS_VERSION=9862592
ARG ANDROID_PLATFORM_VERSION=36
ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0
ARG ANDROID_NDK_VERSION=30.0.14904198
ARG ANDROID_CMAKE_VERSION=3.22.1
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
PATH=/root/.local/bin:${PATH} PATH=/root/.local/bin:${PATH}
# build-essential: gcc/make for the desktop deck-engine/interpreter-cli # build-essential: gcc/make for the desktop deck-engine/interpreter-cli
# binaries (`make test`, `make package`). # binaries (`make test`, `make package`) and for the Android app's JNI
# native library (app/src/main/jni/CMakeLists.txt - the NDK's own
# toolchain does the actual cross-compiling, but AGP's build needs a
# host C toolchain available too).
# python3/python3-venv/python3-pip: `pebble sdk install` below creates a # python3/python3-venv/python3-pip: `pebble sdk install` below creates a
# venv per SDK version; Pillow (installed below) is needed at `pebble # venv per SDK version; Pillow (installed below) is needed at `pebble
# build` time by watch/scripts/gen_card_images.py and gen_app_icon.py. # build` time by watch/scripts/gen_card_images.py and gen_app_icon.py,
# and by the Android app's android/scripts/gen_card_images.py and
# gen_launcher_icon.py.
# git/curl/ca-certificates/unzip/tar/xz-utils: checkout, tool downloads, # git/curl/ca-certificates/unzip/tar/xz-utils: checkout, tool downloads,
# and `make package`/`make watchapp`'s `git describe --tags` versioning. # and `make package`/`make watchapp`/`make android`'s `git describe
# --tags` versioning.
# openssh-client: the build workflow's `sftp` publish to dl.ladkau.de. # openssh-client: the build workflow's `sftp` publish to dl.ladkau.de.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential python3 python3-venv python3-pip \ build-essential python3 python3-venv python3-pip \
@@ -46,4 +58,37 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& uv tool install "pebble-tool==${PEBBLE_TOOL_VERSION}" \ && uv tool install "pebble-tool==${PEBBLE_TOOL_VERSION}" \
&& pebble sdk install "${PEBBLE_SDK_CORE_VERSION}" && pebble sdk install "${PEBBLE_SDK_CORE_VERSION}"
# Android SDK/NDK: openjdk-17 (Gradle/AGP's own minimum JDK) + the
# cmdline-tools' own sdkmanager to install exactly the platform/build-
# tools/NDK/CMake versions android/app/build.gradle.kts pins - unlike
# the Pebble/CLI build path above, `make android`'s own Gradle build
# still needs network access at build time (Gradle/AGP/androidx
# dependency resolution against Google's Maven + Maven Central - see the
# root CLAUDE.md's Android section for why this is an accepted,
# deliberate deviation from this image's otherwise-offline build
# philosophy). android/gradlew resolves its own pinned Gradle version -
# no Gradle binary is baked into this image.
RUN apt-get update && apt-get install -y --no-install-recommends openjdk-17-jdk-headless \
&& rm -rf /var/lib/apt/lists/*
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk
ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
&& curl -sSL -o /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
&& unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm /tmp/cmdline-tools.zip \
&& yes | sdkmanager --licenses >/dev/null \
&& sdkmanager --install \
"platform-tools" \
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
"ndk;${ANDROID_NDK_VERSION}" \
"cmake;${ANDROID_CMAKE_VERSION}"
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}
WORKDIR /workspace WORKDIR /workspace
+1 -1
View File
@@ -1 +1 @@
1 2
+19 -12
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Runs `make test package watchapp` inside the local build-image # Runs `make test package watchapp android` inside the local build-image
# container, mirroring what the Gitea Actions build workflow does - # container, mirroring what the Gitea Actions build workflow does -
# useful for testing build-image changes locally before pushing # useful for testing build-image changes locally before pushing
# anything to cr.ladkau.de. # anything to cr.ladkau.de.
# #
# Usage: ./run-image.sh [VERSION] # Usage: ./run-image.sh [VERSION]
# VERSION is passed through to `make` (see the Makefile's `package`/ # VERSION is passed through to `make` (see the Makefile's `package`/
# `watchapp` targets); omit it for their own git-tag-based default. # `watchapp`/`android` targets); omit it for their own git-tag-based
# default.
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" cd "$(dirname "${BASH_SOURCE[0]}")"
@@ -36,22 +37,28 @@ VERSION="${1:-${VERSION:-}}"
# CI always starts from a fresh checkout, but this script bind-mounts the # CI always starts from a fresh checkout, but this script bind-mounts the
# live host repo — so build output left over from a previous local run # live host repo — so build output left over from a previous local run
# (build/, dist/, watch/build, and the generated watch resources below) # (build/, dist/, watch/build, the generated watch resources below, and
# would make this run skip work a real fresh checkout wouldn't, silently # the Android app's own build/generated-resource dirs) would make this
# hiding bugs that only show up in CI. Wipe them first so every run # run skip work a real fresh checkout wouldn't, silently hiding bugs that
# exercises a true from-scratch build, same as CI. # only show up in CI. Wipe them first so every run exercises a true
# from-scratch build, same as CI.
echo "== Cleaning generated build artifacts for a fresh build ==" echo "== Cleaning generated build artifacts for a fresh build =="
rm -rf \ rm -rf \
"$ROOT/build" "$ROOT/dist" "$ROOT/watch/build" \ "$ROOT/build" "$ROOT/dist" "$ROOT/watch/build" \
"$ROOT/watch/resources/img" "$ROOT/watch/resources/app_icon.png" \ "$ROOT/watch/resources/img" "$ROOT/watch/resources/app_icon.png" \
"$ROOT/watch/src/c/i18n_tables.auto.c" "$ROOT/watch/src/c/i18n_tables.auto.c" \
"$ROOT/android/build" "$ROOT/android/app/build" "$ROOT/android/app/.cxx" \
"$ROOT/android/app/src/main/assets/i18n" "$ROOT/android/app/src/main/res/drawable-nodpi" \
"$ROOT/android/app/src/main/res"/mipmap-mdpi "$ROOT/android/app/src/main/res"/mipmap-hdpi \
"$ROOT/android/app/src/main/res"/mipmap-xhdpi "$ROOT/android/app/src/main/res"/mipmap-xxhdpi \
"$ROOT/android/app/src/main/res"/mipmap-xxxhdpi
echo "== Running make test package watchapp inside $IMAGE ==" echo "== Running make test package watchapp android inside $IMAGE =="
# The container runs as root (needed for the toolchain baked into the # The container runs as root (needed for the toolchain baked into the
# image), so anything it writes into this bind mount — dist/, build/, # image), so anything it writes into this bind mount — dist/, build/,
# watch/build, etc. — would otherwise come back owned by root, leaving # watch/build, android/.gradle, etc. — would otherwise come back owned
# the host repo unusable without sudo. Chown everything back to the host # by root, leaving the host repo unusable without sudo. Chown everything
# user on exit, whether the build succeeds or fails. # back to the host user on exit, whether the build succeeds or fails.
docker run --rm \ docker run --rm \
-v "$ROOT:/workspace" \ -v "$ROOT:/workspace" \
-w /workspace \ -w /workspace \
@@ -62,7 +69,7 @@ docker run --rm \
bash -c ' bash -c '
git config --global --add safe.directory /workspace git config --global --add safe.directory /workspace
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
make test package watchapp make test package watchapp android
' '
echo "== Done — artifacts in dist/ ==" echo "== Done — artifacts in dist/ =="