whoami

Lucas Jenß

cat /etc/motd

The Coding Journal ツ — Notes taken on an epic coding journey. Technical solutions, debugging notes, and practical guides from the trenches of software development.

Close-up of white dominoes with black dots standing on green felt surface, shallow depth of field, focused mood

ls -la ~/languages/

total 8
drwxr-xr-x
▶ PHP
▶ Ruby
▶ Scala
▶ C#
▶ JavaScript
▶ Objective-C
▶ Shell Scripting

ls -la ~/toolchain/

total 7
drwxr-xr-x
▶ Typo3
▶ Akka
▶ Capistrano
▶ Git
▶ MAMP
▶ Adobe Illustrator
▶ NSTrackingArea (Cocoa)

uname -a

platforms
drwxr-xr-x
▶ Mac OS X
▶ Unix

Embedding Lua in an Android App with the NDK

Embedding a scripting engine lets an Android app change rules, formulas and workflows without rebuilding the application. Lua fits well because its compact C API, small runtime and predictable state model work inside a native library. The Android NDK provides the compiler and linker, but the difficult work sits at the boundaries: JNI, Android’s process lifecycle, packaging and keeping untrusted code away from sensitive APIs.

A workable integration exposes a narrow interface rather than connecting every Lua function to Kotlin. This walkthrough covers compiling the VM, invoking scripts, reporting failures safely and packaging the result for Google Play users in Australia. The same discipline matters on lower-cost Android phones as on developer emulators: constrained memory and background limits expose problems that a fast workstation often hides.

Why native scripting belongs in an Android app

A scripting layer is useful when behaviour changes more often than the UI. It can power validation rules, scenario previews or configurable exports while keeping the core application in Kotlin or Java. Running it inside the app avoids sending private data to a remote interpreter. Lua’s syntax is approachable, and its compact state model suits short, data-oriented scripts.

Keep ownership clear. The native library owns the Lua virtual machine and its C data structures; Kotlin owns Android services, permissions and screens. JNI should pass simple values such as strings, numbers and byte arrays. A script should not receive an unrestricted Activity reference, because that would bypass the controls your app applies to files, contacts or location. A small façade provides only the capabilities each script needs.

Threading is another design decision. A Lua state is not automatically safe for concurrent calls, and Android callbacks may arrive on different threads. Run the VM on a dedicated executor, serialise access with a mutex and post results to the main thread. Avoid long native calls on the UI thread, where Android may terminate an unresponsive app. This separation also makes failures reproducible in headless tests.

Build the Lua VM and native library

Install the NDK, CMake and the required Android SDK platform, then let Gradle drive the native build. Prefer a checked-in CMakeLists.txt over undocumented IDE settings. A typical layout keeps Lua source in third_party/lua, bridge code in src/main/cpp and the shared library target in the same native build. Lua’s C files can form a static library linked into one libscripting.so, avoiding a runtime search for another .so file.

Build one shared library per Application Binary Interface: arm64-v8a for current phones, armeabi-v7a for older 32-bit devices and x86_64 for emulators. Do not mistake an x86 emulator image for an arm64 test. The 64-bit ABI matters to Google Play’s current device requirements, while a 32-bit slice can widen compatibility with older hardware. The NDK’s sysroot supplies the correct libc and headers for each target.

Pin the NDK revision, Lua release, CMake version and compiler flags. I keep them with the exact failing command in my engineering notes, since a missing symbol can look like a Lua bug when it is really a native-toolchain mismatch. Teams in Sydney, Melbourne and Brisbane share a UTC+10 base offset, but daylight-saving transitions still affect scheduled jobs and stored timestamps. Test those boundaries with fixed inputs and a configurable time zone.

Connect Lua to Kotlin through JNI

Expose a Kotlin function such as nativeRunScript(script: String): String and keep the native surface intentionally boring. Initialise the Lua state once, or create and close it per execution when isolation matters more than speed. Load the script with luaL_loadbuffer, call it with lua_pcall and read the result from the Lua stack. Remove unused stack values so repeated runs cannot grow the stack without limit.

The central native sequence is short:

lua_State* L = luaL_newstate();
int status = luaL_loadbuffer(L, source, length, "script.lua");
if (status == LUA_OK) {
    status = lua_pcall(L, 0, 1, 0);
}
// Read the result or error from L, then call lua_close(L).

Use a traceback error handler so a syntax error includes the script name and line, and return that message to Kotlin as an ordinary failure. Do not let an uncaught Lua error cross the JNI boundary. If Lua calls Java, store the JavaVM in a long-lived owner and check whether the current thread is attached before invoking a callback. Release JNI string handles and local references on every path; repeated calls can otherwise exhaust memory.

Native bridges are easy to document badly because each platform has its own lifecycle rules. This Cocoa tracking example is a useful reminder to record how a callback is registered, when it is released and which thread owns it. The same checklist applies on Android: explain ownership of the Lua state, JNI references and the executor used for callbacks.

Design a sandbox for scripts

Sandboxing begins with what you do not expose. The standard library opener loads modules such as os, io and package; omit them unless scripts need them, and provide narrow replacements for approved file or network operations. Lua’s filesystem functions inherit Android app permissions, but permission alone is not a capability policy. A script supplied by a customer can still contain an infinite loop or allocate until the process is killed.

Add execution limits as well. A debug hook can count VM instructions and stop a script after a budget, while a custom allocator can cap memory. For scripts from genuinely untrusted sources, run the engine in a separate, disposable process and communicate through a minimal IPC channel. Parsing a script is not validation, and precompiled Lua bytecode should never be accepted unless you trust its origin.

If the engine reaches personal information, expose only a service that enforces the app’s permission checks and user consent. Australian teams should also check the Privacy Act 1988 and Australian Privacy Principles when personal information leaves the device; a local script engine does not create an exemption. The Australian Consumer Law can also apply to digital functionality promised as part of a paid app or service.

Package assets and test real devices

Store scripts as UTF-8 assets and read them through AssetManager, or extract them to app-private storage when a tool genuinely needs to edit them. Files in assets are read-only, while extracted copies can be modified on disk. Keep large data out of Lua source, and give each script a meaningful chunk name so errors remain diagnosable.

Load the shared library with System.loadLibrary before calling any JNI method, and verify the packaged APK or app bundle includes the expected ABI directories. An emulator failure after changing architecture often means the new image and native library disagree. Test on a physical arm64 phone, an x86_64 emulator and a device with little free storage. Native crashes produce tombstone or logcat output, while ordinary script failures should stay in the app’s error channel.

Treat process death as normal. Android may kill a backgrounded app, so restore or recreate the Lua state instead of assuming it survived. Use WorkManager with network and battery constraints for lengthy background work, and make retries idempotent. Teams moving between Melbourne and Adelaide can encounter patchy mobile coverage, so timeout and retry rules belong beside the script runner rather than in scattered call sites.

Ship the integration and keep learning

The scripting engine choice shapes the native surface, package size and testing effort. Compare the common candidates before committing:

Option Advantages Trade-offs Best fit
Lua 5.4 Compact C API; predictable VM Standard interpreter speed; build a deliberate sandbox Existing Lua scripts with tight native control
LuaJIT Fast execution for compatible Lua 5.1 code Different language semantics; NDK and 64-bit builds need care CPU-heavy scripts on tested platforms
Wren Small runtime; friendly syntax Smaller ecosystem; no Lua compatibility A new embedded scripting language
QuickJS Modern ECMAScript; familiar JavaScript tooling Heavier integration; separate API security model Teams standardised on JavaScript

For this use case, standard Lua offers the best balance of control, familiarity and a manageable footprint. LuaJIT can improve hot paths, but its language version and build requirements need deliberate checking. The alternatives are reasonable when team skills or an existing script library matter more than Lua compatibility. Whichever engine you choose, keep the JNI boundary narrow, make failures explicit and test the release build on representative hardware.

Subscribe to The Coding Journal for practical notes on native builds, scripting engines and release-day troubleshooting.


cat ~/interests.json

KeyValue
editorTerminal-first workflow
osMac OS X / Unix
vcsGit, distributed version control
deployCapistrano, cron automation
graphicsSVG, Adobe Illustrator troubleshooting
networkingIP validation, SSH, VPN

git log --oneline --reverse

2013-10-30

Solving SVG import issues in Adobe Illustrator CS6 and CC

When importing an SVG into Illustrator, the operation fails with an unknown error [CANT]. A workaround for this Adobe-side bug.

2013

Solving NDK build issues on OS X

Troubleshooting native development kit compilation problems on Mac OS X.

2013

Programmatically adding PHP generated TypoScript to the backend configuration

Integrating dynamically generated TypoScript into Typo3 backend setups using PHP.

2013

ArgumentError: Could not parse PKey: no start line

Debugging an SSH key parsing error encountered during deployment.

2011-08-04

Validating IP-Addresses in PHP

Using PHP filter functions with flags like FILTER_FLAG_IPV4 and FILTER_FLAG_IPV6, and understanding how filter_var handles reserved IP addresses.

2011-07-09

Cocoa: Using NSTrackingArea

A short tutorial on using Cocoa's NSTrackingArea to capture mouseEntered and mouseExited events.


cat ~/contact.txt

github: github.com/x3ro
stackoverflow: x3ro
coderwall: coderwall.com/x3ro
twitter: @x3rames