| .. | ||
| app | ||
| audit-libs.sh | ||
| build.gradle.kts | ||
| gradle.properties | ||
| README.md | ||
| settings.gradle.kts | ||
| SETUP.md | ||
Tally for Android
Three screens, reached from a hamburger drawer: the standard calculator (design 9.2), an
NCalc-style pad with a live answer as you type; programmer mode (design 9.3), every base
of a value at once and a bit grid to edit it; and the converter (design 9.6), which shows
a value in every unit at once. Each reopens exactly as it was left. Settings has the
theme: follow the device, light or dark. The engine behind it is the finished one, so
2^100 + 1, x = 5 then x * 2 and cagr(10000, 25000, 5) * 100 all work. The
financial screen (design 9.5) is not built.
Building
Nothing is installed system-wide; the toolchain comes from mise. Setup, emulator and deployment, including wireless adb, are in SETUP.md - this file is about what the app is.
mise install && mise run android-sdk # toolchain, then the SDK packages
zig build android # the native library, one .so per ABI
cd android && gradle assembleDebug
zig build android writes zig-out/android/{arm64-v8a,x86_64,armeabi-v7a}/libtally.so,
which is the jniLibs layout Gradle expects, so Gradle only packages it. Gradle never
invokes Zig, and there is no CMake or ndk-build in this project: the library is built
without the NDK entirely (design 6.2), and the JNI glue is hand-written Zig
(engine/src/jni.zig) rather than generated from jni.h.
Because Gradle never invokes Zig, zig build android has to be re-run by hand after any
engine or JNI change, or the APK gets a stale library.
The Gradle wrapper is not committed; gradle wrapper writes one, or Android Studio offers
to on first open.
The first thing to run, and it needs no device
zig build jvm-test
engine/src/jni.zig reaches the JNI function table by index, because there is no jni.h
here to name the entries. Four of those indices have to be right. JNI's table is fixed by
the specification rather than by the platform, so a desktop JVM checks them just as well as
a phone: this step builds the library for the host, loads it from Java (which runs
JNI_OnLoad, which round-trips a string through two of the four), and drives every entry
point. Moving one index by a slot makes it fail.
Then, with a device or emulator attached:
cd android && gradle connectedAndroidTest
TallyEngineTest covers what only the real app can: that a session remembers x and
Ans, that 2^100 + 1 arrives with its last digit intact, that 12 in to ft is exactly
1, that an error carries the engine's own wording rather than a second copy written in
Kotlin, and that a closed session refuses to evaluate instead of crashing. It passes on an
Android 15 x86_64 image with 16KB pages. Note that it uninstalls the app when it finishes.
The rules the screens follow - the converter's memory, programmer mode's input and
register, the saved calculator state - are plain Kotlin with JVM tests that need no
device: gradle testDebugUnitTest.
Verified on a physical device too: a Galaxy S23+ (arm64-v8a, 4 KB pages, Android 16).
arm64-v8a is the interesting ABI: it is the one phones actually use, and the only one
where the page-size bug in design 6.3 showed up. mise run android-audit checks every
ABI's library for the symbols that would make dlopen fail.
Release builds
gradle assembleRelease makes an unsigned release APK. CI signs it with the HSM (see
the top-level README, "Signing"); mise run apk-signer-test runs that signing against
SoftHSM locally. A release APK and a debug build are signed by different keys, so one
cannot update the other: uninstall first.
What is deliberately not here
- No arithmetic. Kotlin parses no expressions, formats no numbers and words no errors. All of it is the shared engine, so a wrong answer is wrong in the CLI and TUI too, which is the point of having one engine.
- No error table. Messages come from
engine.phrasethrough the JSON. - No unit list.
TallySession.unitCatalog()returns the engine's own tables, aliases and display names included, and the converter is built from it.