Post

Integrating Native Libraries in Android Apps Using JNI

Integrating Native Libraries in Android Apps Using JNI

The Java Native Interface (JNI) is a programming framework that enables Java code running in the Java Virtual Machine (JVM) to call and be called by native applications and libraries written in languages like C, C++, or assembly. JNI is commonly used in Android development to leverage native code for performance-critical tasks or to reuse existing C/C++ libraries.

JNI Bridge

Diagram Source

JNI is a bridge between Java and native code. It allows:

  • Java code to call native functions.
  • Native code to call Java methods and access Java objects.

Why Use JNI?

  • Performance: Native code can be faster for computationally intensive tasks.
  • Legacy Code: Reuse existing C/C++ libraries.
  • Hardware Access: Access hardware features not exposed by the Java API.
  • Cross-Platform: Write platform-specific code in C/C++ and use it in Java.

JNI Workflow

  1. Declare Native Methods in Java:
    • Use the native keyword to declare methods in Java.
    • Load the native library using System.loadLibrary().
  2. Implement Native Methods in C/C++:
    • Write the corresponding native function in C/C++.
    • Use JNI functions to interact with Java objects.
  3. Compile and Link:
    • Compile the native code into a shared library (.so for Linux/Android, .dll for Windows).
    • Load the library in Java at runtime.

Setting Up JNI

Prerequisites

  1. Java Development Kit (JDK): Install JDK and set up JAVA_HOME.
    1
    2
    
    sudo apt-get update
    sudo apt-get install openjdk-17-jdk
    
  2. C/C++ Compiler: Install a compiler like GCC or Clang.
    1
    
    sudo apt install build-essential
    
  3. Android NDK (for Android development): Install the NDK to compile native code for Android.

Set Java Environment Variables:

1
2
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export PATH=$PATH:$JAVA_HOME/bin

Steps

  1. Write Java code with native methods.
  2. Generate a C/C++ header file using javac and javah.
  3. Implement the native methods in C/C++.
  4. Compile the native code into a shared library.
  5. Load the library in Java and call the native methods.

Writing JNI Code

Step 1: Declare Native Methods in Java

1
2
3
4
5
6
7
8
9
10
11
12
public class HelloJNI {
    static {
        System.loadLibrary("hello"); // Load the native library
    }

    // Declare a native method
    public native void sayHello();

    public static void main(String[] args) {
        new HelloJNI().sayHello(); // Call the native method
    }
}

Step 2: Generate the Header File

1
2
3
4
# Compile the Java code and Generate the JNI-style header file:
javac HelloJNI.java
javac -h . HelloJNI.java # NEW
javah -jni HelloJNI # OLD

Since Java 8, the javac command has a built-in option to generate header files for JNI and javah no longer exists in the your /usr/lib/jvm/java-xx-openjdk-amd64/bin folder. You can use the -h option with javac to achieve the same result as javah.

This generates a header file (HelloJNI.h) with the native function signature:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class HelloJNI */

#ifndef _Included_HelloJNI
#define _Included_HelloJNI
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     HelloJNI
 * Method:    sayHello
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

Step 3: Implement the Native Method

Write the native implementation in C/C++ (HelloJNI.cpp):

Hacky Method is to copy and paste the Function Prototype generated in above step ;)

1
2
3
4
5
6
7
#include <jni.h>
#include <iostream>
#include "HelloJNI.h"

JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *env, jobject thisObj) {
    std::cout << "Hello from C++!" << std::endl;
}

Step 4: Compile the Native Code

1
2
# Compile the native code into a shared library:
g++ -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" -shared -o libhello.so HelloJNI.cpp

Step 5: Run the Java Program

1
java -Djava.library.path=. HelloJNI

Data conversion across the JNI boundary

When Java calls native code:

  1. Java objects exist in the managed heap (JVM/ART).
  2. Native code operates on raw memory (C/C++ heap/stack).
  3. Therefore, the JVM must:

    • Convert Java types → JNI types
    • Provide access to object memory
    • Manage references safely

This process is known as:

Marshalling (Java → Native)
Unmarshalling (Native → Java)

Data Types and Marshalling

JNI provides mappings between Java and native data types:

C TypeJNI TypeJava TypeBitsType Signature
unsigned char (uint8_t)jbooleanboolean8 (unsigned)Z
signed char (int8_t)jbytebyte8 (signed)B
unsigned short (uint16_t)jcharchar16 (unsigned)C
short (int16_t)jshortshort16 (signed)S
int (int32_t)jintint32 (signed)I
long long (int64_t)jlonglong64 (signed)J
floatjfloatfloat32F
doublejdoubledouble64D
voidvoidvoidN/AV
N/AjobjectObjectReferenceL
N/AjclassClassReferenceLjava/lang/Class;
N/AjstringStringReferenceLjava/lang/String;
N/AjarrayArrayReference[
N/AjobjectArrayObject[]Reference[L

Example: Passing Parameters

1
public native int add(int a, int b);
1
2
3
JNIEXPORT jint JNICALL Java_HelloJNI_add(JNIEnv *env, jobject thisObj, jint a, jint b) {
    return a + b;
}

Example:

Step 1: Write the Java Code Create a Java class (AddNumbers.java) with a native method to add two numbers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class AddNumbers {
    // Load the native library
    static {
        System.loadLibrary("add"); // Name of the shared library (without "lib" prefix and file extension)
    }

    // Declare the native method
    public native int add(int a, int b);

    public static void main(String[] args) {
        AddNumbers adder = new AddNumbers();
        int result = adder.add(10, 20); // Call the native method
        System.out.println("Result of addition: " + result);
    }
}

Step 2: Generate the C Header File

1
2
# Generate the JNI-style header file:
javac -h . javac AddNumbers.java

This will generate a header file named AddNumbers.class and AddNumbers.h.

The header file defines the function signature for the native method.

Step 3: Implement the Native Method in C

Create a C file (AddNumbers.c) and implement the native method:

1
2
3
4
5
6
7
#include <jni.h>
#include "AddNumbers.h"

// Implementation of the native method
JNIEXPORT jint JNICALL Java_AddNumbers_add(JNIEnv *env, jobject thisObj, jint a, jint b) {
    return a + b; // Add the two numbers and return the result
}

The JNIEXPORT and JNICALL macros ensure the function is exported and uses the correct calling. JNIEXPORT: This is a macro that is used to indicate that the following function should be made available to Java code. It is typically defined to an appropriate compiler directive for exporting symbols from a shared library. JNICALL: Similar to JNIEXPORT, this is a macro used to declare the calling convention for the function. It ensures that the function is called correctly according to the platform’s requirements.

Java_AddNumbers_add: This is the name of the JNI function. It follows a specific naming convention:

  • Java_: This is a prefix that indicates the function is a JNI function.
  • AddNumbers: This is the name of the Java class.
  • add: This is the name of the Java method within the AddNumbers class.

JNIEnv* env: Pointer to the JNI environment. It provides access to JNI functions. jobject thisObj: This is a reference to the Java object on which the method is being called. In this case, it’s the AddNumbers object.

Step 4: Compile the C Code into a Shared Library

Ensure you have the JDK installed and the JAVA_HOME environment variable set.

1
2
3
4
sudo apt-get update
sudo apt-get install openjdk-17-jdk
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export PATH=$PATH:$JAVA_HOME/bin

Install a C compiler like GCC or Clang.

1
sudo apt-get install build-essential

Compile the C code:

On Linux:

1
gcc -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" -shared -o libadd.so AddNumbers.c

The -I flag includes the JDK headers (jni.h).

On Windows:

1
gcc -I"%JAVA_HOME%\include" -I"%JAVA_HOME%\include\win32" -shared -o add.dll AddNumbers.c

Step 5: Run the Java Program

Ensure the shared library is in the same directory as the Java program or specify the library path using the -Djava.library.path option.

1
java -Djava.library.path=. AddNumbers

The -Djava.library.path=. option tells the JVM to look for the shared library in the current directory.

JNI String Conversions Overview

When you pass a Java String to a native method, JNI provides functions to convert it into a C-style string (const char*) so you can work with it in C/C++. However, Java strings are managed by the JVM, and C-style strings are not. Therefore, you need to explicitly manage the memory for these conversions.

Key JNI Functions for String Conversion

  1. Java String → C-style UTF-8 Function: GetStringUTFChars

Usage:

1
const char *cStr = env->GetStringUTFChars(javaString, 0);

Memory Management: Release C-style UTF-8 Call ReleaseStringUTFChars after use.

1
env->ReleaseStringUTFChars(javaString, cStr);
  1. C-style UTF-8 → Java String Function: NewStringUTF Usage:
    1
    
    jstring javaString = env->NewStringUTF(cStr);
    
  2. Java String → C-style Buffer (UTF-8) Function: GetStringUTFRegion Usage:
    1
    2
    
    char buffer[256];
    env->GetStringUTFRegion(javaString, start, length, buffer);
    
  3. Get Java String Length (UTF-16) Function: GetStringLength Usage:
    1
    
    jsize length = env->GetStringLength(javaString);
    

JNI in Android App

Let’s try to implement JNI and add base64 encoding decoding feature.

I am using base64 code from this repo.

Create New Project in Android Studio -> Empty Views Activity and give the name of the project as “Base64 JNI”

In Android Studio, the native implementation must go inside:

app/src/main/cpp/

Create cpp directory inside main directory. Then right click on cpp directory and Add C++ Module. It will create a base64jni.cpp.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Write C++ code here.  
//  
// Do not forget to dynamically load the C++ library into your application.  
//  
// For instance,  
//  
// In MainActivity.java:  
//    static {  
//       System.loadLibrary("base64jni");  
//    }  
//  
// Or, in MainActivity.kt:  
//    companion object {  
//      init {  
//         System.loadLibrary("base64jni")  
//      }  
//    }

As suggested by Android Studio we need to add the above commented code into MainActivity.java.

Correct Android Project Structure

Base64JNI/
└── app/
    └── src/
        └── main/
            ├── java/com/example/base64jni/
            │      └── MainActivity.java
            │
            ├── cpp/
            │      ├── base64jni.cpp   <-- IMPLEMENT HERE
            │      └── CMakeLists.txt
            │
            └── AndroidManifest.xml

Add base64.h in cpp and add the code of base64.c into this .cpp file.

Just change the following lines in base64jni.cpp from code base64.c to make it compatible for .cpp.

1
2
3
4
5
6
7
8
char* base64_encode(char* plain) {  
//...
    char* cipher = static_cast<char *>(malloc(strlen(plain) * 4 / 3 + 4));
}
char* base64_decode(char* cipher) {
//...
	char* plain = static_cast<char *>(malloc(strlen(cipher) * 3 / 4));
}

Now run the app in Android Device you connected or use Emulator for it. After you see the app is running successfully then you can continue from here.

In the Android ADB Shell.

1
2
3
4
5
6
# Inside ADB Shell
pm list packages | grep base64jni
package:com.example.base64jni

pm path com.example.base64jni
/data/app/com.example.base64jni-NYLpjbJeaPfSHX_p9wZiGQ==/base.apk

Now in other terminal pull this apk

1
2
3
4
5
adb pull /data/app/com.example.base64jni-2NPKe9H4zSST9UQe1bUSFg==/base.apk .
apktool d base.apk 

file base/lib/arm64-v8a/libbase64jni.so 
base/lib/arm64-v8a/libbase64jni.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, BuildID[sha1]=d4070dd378e054a247c9a5ab404e8b64d3f300ce, stripped

As you can see we have added our Native library to our App.

Now the next thing we need to do is take input from user using EditText widget and add two Button so that user can have the choice to Encode or Decode data and we can send the result to the user in a TextView widget.

When we send a String from Java to native code through JNI:

  1. The Java String must be converted (marshalled) into a native C/C++ representation.
  2. After performing the required native operation,
  3. The native result must be converted back into a Java String before returning to the JVM.

Java → Native Conversion

To access the contents of a Java String, we use:

1
const char* nativeStr = env->GetStringUTFChars(jstring input, nullptr);

This:

  • Converts Java’s UTF-16 String
  • Into Modified UTF-8 (const char*)
  • Managed by JNI

After using it, we must release it:

1
env->ReleaseStringUTFChars(input, nativeStr);

Native → Java Conversion

To return a native string back to Java:

1
jstring result = env->NewStringUTF(nativeCString);

This:

  • Allocates a new Java String
  • Copies the native UTF-8 buffer into the JVM heap
  • Returns a jstring

The source code for the app you can find here

Revision

Primitive Type Mappings

Java TypeJNI TypeNative C/C++ Type
booleanjbooleanuint8_t
bytejbyteint8_t
charjcharuint16_t
shortjshortint16_t
intjintint32_t
longjlongint64_t
floatjfloatfloat
doublejdoubledouble
voidvoidvoid

These map directly — no manual marshalling required.

String Conversion

DirectionJava TypeJNI TypeNative Access Method
Java → NativeStringjstringGetStringUTFChars()
Native → Javachar*jstringNewStringUTF()

Example

1
2
3
4
5
// Java -> Native
const char* str = env->GetStringUTFChars(input, nullptr);

// Native -> Java
jstring result = env->NewStringUTF("hello");

Java strings are UTF-16 internally.
GetStringUTFChars() returns Modified UTF-8.

Primitive Arrays

Java ArrayJNI TypeAccess MethodRelease Method
int[]jintArrayGetIntArrayElements()ReleaseIntArrayElements()
byte[]jbyteArrayGetByteArrayElements()ReleaseByteArrayElements()
long[]jlongArrayGetLongArrayElements()ReleaseLongArrayElements()
float[]jfloatArrayGetFloatArrayElements()ReleaseFloatArrayElements()
double[]jdoubleArrayGetDoubleArrayElements()ReleaseDoubleArrayElements()

Example

1
2
3
4
5
6
jint* elems = env->GetIntArrayElements(array, nullptr);
jsize len = env->GetArrayLength(array);

// Use elems...

env->ReleaseIntArrayElements(array, elems, 0);

Creating Arrays in Native

Desired Java Return TypeJNI Creation Function
int[]NewIntArray()
byte[]NewByteArray()
long[]NewLongArray()
Object[]NewObjectArray()

Example

1
2
jintArray arr = env->NewIntArray(5);
env->SetIntArrayRegion(arr, 0, 5, data);

Object Conversion

Java TypeJNI TypeHow To Access
Any objectjobjectGetObjectClass()
ClassjclassFindClass()
MethodjmethodIDGetMethodID()
FieldjfieldIDGetFieldID()

Example

1
2
3
jclass cls = env->GetObjectClass(obj);
jmethodID mid = env->GetMethodID(cls, "methodName", "()V");
env->CallVoidMethod(obj, mid);

Reference Types

TypeMeaning
jobjectGeneric object reference
jstringJava String reference
jarrayBase array type
jintArrayint[]
jobjectArrayObject[]

All are opaque handles, not raw pointers.

Conclusion

In this part of blog, we implemented a simple Base64 encoder and decoder using JNI to bridge Java and native C/C++ code. JNI is powerful, but with that power comes responsibility. Once you cross the managed boundary, you lose Java’s safety guarantees and must handle memory, lifetimes, and data validation manually.

This post is licensed under CC BY 4.0 by the author.