tally/android/README.md

4 KiB

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.phrase through 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.