Android

By the end of this guide your own C program runs on an Android phone as an installable app.

Android needs one thing explained before anything else: it never starts a native executable. That single fact shapes the whole build, so the first section is about it.

Understand why the result is a library, not a program

An APK names a shared library in its manifest. Android loads that library and calls ANativeActivity_onCreate inside it - that symbol is the entry point, not main. Natiny provides it, and it starts the thread that calls your main.

So your program and the engine have to end up in one .so file:

TEXT
main.c + libnatiny_android_arm64.a  →  libhello.so  →  hello.apk

You write ordinary C with a normal main. Nothing about your source is Android-specific.

Install the compiler and the packaging tools

You need the NDK for the compiler, the SDK build-tools and one platform for packaging, and a JDK 17 or newer for the signer:

Shell
sdkmanager "ndk;28.2.13676358" "build-tools;35.0.0" "platforms;android-35"
sdkmanager --licenses
export ANDROID_HOME="$HOME/Android/Sdk"

Another NDK works as well; only the version in the paths below changes.

Connect a phone with USB debugging on, or start an emulator, and check it is there:

Shell
adb devices

Unzip the SDK and put your program beside it

Download the SDK bundle for android-arm64:

TEXT
my_game/
├── include/
│   └── natiny.h
├── lib/
│   └── libnatiny_android_arm64.a
├── android/
│   ├── pack.py
│   ├── AndroidManifest.xml.in
│   └── res/
└── main.c

There is no shared library of Natiny's in the bundle, and that is deliberate: the .so Android loads is the one you are about to create from the engine and your program together.

Write a program you will recognise on screen

The window always covers the screen, so the size you ask for is ignored - use natiny_window_get_width and natiny_window_get_height for the real one. A green square on a dark background is enough to prove the whole chain worked.

C
#include "natiny.h"
#include <stddef.h>

static NatinyWindow window;

static void frame(float dt, void* userdata)
{
    (void)dt;
    (void)userdata;

    natiny_window_bind(window);
    natiny_render_clear(0.07f, 0.09f, 0.13f, 1.0f);
    natiny_render_set_color(0.66f, 0.76f, 0.22f, 1.0f);
    natiny_render_rectangle(360.0f, 260.0f, 80.0f, 80.0f);
    natiny_window_unbind();
}

int main(void)
{
    if (!natiny_backend_init(NATINY_BACKEND_AUTO)) {
        return 1;
    }

    window = natiny_window_create("Hello, Natiny", 800, 600);
    if (!window) {
        natiny_backend_shutdown();
        return 1;
    }

    natiny_backend_loop(frame, NULL);
    natiny_backend_shutdown();
    return 0;
}

NATINY_BACKEND_AUTO selects Vulkan, the only backend Android has.

The NDK's compiler is named after its target: the ABI and the minimum API level are both in the file name, so no flags are needed for either.

Shell
NDK_BIN="$ANDROID_HOME/ndk/28.2.13676358/toolchains/llvm/prebuilt/linux-x86_64/bin"

Use darwin-x86_64 on a Mac and windows-x86_64 on Windows.

Shell
"$NDK_BIN/aarch64-linux-android24-clang" -O2 -fPIC \
    -Iinclude -c main.c -o main.o

"$NDK_BIN/aarch64-linux-android24-clang" -shared main.o \
    lib/libnatiny_android_arm64.a -o libhello.so \
    -u ANativeActivity_onCreate -landroid -llog -ldl -lm

Two of those options are not obvious:

  • -shared makes the .so that Android loads, rather than an executable that nothing would start.
  • -u ANativeActivity_onCreate keeps the entry point. Nothing on the link line refers to it - Android finds it by name at run time - so without -u the linker drops it, and the app installs and then fails at startup.

Check that it survived, with the NDK's own nm - a host nm cannot always read an aarch64 library:

Shell
"$NDK_BIN/llvm-nm" -D libhello.so | grep ANativeActivity_onCreate

A line with T ANativeActivity_onCreate means the symbol is exported. No output means -u is missing.

Pack the library into an APK

pack.py builds the manifest, adds the resources, signs and aligns. Create a folder for your data even if the program reads nothing yet:

Shell
mkdir -p assets

python android/pack.py --project ./assets --lib libhello.so \
    --abi arm64-v8a --package com.example.hello --name "Hello" \
    --output hello.apk

adb install -r hello.apk

--abi has to match the compiler you used: arm64-v8a goes with aarch64-linux-android24-clang. For an x86_64 emulator, take the android-amd64 SDK, compile with x86_64-linux-android24-clang, and pass --abi x86_64.

Without --keystore the packer makes a debug key at ~/.natiny/debug.keystore on first use and reuses it, which is what keeps adb install -r working between builds. Use a real key for anything you publish, and keep it: Google Play ties an application id to the key that first signed it.

Run it and read what it prints

Open Hello from the launcher. A dark screen with a green square means Android loaded your library, called Natiny's entry point and reached your frame callback.

When it closes instead, the log says why:

Shell
adb logcat -s natiny

Read and write files from the app

Whatever you pass to --project becomes assets/data/ inside the APK, and that is what the resource API reads:

TEXT
assets/
└── player.png
C
NatinyResource player = natiny_resource_load("data/player.png");

That is the same string a desktop build uses. APK assets are read-only and have no filesystem path, so they can only be read through the resource API. A relative path given to natiny_resource_save goes to the app's private directory, and natiny_resource_load looks there too.

Typical problems

  • The app installs and dies at startup with no log - -u ANativeActivity_onCreate is missing from the link line.
  • INSTALL_FAILED_NO_MATCHING_ABIS - --abi does not match the device, or does not match the compiler you used.
  • cannot find the Android SDK - set ANDROID_HOME, or pass --sdk.
  • INSTALL_FAILED_UPDATE_INCOMPATIBLE - the same application id is installed under a different key. Uninstall it or reuse the key.
  • A device older than Android 7.0 cannot run it. API 24 is the release where Vulkan is required of a device, and Vulkan is the backend.

What you have now

libhello.so holds your program, the engine and the entry point Android looks for, and hello.apk is that library with your data, a manifest and a signature around it. The C source and the resource paths are the ones a desktop build uses.

Two things about the platform are worth knowing next. A touch arrives as the left mouse button, plus a callback that reports every finger. And when Android sends your app to the background it destroys the window you were drawing to: Natiny reports that as window iconification, and drawing after it is a crash. Stop inside that callback.