Cracking the Code: A Beginner’s Adventure into Native Development with Android NDK — Part B

Rishav Raj
8 min readApr 9, 2024

--

Ahoy!! As we set sail on the sea of code, our ship laden with NDK treasures, the horizon beckons with new challenges and discoveries. With our NDK treasure map in hand and our compasses pointing true, let’s delve deeper into the labyrinth of Android’s native development world.

Kindly refer to Part A of this article to understand the basics of our development. Continuing with our development from our previous article, once the empty project gets build using gradle.

Check local.properties of the project, you will find two directories variables there — sdk.dir, ndk.dir

Now set up the NDK path and CMake path in the build.gradle.kts file of app folder.

What is abiFilters here, what do they do???

The abiFilters block within the ndk configuration in your Android project's build.gradle file is like a gatekeeper, allowing you to specify which Application Binary Interfaces (ABIs) your native code should be built for. This configuration option is particularly useful for optimizing your app's performance and compatibility across different devices and CPU architectures. In our case we are building it for armeabi-v7a, arm64-v8a, x86, x86_64.

Now create a folder in the app -> src → main directory of the project that will include your c++ code and CMakeLists.txt

Creating a file naming native-lib.cpp that will contain our c++ code for NDK, we will write a code for converting a image to grayscale and learn about its intricacies.

These lines include necessary headers for JNI (Java Native Interface), Android bitmap manipulation, and Android logging.

These lines define macros for logging messages in Android, offering a streamlined approach to incorporate log statements into code. The LOG_TAG macro sets a tag to identify log messages related to native image processing. The LOGI macro logs informational messages, while the LOGE macro logs error messages. Each macro utilizes Android's logging mechanism to print messages with the specified tag and log level, enhancing code readability and facilitating debugging efforts during development.

You might be wondering why is it having a ‘C’ linkage in a C++ code. The `extern “C”` declaration in C++ is used to specify that the enclosed functions or variables should have C linkage, rather than C++ linkage. This is essential for interfacing with external code, such as the JNI (Java Native Interface) in Android development, which expects functions with C linkage. By wrapping the function declarations in `extern “C” { }`, you ensure compatibility with external interfaces that require C linkage, like JNI, without C++ name mangling.

The line JNIEXPORT jobject JNICALL is a JNI (Java Native Interface) function declaration signature commonly seen in native code written for Android applications. Here's a succinct breakdown of its meaning:

  • JNIEXPORT: This macro is used to declare functions that are accessible from Java code. It specifies the calling convention and linkage for the function, indicating that it should be accessible from Java.
  • jobject: This is the return type of the function. In JNI, jobject represents a reference to a Java object.
  • JNICALL: This specifies the calling convention for the function, indicating that it follows the standard JNI calling convention.

In summary, JNIEXPORT jobject JNICALL declares a JNI function that returns a reference to a Java object and follows the standard JNI calling convention. This signature is commonly used for JNI functions that are invoked from Java code in Android applications.

It is getting followed by JNI declaration of a fucntion

This is the JNI function declaration. It tells JNI to map this C++ function to a Java method named funtion_namein the packagename.ActivityName class. This function takes three parameters: a JNIEnv pointer, a jobject representing the Java instance calling the method, and a jobject representing the Bitmap object passed from Java.

Now it is followed by the computation code we are writing for converting the image into grayscale-

This code snippet performs image processing on an Android bitmap:

  • AndroidBitmapInfo info; void *pixels; int ret;: Declares variables to store bitmap information, pixel data, and return codes.
  • AndroidBitmap_getInfo(env, bitmap, &info): Retrieves bitmap information using JNI, storing it in the info structure. If unsuccessful, logs an error and returns NULL.
  • AndroidBitmap_lockPixels(env, bitmap, &pixels): Locks the bitmap's pixel data for direct access, storing the pixel data pointer in pixels. If locking fails, logs an error and returns NULL.
  • Grayscale Conversion: Iterates over each pixel in the bitmap, extracting color components (red, green, blue, alpha), and calculates the grayscale value using a weighted average formula. Updates the pixel’s color with the grayscale value.
  • AndroidBitmap_unlockPixels(env, bitmap): Unlocks the bitmap's pixel data.
  • return bitmap;: Returns the modified bitmap back to Java.

In summary, the code fetches bitmap info, locks its pixels, converts them to grayscale, unlocks the pixels, and returns the processed bitmap.

Now, after writing the C++ code, we will write a CMake file in the same directory, that will be CMakeLists.txt. CMake script sets up a build system for an Android NDK project, collecting source files, defining a target library, and linking against necessary system libraries.

It is a cross-platform build system generator. It’s a tool that helps manage the build process for software projects across different platforms and compiler environments. It allows developers to describe the build process using a high-level scripting language (CMake language), which then generates the necessary files (e.g., Makefiles for Unix-like systems, Visual Studio project files for Windows) to build the project.

In the provided CMake script:

  1. cmake_minimum_required(VERSION 3.10.2): This line specifies the minimum required version of CMake for the project.
  2. project(project_name): This line sets the name of the project to "project_name".
  3. set(SRC_DIR path_to_folder_containing_cpp_code): This line sets the path to the directory containing native source files.
  4. file(GLOB SRC_FILES "${SRC_DIR}/*.cpp"): This line uses the file command with the GLOB option to collect all source files (with a .cpp extension) in the specified directory and stores them in the variable SRC_FILES.
  5. add_library(native-lib SHARED ${SRC_FILES}): This line adds a shared library target named "native-lib" to the project. It includes the source files specified in the SRC_FILES variable.
  6. target_sources(native-lib PRIVATE native-lib.cpp): This line adds additional source files (native-lib.cpp) to the "native-lib" target.
  7. target_link_libraries(native-lib PRIVATE android log jnigraphics atomic camera2ndk mediandk): This line specifies the libraries that the "native-lib" target depends on. It links the native library against the Android Native Development Kit (NDK) libraries such as android, log, jnigraphics, atomic, camera2ndk, and mediandk, which provide functionality for interacting with the Android platform, logging, graphics manipulation, atomic operations, camera support, and media operations respectively.

Overall, this CMake script sets up a build system for an Android NDK project, collecting source files, defining a target library, and linking against necessary system libraries.

Now, we need to build our project—

After building the project using the provided CMake script, you will typically see a few changes or additions in the project structure:

  1. Build Directory: A new directory named build (or whatever name you chose) will be created in the root directory of your project. This directory contains all the build artifacts generated by CMake.
  2. Build Artifacts: Within the build directory, you’ll find various files and directories generated by CMake based on the build system you’re using. These may include:
  • Compiled object files (*.o, *.obj)
  • Static and shared library files (e.g., libnative-lib.so in our case)
  • Executable files (if applicable)
  • Build configuration files (e.g., Makefiles, IDE specific project files)
  • Temporary build files and directories

3. Log Files (if applicable): If there were any errors or warnings during the build process, they might be logged in files within the build directory. These logs can help diagnose build issues.

Once the native library is loaded, you can call the native methods from your Compose Activity just like any other Java or Kotlin method.

So, the file that requires the native library to be called add this snippet to load the library-

Here, it is define within a static block and within the initialiser block, `System.loadLibrary()` is called to load a native library named “native-lib (in our case) ”. This is a standard Java method used to load native libraries, typically associated with JNI. The argument "native-lib" should match the name of the native library you want to load, which is usually the name specified in your CMakeLists.txt file or the name of the compiled library file without the extension (e.g., "libnative-lib.so").

Now, once the library is loaded, we can call the funtion with the same function name that we defined in our C++ code along with the package name.In our code we defined it as -

Once we have added this snippet along with logic in our Android code, we can directly build our apk using gradle

./gradlew assembleDebug or assembleRelease

In the app/build/outputs/apk section we can find the apks. Directly install it using adb install <path_to_apk> .

In our case, once our code is compiled the output was something like this —

Before and After conversion

For more details, please check out the code —

Please have a look at Part A as well

“Hopefully, after reading this article, you’ve gained some valuable insights into building Android apps, how to write CPP code and NDK architecture, building cpp using CMake for native development, and maybe even a dash of humor along the way!”

Remember, the world of technology is vast and ever-changing, with countless opportunities to learn and explore. So, whether you’re delving into the depths of JNI, NDK, C++ or simply perfecting your Kotlin skills, keep exploring, keep learning, and most importantly, keep coding — because the next ‘Eureka!’ moment could be just around the corner. 🚀☕️😄

We have found the One Piece of the treasure to begin our exploration with. So, hoist the flag, splice the mainbrace, and embark on yer own epic voyage of learning.

Stay Vigilant for more updates on my page….

--

--