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.
- Create a template
temp.apk
file withaapt
(oraapt2
) using anAndroidManifest.xml
text file, anicon.png
mipmap resource, and theandroid.jar
file. - Extract
temp.apk
intobuild/
and add abuild/lib/aarch64
directory. - Compile a
build/lib/aarch64/libseglapp.so
shared library withclang
. - Re-package the
build/
directory intotemp2.apk
withzip
. - Run the
zipalign
tool ontemp2.apk
to createseglapp.apk
. - Sign
seglapp.apk
withapksigner
.
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.