(It is a blog post translated from my Japanese version.)

When we write some code using Android SDK API, we often write code like "we go for this code path on new Android version X or later, and go the other way on the older versions..." It is as simple as:

if (Build.VERSION.SDK_INT >= 30) { /* code for newer Androids */ }
else { /* code for older Androids */ }

But if you want to do the same in native C/C++ code, it is not that simple. Even if you don't write such code by yourself, some code generated by tools may have such code fragments. In this blog post I try to explain what the issues are and provide some solutions i.e. how to specify the target (or compile-time) SDK version on native Android development with NDK.

What were the SDK versions?

(This post is primarily to describe the SDK version issue for Android NDK, but in this section I only describe what an SDK version is in Android SDK land. Skip this if you are already familiar with it. You can always come back to this part if you got stuck on understanding the later sections.)

I believe it is mostly common knowledge that we specify the target Android platform versions in build.gradle file when we deal only with Java/Kotlin using Android SDK, If you create a new app project using Android Studio Dolphin, Electric Eel, or Framingo with no tweaks, the content in app/build.gradle (that is the build description file in app module) would look like this:

android {  
  namespace 'com.example.myapplication'  
  compileSdk 33  
  
  defaultConfig {  
  applicationId "com.example.myapplication"  
  minSdk 24  
  targetSdk 33  

These compileSdk, minSdk, and targetSdk indicates the platform Androdi version when we compile code, the minimum version for running the code in the app, and the target SDK version, respectively. Well, "targetSdk is the target SK version" does not make sense. What is important here is not the property names, but the consequences of specifying these property values.

Let's begin with compileSdk as it's kind of obvious. This value is used to resolve to the Android platform/framework API i.e. android.jar in one of those platforms/android-XX directories in Android SDK when javac or kotlinc resolves Android API symbols. As newer platform APIs are included only in newer android.jars, it would be fairly understandable how significant it is. This property value is useful for app build time, but not at run time. So it is not recorded in AndroidManifest.xml.

minSdk and targetSdk versions are, on the other hand, recorded in AndroidManifest.xml, as attributes on <uses-sdk> element (they are automatically reflected to the final AndroidManifest.xml that Gradle generates as the buld result so we app developers don't have to add to src/main/AndroidManifest.xml). The Android platform (runtime) somteimes behaves differently depending on the targetSdkVersion value in AndroidManifest.xml in the app. Also, if the running platform does not meet the minSdk condition, then the app won't run (unless the running platform implementation has changed from AOSP).

Every time a new platform API emerges in Android SDK, it usually comes with some behavioral changes and they are enabled when targetSdk has some (high) value, and they usually mean "modern Android app behaviors". What is important here is that Google requires certain targetSdk(Version) (quite high) for new apps to register at Google Play Store. So if you would like to use the newer APIs, but cannot upgrade the targetSdk as the app is not ready for newer Android app behavior it implies, you end up to only specify compileSdk. You can do so. On the other hand, if you build your app or library and that depends on another library, and if the library requires higher targetSdk, you are forced to use the same newer targetSdk version (or newer than that). What happens if your app or library is not ready for the new behavior...?

For the record, former build.gradles in Android projects used compileSdkVersion, targetSdkVersion, and minSdkVersion instead of those without Version (in other words, they used to be identical to what is expected in AndroidManifest.xml). They were "functions" in AGP (Android Gradle Plugin) and you could just write targetSdkVersion 33 in build.gradle Groovy script (the following 33 is interpreted as an argument), but in build.gradle.kts Kotlin script they had to be targetSdkVersion(33), which was kind of ugly. targetSdk is a property of type Int, and we could just write targetSdk = 33 in build.gradle.kts, and it has type checks. Meanwhile, we often have to specify preview API identifier like "tiramisu", and there is targetSdkPreview property of type String... things had to become messy, after all.

Android NDK: a totally different ecosystem

Ok, so far enough for Android SDK. We'd focus on Android NDK. It is used for building native code using C/C++ toolchains. Android apps are not native executables but a Dalvik bytecode based apps written in Java/Kotlin code. They have certain entrypoint in the Android framework API, which is loaded from an app process thread created by the Android runtime (a fork()-ed Zygote thread). Native code could be loaded only via this Dalvik bytecode VM (ART in 2022) via JNI, and they necessarily take form of shared libraries (*.sos).

The native code libraries are packaged in APK or AAB just like Java/Kotlin based binary code (*.dex), but it should be noted that native libraries must be built per target CPU ABI. As of 2022 Android NDK ships with arm-linux-androideabi (32bit), aarch64-linux-android (64bit), i686-linux-android (32bit), and x86_64-linux-android (64bit). Those are the triplet identifiers for compiler toolchains (clang/gcc), and we have another set of symbols armeabi-v7a, arm64-v8a, x86 and x86_64 for build.gradle as well as jniLibs directory layout at the source project. Sometimes third party vendors provide other toolsets e.g. IBM for S390.

Regarding build systems for Android NDK, there wasn't really a standard way until 5-6 years ago. There was ndk-build, which was based on Makefile, and Google has been using it for a while, but we app developers either use it or preferred other build systems using standalone toolchains. But since CMake got supported and it was integrated to the AGP seamlessly (i.e. ./gradlew build just became easy and common), it became the majority. Hardwave developers and framework developers would have to deal with AOSP and that's another story (I don't focus on them in this post), but we can say that CMake is "the build system" in 2022 for app developers.

NDK platform API: where target SDK matters

Android NDK offers a set of platform API for C/C++ developers which is completely different Android SDK API for JVM toolchains. The native API includes the C/C++ alternatives to Android SDK API such as NativeActivity, NdkBinder, some APIs that should be used primarily in native code such as OpenGLES, Vulkan and AAudio, and some system APIs like Linux ALSA API (actually tinyalsa). What is important in the context of this blog post is that they are libraries that are already installed in each platform, and their availability depends on the target's Android version. For example, NdkBinder and Native MIDI API (AMidi) are available only on API Level 29 (Android 10) or later (I would explain sooner but NdkBinder has a problem with API Level 29). When we want to use them, we would like to use some alternative to the conditional code in Android SDK API that I showed in the beginning of this post, but in C/C++.

As of Nov. 2022, AGP, from Android Studio Dolphin to Framingo, cannot really deal with the SDK versions when it kicks CMake for native library builds. The only information AGP passes to CMake is ANDROID_PLATFORM, and it is equivalent to minSdk. It is a variable that AGP automatically passes and not meant to be open for changes to app developers.

minSdk is to indicate the minimum Android version that the app can run, and it is quite possible that we also want to use some newer API on newer Android devices. It is quite common in Android SDK API land. So, how can we indicate compileSdk for CMake and/or NDK toolchains? The answer is "no, you can't".

Welcome the frontline in the Android native app development. The world is still under construction from here.

Let me describe what kind of problem we face on this frontline. Android NDK ships with certain header files that "should not be right there". I have been using NdkBinder to connect to my Service apps from my client apps, and I use aidl(.exe) to generate the strongly typed service and client code. Those code files include those "wrong" header files in NDK. The code generated by aidl references headers like android/binder_auto_utils.h, and they are resolved to the file under sysroot (such as toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include) in NDK. That binder_auto_utils.h in particular causes some build failures if minSdk is 29:

(.../sysroot)/usr/include/android/binder_auto_utils.h:263:32: error: 'AStatus_getDescription' is unavailable: introduced in Android 30

If we specify minSdk 30 it goes away, but minSdk 30 is "too progressive". Only 40%-ish deveices in the world has Android 11. Even Android 10 is too progressive, but it is 60%-ish. If we can specify compileSdk then it would go away, but there is no way for now.

__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__, __builtin_available() and platform detection at run time

The fact that the NdkBinder include files are "wrong" was what the NDK developer team told me on the issuetracker, and according to them the right headers exist under platforms/android-XX/optional/libbinder_ndk_cpp in Android SDK. I was not aware that there are such native header files under platforms directory in Android SDK, but right, that design makes sense to me. The native platform API should vary per platform, per se.

So, the current header location seems appropriate, but the fact that we cannot specify compileSdk is still there. In my holding opinion, AGP should automatically add -I path/to/platforms/android-XX/optional/libbinder_ndk_cpp compiler option at CMake invocation. Unfortunately, it seems that the NDK team deals only with NDK toolchains with CMake and header files, the SDK team deals only with platform API, and the Android Studio team deals with AGP - everything is split and would likely take time to get things working effectively across many teams.

But there is a workaround; we can specify -D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ to CMake (-D is for defining an external CMake variable, which you can specify in the CMake command line options as externalNativeBuild { cmake { arguments ... } } in build.gradle). If it is specified, it will change the native build to "not treat undefined symbols as errors". That meas, undefined reference to XXX are not going to be reported as compile-time errors but rather cause run-time error. It is not really acceptable as a standalone option, but fortunately we can also specify -Werror=unguarded-availability which brings back build errors that we want.

This -D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ is basically used with __builtin_available() macro. Here is an example usage:

#ifdef __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__
        if (__builtin_available(android 30, *)) {
#else
        if (__ANDROID_API__ >= 30) {
#endif

We would like the code expression __builtin_available(android 30, *) and its subsequent conditional code to be skipped if the run time platform version (i.e. minSdk in NDK land) does not meet, but the code block is actually compiled and it results in compilation error as the specified code symbol references does not exist. But if __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ is defined, then undefined references (unavailable symbols) are not errors anymore and regarded as "to be resolved at runtime". Thus it will run fine on Android 30 and will result in runtime error on Android 29 (but the condition does not meet so it will flow into the else branch). If I understand correctly, __builtin_available() will make the scoped context as "guarded" block and within it references to undefined symbols will not cause compilation/linking errors even if -Werror=unguarded-availability is specified (correct me if that seems wrong).

With these additional compilation options, it is possible to write run time condition in C/C++ like if (Build.VERSION.SDK_INT >= 30) in Android SDK API code.

This __builtin_available() is specific to clang, so if we use gcc or other toolchains it won't work, but NDK r23 and later only ships clang, so it would not be problematic unless we use older NDKs.

As these macros begin with __, these symbols are internal use only and the solution would be only temporary. At this state they are seen only in NDK or platform internals. They should not be used if there is any other way around. However it seems the only solution for certain use cases, like my case with the aidl-generated code.

Dealing with disappeared platform API: in Oboe

This year I encountered another issue that was affected by platform API, and learned another approach to deal with the platform API dependencies.

Android platform API changes from time to time. New APIs emerge and old APIs vanish. In the new API level, some old API gets deprecated, and you would see build warnings if you try to use them. We can specify -Werror in CMakeLists.txt, and if you use such deprecated API it will result in build failures.

I was caught in an interesting trap when I was trying to use Oboe. To explain what Oboe is, I would briefly explain the history of Android native audio stack. It started with OpenSL ES since API Level 9. It was not sufficient for low latency, so those Android devs worked on the low latency audio foundation and at Android 8.0 they ended up with the public API surface: AAudio (for some technical reason it is practically usable since Android 8.1). As is often happens, the latest API is not really available for a few years, so the Android audio team came up with an API that "is available regardless of whether AAudio is available or not" and also that "may or may not work for low latency audio" - and that is Oboe. It stays outside the framework and evolves, just like Jetpack libraries do regardless of the platform API evolution.

Since Oboe is more intuitive and easy to use compared to AAudio, Oboe became the de facto standard API for Android native audio. And since AAudio became the solid underlying foundation, and Oboe became the de facto standard API for public surface, OpenSLES simply became unnecessary and it started disappearing since API Level 30. It still exists but we should anticipate that it might disappear at any time.

By the way, when we build Oboe both OpenSLES and AAudio must be available. But since android-30 OpenSLES becomes deprecated, we get build warnings by default, and it if -Werror is specified then it will result in build errors. Oboe can be referenced as a binary package as it is also provided as a Prefab package, but if we would like to investigate its source code for debugging, or when we cannot really depend on Prefab packages (it has a handful of issues that you can search at issuetracker.google.com) we run into trouble.

It was somewhat problematic (I could not use Prefab), but the latest Oboe is already fixed. How did the Android audio team fix the issue? Oboe now uses dlopen() and dlsym() to load OpenSLES API dynamically. In fact, the same approach was used from the first place when they used AAudio, otherwise the Oboe sources could not be compiled within apps that targets < Android 8.0. Unlike Dalvik bytecode on ART VM, native libraries that are built with the NDK toolchains and loaded by bionic libc must have all the symbols resolved to existent definitions (implementations) when it was loaded. With bionic libc dlopen(), RTLD_LAZY works like RTLD_NOW i.e. there is no lazy symbol resolution.

So, Oboe dynamically loads AAudio without strong symbol references for the < Android 8.1 devices, and same goes for OpenSLES for >= Android 11 devices. It is tricky, but Oboe does not reference those native APIs a lot and therefore it seems working fine for them. I had similar idea to conditionally load NdkBinder the same way, but quickly gave up as it will quickly get complicated.

Summary

In this post, I have described two approaches to achieve conditional run time code by API levels in native code with Android NDK. The __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ approach is closer to what Android SDK API and what Android platform code does, but it is still temporary solution that Google cannot make official. The dlopen() approach is doable within public stable API, but the road would be quite rough. If you have any better approach it would be nice if you share it.