Anyway, do you guys have an Android charger?
I told you, we don’t support that.
Recently, I set out find a simple way to package my C and OpenGL desktop apps into .apk
files that would run on my Pixel 7a phone. I am a dependency minimalist, so I wanted to avoid the Android Studio IDE and its associated build systems if at all possible.
The wonderful rawdraw Android project is a C app framework that only depends on a few tools from the Android SDK. While I wasn’t interested in using the framework itself, the Makefile
was exactly the command-line build process proof of concept I was after.
In this article we’ll walk through the steps required to acquire the SDK build tools, write a minimal OpenGL app in C, and package a .apk
file to install on an Android device.
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/libmain.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 org name (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:android="http://schemas.android.com/apk/res/android"
package="org.seglorg.seglapp">
<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"
android:icon="@mipmap/icon">
<activity android:configChanges="orientation"
android:name="android.app.NativeActivity"
android:exported="true">
<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. 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 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/libmain.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 (;;) {
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);
}
}
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
elapsed += time_since_ns(now, last);
last = now;
while (elapsed >= PERIOD) {
elapsed -= PERIOD;
}
if (egl_ctx.display == EGL_NO_DISPLAY) {
const struct timespec duration = {
.tv_nsec = 16L * 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/libmain.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 suspended and resumed.
Persistent files and the Android activity lifecycle
The APP_CMD_RESUME
/APP_CMD_PAUSE
events are sent as soon as an app enters/leaves the foreground. Once an app has left the foreground, it becomes low priority and the Android scheduler may terminate its process without notice.
Currently, our app destroys it’s EGL surface and OpenGL context when the TERM_WINDOW
event is sent, but otherwise continues running as usual in the background. In this section we will handle the PAUSE
/RESUME
events to save/load the state to/from persistent storage, and no longer run updates in the background.
Each app receives a pair of directories to use for private persistent files. The NDK provides a path to each directory through the internalDataPath
and externalDataPath
members of app->activity
. Internal storage is always available, but space may be limited depending on the device. External storage is not always available, as it may be located on removable storage such as an SD card.
/* src/main.c */
/* ... */
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
/* ... */
#define STATE_FILE "state"
#define TMP_FILE STATE_FILE ".tmp"
struct state {
int64_t elapsed;
};
static void load_state(struct state *state, struct android_app *app) {
int dirfd = open(app->activity->internalDataPath, O_RDONLY);
if (dirfd < 0) {
__android_log_print(
ANDROID_LOG_ERROR,
SEGL_ANDROID_LOG_ID,
"failed to open internalDataPath"
);
return;
}
int statefd = openat(dirfd, STATE_FILE, O_RDONLY, 0);
if (statefd >= 0) {
struct state last_state;
char *bytes = (char *)&last_state;
char *bytes_end = bytes + sizeof(last_state);
while (bytes != bytes_end) {
ssize_t len = read(statefd, bytes, (size_t)(bytes_end - bytes));
if (len < 0) {
if (errno == EINTR) {
continue;
} else {
break;
}
}
bytes += len;
}
if (bytes == bytes_end) {
*state = last_state;
}
close(statefd);
}
close(dirfd);
}
enum save_error {
SAVE_ERROR_NONE = 0,
SAVE_ERROR_DIR,
SAVE_ERROR_TMP,
SAVE_ERROR_WRITE,
SAVE_ERROR_SWAP,
};
static void save_state(struct state *state, struct android_app *app) {
enum save_error error = SAVE_ERROR_NONE;
int dirfd = open(app->activity->internalDataPath, O_RDONLY);
if (dirfd < 0) {
error = SAVE_ERROR_DIR;
}
int tmpfd;
if (!error) {
/* create temp file */
tmpfd = openat(
dirfd,
TMP_FILE,
O_WRONLY | O_CREAT,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH
);
if (tmpfd < 0) {
error = SAVE_ERROR_TMP;
}
}
if (!error) {
/* save state to temp file */
char *bytes = (char *)state;
char *bytes_end = bytes + sizeof(*state);
while (bytes != bytes_end) {
ssize_t len = write(tmpfd, bytes, (size_t)(bytes_end - bytes));
if (len < 0) {
if (errno == EINTR) {
continue;
} else {
error = SAVE_ERROR_WRITE;
break;
}
}
bytes += len;
}
close(tmpfd);
}
if (!error) {
/* "atomically" replace state file with temp file */
if (renameat(dirfd, TMP_FILE, dirfd, STATE_FILE)) {
error = SAVE_ERROR_SWAP;
}
}
close(dirfd);
if (error) {
__android_log_print(
ANDROID_LOG_ERROR,
SEGL_ANDROID_LOG_ID,
"failed to save state: %d",
(int)error
);
}
}
static struct state state;
static int running;
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_RESUME: {
__android_log_print(
ANDROID_LOG_INFO,
SEGL_ANDROID_LOG_ID,
"APP_CMD_RESUME"
);
running = 1;
load_state(&state, app);
break;
}
case APP_CMD_PAUSE: {
__android_log_print(
ANDROID_LOG_INFO,
SEGL_ANDROID_LOG_ID,
"APP_CMD_PAUSE"
);
running = 0;
save_state(&state, app);
break;
}
default:
break;
}
}
/* ... */
void android_main(struct android_app *app) {
/* ... */
/* main app loop */
struct timespec last;
clock_gettime(CLOCK_MONOTONIC, &last);
for (;;) {
int events;
struct android_poll_source *source;
while (!running) {
/* while paused, freeze clock and wait for next event */
ALooper_pollOnce(-1, 0, &events, (void **)&source);
if (source != NULL) {
source->process(app, source);
}
clock_gettime(CLOCK_MONOTONIC, &last);
}
while (ALooper_pollOnce(0, 0, &events, (void **)&source) >= 0) {
if (source != NULL) {
source->process(app, source);
}
}
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
state.elapsed += time_since(now, last);
last = now;
while (state.elapsed >= PERIOD) {
state.elapsed -= PERIOD;
}
if (egl_ctx.display == EGL_NO_DISPLAY) {
/* if EGL display not loaded, wait 16ms (1 frame @ 60FPS) */
const struct timespec duration = { .tv_nsec = 16L * 1000L * 1000L };
nanosleep(&duration, NULL);
} else {
int width = ANativeWindow_getWidth(app->window);
int height = ANativeWindow_getHeight(app->window);
float t = (float)state.elapsed / 1e9f;
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);
}
}
}
Now the current color state should be saved when the the app is hidden or closed, and loaded when the app becomes visible or is re-opened.
Note: Running in the background may sometimes be preferrable to this new pause and resume behavior. In such cases the app could instead periodically save its state to disk, and load the last state at the start of android_main
.
Fullscreen mode and the Java API
You may have noticed that our OpenGL colored background is drawn behind the default Android UI decorations. The NDK does not provide a C API to hide these decorations, or even determine the size and position of each element. In this section we’ll learn how to use the Java Native Interface (JNI) to access the Android Java API and put our app in fullscreen “immersive” mode.
Calling Java code from C
Every call across the JNI boundary must pass a pointer to the JNI environment associated with the current thread. When a Java thread calls a native function, the associated environment is automatically provided by the Java Virtual Machine (JVM). Our android_main
function, however, is running on a pthread
created without the JVM’s knowledge. Therefore we must first “attach” our thread to the JVM that our app is running in.
/* src/main.c */
/* ... */
#include <jni.h>
/* ... */
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;
/* NOTE: JavaVM and JNIEnv are pointer types */
JavaVM jvm = *app->activity->vm;
JNIEnv *envptr = NULL;
jvm->AttachCurrentThread(&jvm, &envptr, NULL);
JNIEnv env = *envptr;
/* JNI calls to enable fullscreen */
jvm->DetachCurrentThread(&jvm);
/* main loop */
/* ... */
}
Hiding system bars
To hide the system bars and display in fullscreen, a Java app would execute the code shown below.
Window window = getWindow();
View decorView = window.getDecorView();
decorView.setSystemUiVisibility(
// make system bars transparent when shown
View.SYSTEM_UI_FLAG_LOW_PROFILE |
// hide system bars
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
// make content fullscreen
View.SYSTEM_UI_FLAG_FULLSCREEN |
// re-hide system bars after timeout
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
// don't resize layout when showing/hiding system bars
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
window.addFlags(
// full screen window
WindowManager.LayoutParams.FLAG_FULLSCREEN |
// don't go to sleep while our app is open
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON |
// request/maintain hardware acceleration
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
);
Note: The method above is technically deprecated, but it should still work well into the future. See here for the modern API.
The C code to accomplish the same thing through the JNI is simple, but verbose. We first need to look up each Java class with FindClass
. Then we’ll look up each method and field with GetMethodID
, GetStaticMethodID
, GetFieldID
, and GetStaticFieldID
. Finally, we’ll call methods and access fields with Call<type>Method
, CallStatic<type>Method
, Get<type>Field
, and GetStatic<type>Field
.
/* src/main.c */
/* ... */
void android_main(struct android_app *app) {
/* ... */
/* NOTE: JavaVM and JNIEnv are pointer types */
JavaVM jvm = *app->activity->vm;
JNIEnv *envptr = NULL;
jvm->AttachCurrentThread(app->activity->vm, &envptr, NULL);
JNIEnv env = *envptr;
jclass av_NativeActivity = env->FindClass(
envptr,
"android/app/NativeActivity"
);
jclass av_Window = env->FindClass(envptr, "android/view/Window");
jclass av_View = env->FindClass(envptr, "android/view/View");
jclass avwm_LayoutParams = env->FindClass(
envptr,
"android/view/WindowManager$LayoutParams"
);
jmethodID av_NativeActivity_getWindow = env->GetMethodID(
envptr,
av_NativeActivity,
"getWindow",
"()Landroid/view/Window;"
);
jmethodID av_Window_getDecorView = env->GetMethodID(
envptr,
av_Window,
"getDecorView",
"()Landroid/view/View;"
);
jmethodID av_Window_addFlags = env->GetMethodID(
envptr,
av_Window,
"addFlags",
"(I)V"
);
jmethodID av_View_setSystemUiVisibility = env->GetMethodID(
envptr,
av_View,
"setSystemUiVisibility",
"(I)V"
);
jfieldID av_View_SYSTEM_UI_FLAG_LOW_PROFILE =
env->GetStaticFieldID(
envptr,
av_View,
"SYSTEM_UI_FLAG_LOW_PROFILE",
"I"
);
jfieldID av_View_SYSTEM_UI_FLAG_HIDE_NAVIGATION =
env->GetStaticFieldID(
envptr,
av_View,
"SYSTEM_UI_FLAG_HIDE_NAVIGATION",
"I"
);
jfieldID av_View_SYSTEM_UI_FLAG_FULLSCREEN =
env->GetStaticFieldID(
envptr,
av_View,
"SYSTEM_UI_FLAG_FULLSCREEN",
"I"
);
jfieldID av_View_SYSTEM_UI_FLAG_IMMERSIVE_STICKY =
env->GetStaticFieldID(
envptr,
av_View,
"SYSTEM_UI_FLAG_IMMERSIVE_STICKY",
"I"
);
jfieldID av_View_SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN =
env->GetStaticFieldID(
envptr,
av_View,
"SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN",
"I"
);
jfieldID av_View_SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION =
env->GetStaticFieldID(
envptr,
av_View,
"SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION",
"I"
);
jfieldID av_View_SYSTEM_UI_FLAG_LAYOUT_STABLE =
env->GetStaticFieldID(
envptr,
av_View,
"SYSTEM_UI_FLAG_LAYOUT_STABLE",
"I"
);
jfieldID avwm_LayoutParams_FLAG_FULLSCREEN =
env->GetStaticFieldID(
envptr,
avwm_LayoutParams,
"FLAG_FULLSCREEN",
"I"
);
jfieldID avwm_LayoutParams_FLAG_KEEP_SCREEN_ON =
env->GetStaticFieldID(
envptr,
avwm_LayoutParams,
"FLAG_KEEP_SCREEN_ON",
"I"
);
jfieldID avwm_LayoutParams_FLAG_HARDWARE_ACCELERATED =
env->GetStaticFieldID(
envptr,
avwm_LayoutParams,
"FLAG_HARDWARE_ACCELERATED",
"I"
);
/* Window window = getWindow(); */
jobject window = env->CallObjectMethod(
envptr,
app->activity->clazz,
av_NativeActivity_getWindow
);
/* View decorView = window.getDecorView(); */
jobject decorView = env->CallObjectMethod(
envptr,
window,
av_Window_getDecorView
);
/*
* decorView.setSystemUiVisibility(
* View.SYSTEM_UI_FLAG_LOW_PROFILE |
* View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
* View.SYSTEM_UI_FLAG_FULLSCREEN |
* View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
* View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
* View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
* View.SYSTEM_UI_FLAG_LAYOUT_STABLE
* );
*/
env->CallVoidMethod(
envptr,
decorView,
av_View_setSystemUiVisibility,
env->GetStaticIntField(
envptr,
av_View,
av_View_SYSTEM_UI_FLAG_LOW_PROFILE
) |
env->GetStaticIntField(
envptr,
av_View,
av_View_SYSTEM_UI_FLAG_HIDE_NAVIGATION
) |
env->GetStaticIntField(
envptr,
av_View,
av_View_SYSTEM_UI_FLAG_FULLSCREEN
) |
env->GetStaticIntField(
envptr,
av_View,
av_View_SYSTEM_UI_FLAG_IMMERSIVE_STICKY
) |
env->GetStaticIntField(
envptr,
av_View,
av_View_SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
) |
env->GetStaticIntField(
envptr,
av_View,
av_View_SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
) |
env->GetStaticIntField(
envptr,
av_View,
av_View_SYSTEM_UI_FLAG_LAYOUT_STABLE
)
);
/*
* window.addFlags(
* WindowManager.LayoutParams.FLAG_FULLSCREEN |
* WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON |
* WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
* );
*/
env->CallVoidMethod(
envptr,
window,
av_Window_addFlags,
env->GetStaticIntField(
envptr,
avwm_LayoutParams,
avwm_LayoutParams_FLAG_FULLSCREEN
) |
env->GetStaticIntField(
envptr,
avwm_LayoutParams,
avwm_LayoutParams_FLAG_KEEP_SCREEN_ON
) |
env->GetStaticIntField(
envptr,
avwm_LayoutParams,
avwm_LayoutParams_FLAG_HARDWARE_ACCELERATED
)
);
jvm->DetachCurrentThread(app->activity->vm);
/* ... */
}
If we re-package the .apk
, then install and run it on our device, the colors should now fill the full screen. Swiping from the edge of the screen will temporarily bring back the system bars.
Garbage collection and lifetimes
Above we attached our thread to the JVM, executed some JNI calls to enter fullscreen mode, then detached and never used JNI calls again in android_main
. In some situations, however, it is useful to remain attached and continue making JNI calls throughout an app’s lifetime. In such cases it’s useful to understand how references and lifetimes work with regards to the Java garbage collector.
Each jobject
, jclass
, jstring
, or jarray
is a local reference tracked by the JNI environment for the JVM garbage collector. Initially, there may only be space reserved for 16 local references, but EnsureLocalCapacity
can increase this limit. The JVM has no knowledge of C scopes or stack variable lifetimes, so references will exist until the thread is detached. The DeleteLocalReference
function should be used to destroy references when they are no longer needed.
Read-only asset files
If our app needs to provide static assets, and we don’t want to embed them in the shared library itself, we can add an assets/
directory to our .apk
archive. Any files placed in build/assets/
prior to packaging will be available to our C code through the NDK Asset API.
/* src/main.c */
/* ... */
#include <string.h>
#include <android/asset_manager.h>
/* ... */
void android_main(struct android_app *app) {
/* ... */
AAsset *asset_file = AAssetManager_open(
app->activity->assetManager,
"name.txt",
AASSET_MODE_BUFFER
);
if (asset_file != NULL) {
off_t asset_file_len = AAsset_getLength(asset_file);
const char *asset_file_buffer = AAsset_getBuffer(asset_file);
char *str = malloc((size_t)asset_file_len + 1);
memcpy(str, asset_file_buffer, (size_t)asset_file_len);
str[asset_file_len] = 0;
__android_log_print(
ANDROID_LOG_INFO,
SEGL_ANDROID_LOG_ID,
"name asset: %s",
str
);
free(str);
AAsset_close(asset_file);
}
/* ... */
}
$ mkdir build/assets
$ echo "lana" > build/assets/name.txt
# then proceed with re-building .so and re-packaging .apk
Targeting multiple architectures
The seglapp.apk
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 target! 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/libmain.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/libmain.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/libmain.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 yet either, take a look at the AInputEvent
documentation for more information on that front.
If we want to play sound, we could use the NDK’s built-in Audio API (if we set a minimum SDK version of at least 26), or link against libOpenSLES.so
and use the OpenSL ES API (deprecated but still available). Another option is to use a wrapper like Mini Audio.
I recently found this excellent video about how Android differs from a standard Linux distribution. Unfortunately, I found it after tediously re-discovering most of the information myself, but I can confirm that almost everything still holds true ten years later.
See GitHub for a full version of the example project.