Simple C Android Apps

July 25, 2025
| GitHub

Anyway, do you guys have an Android charger?

I told you, we don’t support that.

The rawdraw android project shows that it is possible to build fully functional Android apps using only C and simple command-line tools. In this article, I’ll break down the basic steps required to package an OpenGL ES application from scratch, and install it on my Pixel 7a phone.

Build environment

I will assume that we are working in a shell on a Unix/POSIX-like operating system (e.g. bash on Linux). The Android command-line tools are the same on Windows though, so with a bit of work translating POSIX commands, it is possible to follow along from PowerShell (I’ve done it myself).

OpenJDK

We will need OpenJDK for java and keytool. Preferably, we can install OpenJDK through our system package manager. Otherwise, if a distribution is available for our CPU architecture and OS, then we can pull a local copy as shown below. If not, there are instructions to build the JDK from source.

$ curl <release-url-for-your-arch-os> -o openjdk24.tar.gz
$ tar -xvf openjdk24.tar.gz
$ export JAVA_HOME=/path/to/jdk-24.0.2
$ export PATH="/path/to/jdk-24.0.2/bin:$PATH"

We have to add the jdk-24.0.2/bin directory to our PATH because the Android SDK apksigner script expects a java command.

Android tools

The Android specific tools we’ll be using are aapt (or aapt2), zipalign, and apksigner from the SDK build-tools, adb from the SDK platform-tools, and the clang toolchain from the NDK.

The official way

If we are on a standard GNU Linux distribution, MacOS, or Windows, we can download and unpack the official Android Studio “command-line tools only” distribution. The sdkmanager can then be used to install the SDK build-tools, platforms, and platform-tools, as well as the NDK.

$ curl <release-url-for-your-arch-os> -o cmdline-tools.zip
$ mkdir android_sdk
$ unzip cmdline-tools.zip -d ./android_sdk/
$ ./android_sdk/cmdline-tools/bin/sdkmanager --sdk-root ./android_sdk \
    "build-tools;35.0.1" \
    "platforms;android-35" \
    "platform-tools" \
    "ndk;29.0.13599879"

All the files we need should now be installed.

./android_sdk/build-tools/35.0.1/aapt
./android_sdk/build-tools/35.0.1/apksigner
./android_sdk/build-tools/35.0.1/zipalign
./android_sdk/platform-tools/adb
./android_sdk/platforms/android-35/android.jar
./android_sdk/ndk/29.0.13599879/toolchains/llvm/prebuilt/<os>-<arch>/bin/clang

For brevity, and to accommodate different installations, from now on we will omit the full path and refer to each of the above by file name. Optionally, we can add each relevant directory our PATH.

A static alternative for Linux

If we are on a non-standard Linux distribution, e.g. the musl-based distro I primarily use, then the official binary distribution may not work. The sdk-custom and ndk-custom GitHub pages provide static Linux binary releases for the Android SDK build-tools, platform-tools, and the Android NDK.

We’ll still need the android.jar file from the Android SDK platform, but that can be downloaded from the official repo or the third party Sable collection.

Building from source

If all else fails, everything we need from the SDK and NDK is open source, split across several repos. I have not tried to build the tools myself yet, but the sdk-custom and ndk-custom projects mentioned above look like a useful resource.

Packaging an Android APK

An Android .apk file is just a specially structured .zip archive. The core ingredients are an AndroidManifest.xml file, a resources.arsc file, and (optionally) res/, asset/, and lib/ directories to store resources, assets, and native libraries, respectively. Note that the AndroidManifest.xml in the .apk is a compiled binary, not an XML text file.

The general build strategy will be as follows.

  1. Create a template temp.apk file with aapt (or aapt2) using an AndroidManifest.xml text file, an icon.png mipmap resource, and the android.jar file.
  2. Extract temp.apk into build/ and add a build/lib/aarch64 directory.
  3. Compile a build/lib/aarch64/libseglapp.so shared library with clang.
  4. Re-package the build/ directory into temp2.apk with zip.
  5. Run the zipalign tool on temp2.apk to create seglapp.apk.
  6. Sign seglapp.apk with apksigner.

To package the initial temp.apk, we will first create a template/ directory that is structured as follows.

 - template/
     - AndroidManifest.xml
     - res/
         - mipmap/
             - icon.png

Our template/AndroidManifest.xml is shown below. The name property in the <activity> tag specifies that our app will use the built-in NativeActivity from the Android SDK. This special activity will relay Java events to our C code through the Java Native Interface (JNI). Also, note the minimum and target SDK versions (22 and 35), the package name (org.seglorg), and the app name (seglapp). In a real project, these would need to change; see the app manifest documentation.

<!-- template/AndroidManifest.xml -->

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
        xmlns:android="http://schemas.android.com/apk/res/android"
        package="org.seglorg">
	<uses-sdk android:minSdkVersion="22"
              android:targetSdkVersion="35" />
    <uses-permission android:name="android.permission.SET_RELEASE_APP"/>
    <application android:debuggable="true" android:hasCode="false"
            android:label="seglapp"
            tools:replace="android:icon,android:theme,android:allowBackup,label"
            android:icon="@mipmap/icon">
        <activity android:configChanges="keyboardHidden|orientation"
                android:label="seglapp"
                android:name="android.app.NativeActivity"
                android:exported="true">
            <meta-data android:name="android.app.lib_name"
                    android:value="seglapp"/>
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

The template/res/mipmap/icon.png can be any square .png file, e.g. the 2x2 square from rawdraw android. Using aapt we may now package our initial .apk.

$ aapt package -v -f  -F ./temp.apk -I android.jar \
    -M ./template/AndroidManifest.xml \
    -S ./template/res --target-sdk-version 35

To use the newer aapt2 binary (aapt has been deprecated), the process is similar, but with some extra steps. The aapt package command has been split into aapt2 compile and aapt2 link.

$ mkdir ./template/compiled
$ aapt2 compile -v ./template/res/mimpmap/icon.png \
    -o ./template/compiled
$ aapt2 link -v -o ./temp.apk -I android.jar \
    --manifest ./template/AndroidManifest.xml \
    --target-sdk-version 35 \
    ./template/compiled/mipmap_icon.png.flat

We can now unpack temp.apk into a new build/ directory where we will insert our C shared library.

$ mkdir ./build
$ unzip -o temp.apk -d ./build
Archive:  ./temp.apk
  inflating: ./build/AndroidManifest.xml  
 extracting: ./build/res/mipmap/icon.png  
 extracting: ./build/resources.arsc

Building a native Android library with C

The C bindings for the Java NativeActivity that we specified in our AndroidManifest.xml are provided by android_native_app_glue.h and android_native_app_glue.c in the sources/android/ subdirectory of the NDK. The glue exports the ANativeActivity_onCreate function that will be called through the JNI on startup. This sets up callbacks to allow our C code to respond to events, then uses pthread_create to start a thread that calls android_main. The android_main function will serve as our C entry point.

We’ll copy both glue files into a src/ directory, then create a new file src/main.c. To begin, we will simply set up event callbacks and run a loop that polls for events.

/* src/main.c */

#include <stddef.h>
#include <stdint.h>

#include <android/log.h>

#include "android_native_app_glue.h"

#define SEGL_LOG_ID "SEGL"

static void handle_cmd(struct android_app *app, int32_t cmd) {
    switch (cmd) {
        case APP_CMD_INIT_WINDOW:
            __android_log_print(
                ANDROID_LOG_INFO,
                SEGL_LOG_ID,
                "APP_CMD_INIT_WINDOW"
            );

            /* app window opened or resumed, create EGL context */

            break;
        case APP_CMD_TERM_WINDOW:
            __android_log_print(
                ANDROID_LOG_INFO,
                SEGL_LOG_ID,
                "APP_CMD_TERM_WINDOW"
            );

            /* app window closed or paused, release EGL context */

            break;
        case APP_CMD_DESTROY:
            __android_log_print(
                ANDROID_LOG_INFO,
                SEGL_LOG_ID,
                "APP_CMD_DESTROY"
            );

            /* app has been closed */

            break;
        default:
            break;
    }
}

static int32_t handle_input(struct android_app *app, AInputEvent *event) {
    /* handle touch and keyboard input events */

    return 0;
}

void android_main(struct android_app *app) {
    __android_log_print(ANDROID_LOG_INFO, SEGL_LOG_ID, "android_main");

    app->onAppCmd = handle_cmd;
    app->onInputEvent = handle_input;

    /* app started, perform initial setup */

    for (;;) {
        int events;
        struct android_poll_source *source;

        for (;;) {
            int res = ALooper_pollOnce(0, 0, &events, (void **)&source);
            if (res < 0) {
                break;
            }
            if (source != NULL) {
                source->process(app, source);
            }
        }

        /* update state and draw to screen */
    }
}

We can now build the library and re-package the .apk. Recall that clang will always refer to the binary distributed with the Android NDK.

$ mkdir -p ./build/lib/arm64-v8a
$ clang --target=aarch64-linux-android22 \
    -Wall -Wextra -Wno-unused-parameter \
    -shared -fPIC -o ./build/lib/arm64-v8a/libseglapp.so \
    ./src/main.c ./src/android_native_app_glue.c \
    -landroid -llog
$ cd ./build
$ zip -D4r ../temp2.apk .
$ zip -D0r ../temp2.apk ./resources.arsc ./AndroidManifest.xml
$ cd ..
$ zipalign -v 4 ./temp2.apk ./seglapp.apk
$ keytool -genkey -v -keystore mykey.keystore -alias mykey \
    -keyalg RSA -keysize 2048 -validity 10000 \
    -storepass mypassword -keypass mypassword \
    -dname "CN=example.com, OU=ID, O=Example, L=Callar, S=Morvern, C=GB"
$ apksigner sign --key-pass pass:mypassword \
    --ks-pass pass:mypassword \
    --ks mykey.keystore \
    ./seglapp.apk

The number at the end of the clang target is the minimum SDK version. Above we defined a minimum version of 22 in our template/AndroidManifest.xml.

Finally, we can install seglapp.apk on an Android device. The device in question must be connected over USB and have USB debugging enabled.

$ adb install seglapp.apk

If all went well, we should have an app named seglapp on our device. It may be necessary to run adb under sudo, or to su and run adb as root, depending on system and user permissions.

When we open the app, we should be met with a black screen: we haven’t drawn anything yet. What we have done, however, is printed some log messages. The logs for everything happening on a connected device may be viewed with adb shell logcat. That command will usually produce a deluge of information which, while potentially helpful if piped to a file, is of little use as command-line output. To view only logs produced by our code, we can use filters.

$ adb shell logcat SEGL:I *:S

The SEGL:I filter tells logcat to print messages tagged with SEGL that have ANDROID_LOG_INFO priority or higher, while the *:S filter silences all other log messages. Recall that our __android_log_print calls used the tag "SEGL".

Try running the above logcat command, then opening seglapp on your Android device. Look at what log messages are printed when you open, minimize, resume, and close the application. Below is the logcat output I see when I tap on the app, swipe up to minimize it, open the app again, then manually terminate it.

$ adb shell logcat SEGL:I *:S
07-24 14:37:19.206 25465 25484 I SEGL    : android_main
07-24 14:37:19.259 25465 25484 I SEGL    : APP_CMD_INIT_WINDOW
07-24 14:37:23.308 25465 25484 I SEGL    : APP_CMD_TERM_WINDOW
07-24 14:37:25.070 25465 25484 I SEGL    : APP_CMD_INIT_WINDOW
07-24 14:37:28.058 25465 25484 I SEGL    : APP_CMD_TERM_WINDOW
07-24 14:37:28.085 25465 25484 I SEGL    : APP_CMD_DESTROY

Note: The android_native_app_glue.c file produces a bunch of useful log messages under the tag "threaded_app". We can add the filter threaded_app:V to take a look.

Drawing graphics with EGL and OpenGL

There are two basic directions our build could take to access the EGL and OpenGL ES 2.0 C APIs: link against the libEGL.so and libGLESv2.so shared libraries with -lEGL and -lGLESv2, or use dlopen to load them at runtime. Here we’ll use the former option.

/* src/main.c */

#include <math.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <time.h>

#include <android/log.h>
#include <android/native_window.h>

#include <EGL/egl.h>
#include <GLES2/gl2.h>

#include "android_native_app_glue.h"

#define SEGL_LOG_ID "SEGL"
#define NSEC_PER_SEC_F (1e9f)
#define PERIOD (int64_t)(2.0f * M_PI * NSEC_PER_SEC_F)

struct egl_ctx {
    EGLDisplay display;
    EGLConfig config;
    EGLContext context;
    EGLSurface surface;
};

static void egl_ctx_load(struct egl_ctx *ctx, struct android_app *app) {
    if (ctx->display != EGL_NO_DISPLAY) {
        return;
    }

    ctx->display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
    if (ctx->display == EGL_NO_DISPLAY) {
        __android_log_print(
            ANDROID_LOG_ERROR,
            SEGL_LOG_ID,
            "failed to find EGL display"
        );
        exit(1);
    }

    EGLint major;
    EGLint minor;
    if (!eglInitialize(ctx->display, &major, &minor)) {
        __android_log_print(
            ANDROID_LOG_ERROR,
            SEGL_LOG_ID,
            "failed to initialize EGL display"
        );
        exit(1);
    }

    /* NOTE: we pick the first 24bit RGB ES2 config */
    const EGLint attribs[] = {
        EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
        EGL_CONFORMANT, EGL_OPENGL_ES2_BIT,
        EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
        EGL_COLOR_BUFFER_TYPE, EGL_RGB_BUFFER,
        EGL_RED_SIZE, 8,
        EGL_GREEN_SIZE, 8,
        EGL_BLUE_SIZE, 8,
        EGL_NONE,
    };
    EGLint nconfigs;
    if (
        !eglChooseConfig(
            ctx->display,
            attribs,
            &ctx->config,
            1,
            &nconfigs
        ) ||
        nconfigs == 0
    ) {
        __android_log_print(
            ANDROID_LOG_ERROR,
            SEGL_LOG_ID,
            "failed to find EGL config"
        );
        exit(1);
    }

    const EGLint context_attribs[] = {
        EGL_CONTEXT_MAJOR_VERSION, 2,
        EGL_CONTEXT_MINOR_VERSION, 0,
        EGL_NONE,
    };
    ctx->context = eglCreateContext(
        ctx->display,
        ctx->config,
        EGL_NO_CONTEXT,
        context_attribs
    );
    if (ctx->context == EGL_NO_CONTEXT) {
        __android_log_print(
            ANDROID_LOG_ERROR,
            SEGL_LOG_ID,
            "failed to create EGL context"
        );
        exit(1);
    }
    ctx->surface = eglCreateWindowSurface(
        ctx->display,
        ctx->config,
        app->window,
        NULL
    );
    if (ctx->surface == EGL_NO_SURFACE) {
        __android_log_print(
            ANDROID_LOG_ERROR,
            SEGL_LOG_ID,
            "failed to create EGL surface"
        );
        exit(1);
    }

    if (
        !eglMakeCurrent(
            ctx->display,
            ctx->surface,
            ctx->surface,
            ctx->context
        )
    ) {
        __android_log_print(
            ANDROID_LOG_ERROR,
            SEGL_LOG_ID,
            "failed to set EGL surface and context"
        );
        exit(1);
    }
}

static void egl_ctx_unload(struct egl_ctx *ctx) {
    if (ctx->display == EGL_NO_DISPLAY) {
        return;
    }

    eglMakeCurrent(
        ctx->display,
        EGL_NO_SURFACE,
        EGL_NO_SURFACE,
        EGL_NO_CONTEXT
    );

    if (ctx->context != EGL_NO_CONTEXT) {
        eglDestroyContext(ctx->display, ctx->context);
    }

    if (ctx->surface != EGL_NO_SURFACE) {
        eglDestroySurface(ctx->display, ctx->surface);
    }

    eglTerminate(ctx->display);

    ctx->display = EGL_NO_DISPLAY;
    ctx->context = EGL_NO_CONTEXT;
    ctx->surface = EGL_NO_SURFACE;
}

static struct egl_ctx egl_ctx = {
    .display = EGL_NO_DISPLAY,
    .context = EGL_NO_CONTEXT,
    .surface = EGL_NO_SURFACE,
};

static void handle_cmd(struct android_app *app, int32_t cmd) {
    switch (cmd) {
        case APP_CMD_INIT_WINDOW:
            __android_log_print(
                ANDROID_LOG_INFO,
                SEGL_LOG_ID,
                "APP_CMD_INIT_WINDOW"
            );

            egl_ctx_load(&egl_ctx, app);

            break;
        case APP_CMD_TERM_WINDOW:
            __android_log_print(
                ANDROID_LOG_INFO,
                SEGL_LOG_ID,
                "APP_CMD_TERM_WINDOW"
            );

            egl_ctx_unload(&egl_ctx);

            break;
        case APP_CMD_DESTROY:
            __android_log_print(
                ANDROID_LOG_INFO,
                SEGL_LOG_ID,
                "APP_CMD_DESTROY"
            );

            /* app has been closed */

            break;
        default:
            break;
    }
}

static int32_t handle_input(struct android_app *app, AInputEvent *event) {
    /* handle touch and keyboard input */

    return 0;
}

static int64_t time_since_ns(struct timespec end, struct timespec start) {
    int64_t seconds = (int64_t)end.tv_sec - (int64_t)start.tv_sec;
    int64_t sec_diff = seconds * 1000L * 1000L * 1000L;
    int64_t nsec_diff = (int64_t)end.tv_nsec - (int64_t)start.tv_nsec;

    return sec_diff + nsec_diff;
}

void android_main(struct android_app *app) {
    __android_log_print(ANDROID_LOG_INFO, SEGL_LOG_ID, "android_main");

    app->onAppCmd = handle_cmd;
    app->onInputEvent = handle_input;

    int64_t elapsed = 0;
    struct timespec last;
    clock_gettime(CLOCK_MONOTONIC, &last);

    for (;;) {
        struct timespec now;
        clock_gettime(CLOCK_MONOTONIC, &now);
        elapsed += time_since_ns(now, last);
        last = now;

        while (elapsed >= PERIOD) {
            elapsed -= PERIOD;
        }

        int events;
        struct android_poll_source *source;
        for (;;) {
            int res = ALooper_pollOnce(0, 0, &events, (void **)&source);
            if (res < 0) {
                break;
            }
            if (source != NULL) {
                source->process(app, source);
            }
        }

        if (egl_ctx.display == EGL_NO_DISPLAY) {
            const struct timespec duration = {
                .tv_nsec = 32L * 1000L * 1000L,
            };
            nanosleep(&duration, NULL);
        } else {
            int width = ANativeWindow_getWidth(app->window);
            int height = ANativeWindow_getHeight(app->window);

            float t = (float)elapsed / NSEC_PER_SEC_F;

            glViewport(0, 0, width, height);
            glClearColor(
                0.5f * (1.0f + cosf(t)),
                0.5f * (1.0f + sinf(t)),
                0.5f * (1.0f - cosf(t)),
                1.0f
            );
            glClear(GL_COLOR_BUFFER_BIT);

            eglSwapBuffers(egl_ctx.display, egl_ctx.surface);
        }
    }
}

The loop in android_main clears the screen with a varying color. The handle_cmd function creates an EGL surface and OpenGL context on an APP_CMD_INIT_WINDOW event, and destroys them on an APP_CMD_TERM_WINDOW event.

Note: The INIT_WINDOW/TERM_WINDOW events are sent when the app is opened/closed, but also when the app is paused/resumed. When TERM_WINDOW is handled, it is important to release all OpenGL context state (shaders, textures, vertex buffers, etc.) before the call to egl_ctx_unload. Then, when INIT_WINDOW is handled, the OpenGL state should be re-constructed from the application state.

Going through the same process as before, we can compile our shared library, re-package seglapp.apk, and install it on our device.

$ adb uninstall org.seglorg.seglapp
$ rm temp2.apk seglapp.apk
$ clang --target=aarch64-linux-android22 \
    -Wall -Wextra -Wno-unused-parameter \
    -shared -fPIC -o ./build/lib/arm64-v8a/libseglapp.so \
    ./src/main.c ./src/android_native_app_glue.c \
    -lm -landroid -llog -lEGL -lGLESv2
$ cd ./build
$ zip -D4r ../temp2.apk .
$ zip -D0r ../temp2.apk ./resources.arsc ./AndroidManifest.xml
$ cd ..
$ zipalign -v 4 ./temp2.apk ./seglapp.apk
$ apksigner sign --key-pass pass:mypassword \
    --ks-pass pass:mypassword \
    --ks mykey.keystore \
    ./seglapp.apk
$ adb install seglapp.apk

Running seglapp should now display a varying solid color background which can be seamlessly suspended and resumed.

Targeting multiple architectures

The seglapp.apk we packaged above will only work on 64 bit ARM devices. Fortunately, the process to package a .apk for multiple architectures is very simple: add a separate subdirectory in build/lib/ for each architecture! Below we add support for 32 bit ARM, x86, and x86_64.

$ mkdir -p ./build/lib/armeabi-v7a
$ clang --target=armv7a-linux-androideabi22 \
    -Wall -Wextra -Wno-unused-parameter \
    -shared -fPIC -o ./build/lib/armeabi-v7a/libseglapp.so \
    ./src/main.c ./src/android_native_app_glue.c \
    -lm -landroid -llog -lEGL -lGLESv2
$ mkdir -p ./build/lib/x86
$ clang --target=i686-linux-android22 \
    -Wall -Wextra -Wno-unused-parameter \
    -shared -fPIC -o ./build/lib/x86/libseglapp.so \
    ./src/main.c ./src/android_native_app_glue.c \
    -lm -landroid -llog -lEGL -lGLESv2
$ mkdir -p ./build/lib/x86_64
$ clang --target=x86_64-linux-android22 \
    -Wall -Wextra -Wno-unused-parameter \
    -shared -fPIC -o ./build/lib/x86_64/libseglapp.so \
    ./src/main.c ./src/android_native_app_glue.c \
    -lm -landroid -llog -lEGL -lGLESv2
# then proceed with re-packaging as usual

The devices I own are all aarch64, but I’ve tested other architectures with emulators.

Next steps

Our OpenGL example was very disappointing, we didn’t even draw a triangle! Adding a basic shader pipeline and pushing some vertices to the GPU should be priority number one.

We also haven’t handled any input in our handle_input function, take a look at the Android NDK input documentation. We used the ANativeWindow API to get the width and height of the application window, but there is more functionality available. There are also a few more APP_CMD events that may be useful, so it might be good to read through the native app glue docs.

A completed version of the example in this article is available on GitHub.

DumbCycle