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 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
- Declare Native Methods in Java:
- Use the native keyword to declare methods in Java.
- Load the native library using
System.loadLibrary().
- Implement Native Methods in C/C++:
- Write the corresponding native function in C/C++.
- Use JNI functions to interact with Java objects.
- Compile and Link:
- Compile the native code into a shared library (
.sofor Linux/Android,.dllfor Windows). - Load the library in Java at runtime.
- Compile the native code into a shared library (
Setting Up JNI
Prerequisites
- Java Development Kit (JDK): Install JDK and set up JAVA_HOME.
1 2
sudo apt-get update sudo apt-get install openjdk-17-jdk
- C/C++ Compiler: Install a compiler like GCC or Clang.
1
sudo apt install build-essential
- 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
- Write Java code with native methods.
- Generate a C/C++ header file using
javacandjavah. - Implement the native methods in C/C++.
- Compile the native code into a shared library.
- 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:
- Java objects exist in the managed heap (JVM/ART).
- Native code operates on raw memory (C/C++ heap/stack).
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 Type | JNI Type | Java Type | Bits | Type Signature |
|---|---|---|---|---|
| unsigned char (uint8_t) | jboolean | boolean | 8 (unsigned) | Z |
| signed char (int8_t) | jbyte | byte | 8 (signed) | B |
| unsigned short (uint16_t) | jchar | char | 16 (unsigned) | C |
| short (int16_t) | jshort | short | 16 (signed) | S |
| int (int32_t) | jint | int | 32 (signed) | I |
| long long (int64_t) | jlong | long | 64 (signed) | J |
| float | jfloat | float | 32 | F |
| double | jdouble | double | 64 | D |
| void | void | void | N/A | V |
| N/A | jobject | Object | Reference | L |
| N/A | jclass | Class | Reference | Ljava/lang/Class; |
| N/A | jstring | String | Reference | Ljava/lang/String; |
| N/A | jarray | Array | Reference | [ |
| N/A | jobjectArray | Object[] | 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
- 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);
- C-style UTF-8 → Java String Function:
NewStringUTFUsage:1
jstring javaString = env->NewStringUTF(cStr);
- Java String → C-style Buffer (UTF-8) Function:
GetStringUTFRegionUsage:1 2
char buffer[256]; env->GetStringUTFRegion(javaString, start, length, buffer);
- Get Java String Length (UTF-16) Function:
GetStringLengthUsage: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:
- The Java
Stringmust be converted (marshalled) into a native C/C++ representation. - After performing the required native operation,
- The native result must be converted back into a Java
Stringbefore 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 Type | JNI Type | Native C/C++ Type |
|---|---|---|
boolean | jboolean | uint8_t |
byte | jbyte | int8_t |
char | jchar | uint16_t |
short | jshort | int16_t |
int | jint | int32_t |
long | jlong | int64_t |
float | jfloat | float |
double | jdouble | double |
void | void | void |
These map directly — no manual marshalling required.
String Conversion
| Direction | Java Type | JNI Type | Native Access Method |
|---|---|---|---|
| Java → Native | String | jstring | GetStringUTFChars() |
| Native → Java | char* | jstring | NewStringUTF() |
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 Array | JNI Type | Access Method | Release Method |
|---|---|---|---|
int[] | jintArray | GetIntArrayElements() | ReleaseIntArrayElements() |
byte[] | jbyteArray | GetByteArrayElements() | ReleaseByteArrayElements() |
long[] | jlongArray | GetLongArrayElements() | ReleaseLongArrayElements() |
float[] | jfloatArray | GetFloatArrayElements() | ReleaseFloatArrayElements() |
double[] | jdoubleArray | GetDoubleArrayElements() | 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 Type | JNI 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 Type | JNI Type | How To Access |
|---|---|---|
| Any object | jobject | GetObjectClass() |
| Class | jclass | FindClass() |
| Method | jmethodID | GetMethodID() |
| Field | jfieldID | GetFieldID() |
Example
1
2
3
jclass cls = env->GetObjectClass(obj);
jmethodID mid = env->GetMethodID(cls, "methodName", "()V");
env->CallVoidMethod(obj, mid);
Reference Types
| Type | Meaning |
|---|---|
jobject | Generic object reference |
jstring | Java String reference |
jarray | Base array type |
jintArray | int[] |
jobjectArray | Object[] |
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.
