Chapter 1: Getting Started with NDK & JNI
Introduction
Welcome to the world of native Android development. In this chapter, you'll take your first steps into a powerful realm that most Android developers never explore — the Android Native Development Kit (NDK). By the end of this chapter, you'll understand why native development matters, how it works under the hood, and you'll have built your first native-powered Android application.
The NDK opens doors to performance-critical operations, legacy code integration, and capabilities that simply aren't possible with Kotlin alone. Whether you're building games, processing audio, implementing security features, or integrating C/C++ libraries, the NDK is your gateway.
What is the Android NDK?
The Android Native Development Kit (NDK) is a toolset that allows you to implement parts of your Android application using native-code languages such as C and C++. While Kotlin (or Java) runs on the Android Runtime (ART) through bytecode interpretation and just-in-time compilation, native code compiles directly to machine instructions for the target processor architecture.
The Android Application Stack
To understand where native code fits, let's visualize the Android application stack:
┌─────────────────────────────────────────┐
│ Your Application │
│ (Kotlin/Java + Native C/C++) │
├─────────────────────────────────────────┤
│ Android Framework │
│ (Activity, View, Content Providers) │
├─────────────────────────────────────────┤
│ Android Runtime (ART) │
│ (Executes Kotlin/Java code) │
├─────────────────────────────────────────┤
│ Native Libraries │
│ (libc, libm, OpenGL ES, Vulkan) │
├─────────────────────────────────────────┤
│ Hardware Abstraction Layer │
├─────────────────────────────────────────┤
│ Linux Kernel │
└─────────────────────────────────────────┘
Your native code sits alongside the Kotlin/Java code in your application layer, but it bypasses the Android Runtime to execute directly on the native libraries and kernel.
NDK vs SDK: Understanding the Difference
| Aspect | Android SDK | Android NDK |
|---|---|---|
| Language | Kotlin/Java | C/C++ |
| Execution | ART (interpreted + JIT) | Direct machine code |
| Memory Management | Automatic (Garbage Collection) | Manual |
| Development Speed | Fast | Slower |
| Debugging | Easy | More complex |
| Platform APIs | Full access | Limited access |
| Performance | Good | Potentially better |
| Code Portability | Android only | Cross-platform possible |
When Should You Use the NDK?
The NDK is not a tool for every situation. In fact, for most applications, pure Kotlin development is preferable. However, there are specific scenarios where native development becomes essential or highly beneficial.
Ideal Use Cases for NDK
1. Performance-Critical Operations When you need maximum computational performance, native code can provide significant speedups. Operations like image processing, physics simulations, and complex mathematical calculations benefit from native execution.
2. Existing C/C++ Codebases If you have battle-tested C/C++ libraries, the NDK allows you to reuse them directly rather than rewriting everything in Kotlin.
3. Cross-Platform Development Native code can be shared between Android, iOS, Windows, and other platforms. Game engines like Unity and Unreal use this approach.
4. Low-Level Hardware Access Certain hardware features, especially in audio and graphics, require low-level access that's only available through native APIs.
5. Security-Sensitive Operations While not foolproof, native code is harder to reverse-engineer than Kotlin bytecode. Sensitive algorithms and key storage often use native code.
6. Real-Time Processing Audio processing, video encoding, and other real-time operations require predictable, low-latency execution that native code provides.
When NOT to Use NDK
- Simple CRUD applications
- Standard UI development
- Network operations (use Kotlin coroutines)
- Database operations (use Room)
- When development speed is the priority
- When you lack C/C++ expertise
Understanding the NDK Architecture
The NDK architecture consists of several key components working together to enable native development.
Core Components
┌─────────────────────────────────────────────────────────────┐
│ Your Android App │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Kotlin Code │◄───────►│ Native Library │ │
│ │ │ JNI │ (.so file) │ │
│ │ MainActivity │ │ libnative-lib.so │ │
│ └─────────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ JNI Layer │
│ (Java Native Interface - The Bridge) │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ CMake │ │ ndk-build │ │ Prebuilt Libs │ │
│ │ (Build Tool)│ │ (Build Tool)│ │ (.so/.a) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ NDK Toolchain │
│ (Clang Compiler, Linker, Headers, Libs) │
└─────────────────────────────────────────────────────────────┘
JNI: The Bridge Between Worlds
The Java Native Interface (JNI) is the critical technology that connects your Kotlin/Java code to native C/C++ code. Think of JNI as a translator that allows two different programming worlds to communicate.
JNI provides:
- Function calling conventions between Kotlin and C++
- Data type conversions
- Object reference management
- Exception handling across boundaries
Native Libraries (.so files)
When you compile native code, it produces shared libraries with the .so extension (Shared Object). These are similar to .dll files on Windows. Each library is compiled for specific CPU architectures:
armeabi-v7a- 32-bit ARM (older devices)arm64-v8a- 64-bit ARM (modern devices)x86- 32-bit Intel (emulators)x86_64- 64-bit Intel (emulators, some Chromebooks)
Build Systems: CMake vs ndk-build
The NDK supports two build systems:
CMake (Recommended)
- Industry-standard build system
- Better IDE integration
- Cleaner syntax
- Used throughout this book
ndk-build
- Legacy build system
- Based on GNU Make
- Still supported but less common in new projects
Setting Up Android Studio for NDK Development
Let's configure your development environment for native development.
Step 1: Install NDK and CMake
- Open Android Studio
- Go to Tools → SDK Manager
- Click on the SDK Tools tab
- Check the following items:
- NDK (Side by side)
- CMake
- Click Apply and wait for the download
Step 2: Verify Installation
After installation, verify the NDK path:
SDK Location: /Users/[username]/Library/Android/sdk
NDK Location: /Users/[username]/Library/Android/sdk/ndk/[version]
CMake Location: /Users/[username]/Library/Android/sdk/cmake/[version]
Step 3: Create a New NDK Project
- Create a new project in Android Studio
- Select "Native C++" template
- Choose Kotlin as the language
- Select minimum SDK (API 24+ recommended)
- Choose C++ Standard: C++17 (or higher)
Android Studio creates a project structure like this:
app/
├── src/
│ └── main/
│ ├── cpp/
│ │ ├── CMakeLists.txt
│ │ └── native-lib.cpp
│ ├── java/
│ │ └── com/example/app/
│ │ └── MainActivity.kt
│ └── AndroidManifest.xml
├── build.gradle.kts
└── ...
Understanding JNI: The Bridge Between Kotlin and C++
JNI is the foundation of everything we'll do with the NDK. Let's understand it thoroughly.
How JNI Works
When you call a native function from Kotlin:
- The JVM looks up the native method in the loaded
.solibrary - JNI prepares the call, converting Kotlin types to C types
- The native function executes
- Return values are converted back to Kotlin types
- Control returns to your Kotlin code
The JNIEnv Pointer
Every native function receives a JNIEnv* pointer as its first parameter. This is your gateway to JNI functionality:
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_app_MainActivity_stringFromJNI(
JNIEnv* env, // <-- JNI Environment pointer
jobject thiz // <-- Reference to the calling object
) {
// Use 'env' to interact with JNI
return env->NewStringUTF("Hello from C++");
}
The JNIEnv provides functions to:
- Create Java/Kotlin objects
- Call methods on objects
- Access fields
- Handle arrays
- Throw exceptions
- And much more
The jobject Parameter
The second parameter depends on whether your native method is static or instance:
- Instance method:
jobject thiz- reference to the calling object - Static method:
jclass clazz- reference to the class itself
JNI Method Signatures and Naming Conventions
JNI uses a specific naming convention to link Kotlin methods to C++ functions.
The Naming Pattern
Java_<package>_<class>_<method>
Where:
- Underscores in package names become
_1 - Each component is separated by
_
Example:
// Kotlin: com.example.myapp.MainActivity.stringFromJNI()
// C++: Java_com_example_myapp_MainActivity_stringFromJNI()
The Function Signature
extern "C" JNIEXPORT <return_type> JNICALL
Java_<package>_<class>_<method>(JNIEnv* env, jobject/jclass, <parameters>)
Let's break down each part:
| Part | Meaning |
|---|---|
extern "C" |
Prevents C++ name mangling |
JNIEXPORT |
Makes function visible outside the library |
JNICALL |
Specifies calling convention |
JNIEnv* |
Pointer to JNI function table |
jobject/jclass |
Object or class reference |
Overloaded Methods
If you have overloaded methods in Kotlin, JNI appends the parameter signature:
external fun calculate(a: Int): Int
external fun calculate(a: Int, b: Int): Int
// For calculate(Int)
Java_com_example_MainActivity_calculate__I(JNIEnv*, jobject, jint)
// For calculate(Int, Int)
Java_com_example_MainActivity_calculate__II(JNIEnv*, jobject, jint, jint)
Static vs Instance Native Methods
You can declare native methods as either instance methods or static methods.
Instance Native Method
class NativeProcessor {
external fun processData(input: ByteArray): ByteArray
}
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_example_NativeProcessor_processData(
JNIEnv* env,
jobject thiz, // Instance reference
jbyteArray input
) {
// Can access instance fields through 'thiz'
}
Static Native Method
class NativeUtils {
companion object {
@JvmStatic
external fun computeHash(data: String): String
}
}
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_NativeUtils_computeHash(
JNIEnv* env,
jclass clazz, // Class reference (not instance)
jstring data
) {
// Cannot access instance fields
// Can access static fields through 'clazz'
}
Loading Native Libraries in Kotlin
Before calling native functions, you must load the native library.
Basic Loading
class MainActivity : ComponentActivity() {
companion object {
init {
System.loadLibrary("native-lib")
}
}
external fun stringFromJNI(): String
}
Loading with Error Handling
class NativeLoader {
companion object {
private var isLoaded = false
@Synchronized
fun loadLibrary(): Boolean {
if (isLoaded) return true
return try {
System.loadLibrary("native-lib")
isLoaded = true
true
} catch (e: UnsatisfiedLinkError) {
Log.e("NativeLoader", "Failed to load native library", e)
false
}
}
}
}
Library Naming Convention
When you call System.loadLibrary("native-lib"):
- Android looks for
libnative-lib.so - The
libprefix and.soextension are added automatically - Never include these in the
loadLibrary()call
Your First Native Function: "Hello from C++"
Let's write our first complete native function.
Step 1: Declare the Native Method in Kotlin
// MainActivity.kt
package com.example.hellondk
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
companion object {
init {
System.loadLibrary("hello-ndk")
}
}
// Declare native method
external fun sayHello(): String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HelloNDKScreen(message = sayHello())
}
}
}
@Composable
fun HelloNDKScreen(message: String) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = message,
style = MaterialTheme.typography.headlineMedium
)
}
}
}
Step 2: Implement the Native Function in C++
// native-lib.cpp
#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_hellondk_MainActivity_sayHello(
JNIEnv* env,
jobject /* this */
) {
std::string message = "Hello from C++!";
return env->NewStringUTF(message.c_str());
}
Step 3: Configure CMakeLists.txt
# CMakeLists.txt
cmake_minimum_required(VERSION 3.22.1)
project("hello-ndk")
add_library(
hello-ndk
SHARED
native-lib.cpp
)
find_library(
log-lib
log
)
target_link_libraries(
hello-ndk
${log-lib}
)
Step 4: Configure build.gradle.kts
// app/build.gradle.kts
android {
// ... other configurations
defaultConfig {
// ... other configurations
externalNativeBuild {
cmake {
cppFlags += "-std=c++17"
}
}
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
}
Project: System Hardware Inspector
Now let's build a real application that demonstrates NDK capabilities. We'll create a System Hardware Inspector that reads detailed hardware information using native code and displays it in a beautiful Compose UI.
Project Overview
This app will:
- Read CPU architecture and features
- Detect available CPU cores
- Get memory information
- Check supported ABIs
- Display everything in a modern dashboard
Why Native Code for This?
While some hardware info is available through Android APIs, native code gives us:
- Direct access to system files like
/proc/cpuinfo - Lower-level hardware details
- Faster information gathering
- Access to CPU-specific features
Project Structure
app/src/main/
├── cpp/
│ ├── CMakeLists.txt
│ ├── hardware_inspector.cpp
│ └── hardware_inspector.h
├── java/com/example/hardwareinspector/
│ ├── MainActivity.kt
│ ├── HardwareInfo.kt
│ ├── NativeInspector.kt
│ └── ui/
│ ├── HardwareScreen.kt
│ ├── InfoCard.kt
│ └── theme/
└── AndroidManifest.xml
Step 1: Define the Data Models
// HardwareInfo.kt
package com.example.hardwareinspector
data class HardwareInfo(
val cpuArchitecture: String,
val cpuCores: Int,
val cpuFeatures: List<String>,
val supportedAbis: List<String>,
val totalMemoryMB: Long,
val availableMemoryMB: Long,
val cpuModel: String,
val cpuVendor: String,
val cpuFrequencyMHz: Int
)
Step 2: Create the Native Interface
// NativeInspector.kt
package com.example.hardwareinspector
object NativeInspector {
init {
System.loadLibrary("hardware-inspector")
}
external fun getCpuArchitecture(): String
external fun getCpuCores(): Int
external fun getCpuFeatures(): Array<String>
external fun getSupportedAbis(): Array<String>
external fun getTotalMemoryMB(): Long
external fun getAvailableMemoryMB(): Long
external fun getCpuModel(): String
external fun getCpuVendor(): String
external fun getCpuFrequencyMHz(): Int
fun getHardwareInfo(): HardwareInfo {
return HardwareInfo(
cpuArchitecture = getCpuArchitecture(),
cpuCores = getCpuCores(),
cpuFeatures = getCpuFeatures().toList(),
supportedAbis = getSupportedAbis().toList(),
totalMemoryMB = getTotalMemoryMB(),
availableMemoryMB = getAvailableMemoryMB(),
cpuModel = getCpuModel(),
cpuVendor = getCpuVendor(),
cpuFrequencyMHz = getCpuFrequencyMHz()
)
}
}
Step 3: Implement the Native Code
// hardware_inspector.h
#ifndef HARDWARE_INSPECTOR_H
#define HARDWARE_INSPECTOR_H
#include <string>
#include <vector>
namespace HardwareInspector {
std::string getCpuArchitecture();
int getCpuCores();
std::vector<std::string> getCpuFeatures();
std::vector<std::string> getSupportedAbis();
long getTotalMemoryMB();
long getAvailableMemoryMB();
std::string getCpuModel();
std::string getCpuVendor();
int getCpuFrequencyMHz();
// Helper functions
std::string readFile(const std::string& path);
std::string extractValue(const std::string& content, const std::string& key);
}
#endif
// hardware_inspector.cpp
#include <jni.h>
#include <string>
#include <vector>
#include <fstream>
#include <sstream>
#include <unistd.h>
#include <sys/sysinfo.h>
#include <android/log.h>
#define LOG_TAG "HardwareInspector"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
namespace HardwareInspector {
std::string readFile(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) {
LOGE("Failed to open file: %s", path.c_str());
return "";
}
std::stringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
std::string extractValue(const std::string& content, const std::string& key) {
size_t pos = content.find(key);
if (pos == std::string::npos) {
return "Unknown";
}
size_t start = content.find(':', pos);
if (start == std::string::npos) {
return "Unknown";
}
start++; // Move past the colon
// Skip whitespace
while (start < content.length() &&
(content[start] == ' ' || content[start] == '\t')) {
start++;
}
size_t end = content.find('\n', start);
if (end == std::string::npos) {
end = content.length();
}
return content.substr(start, end - start);
}
std::string getCpuArchitecture() {
#if defined(__aarch64__)
return "ARM64 (aarch64)";
#elif defined(__arm__)
return "ARM (32-bit)";
#elif defined(__x86_64__)
return "x86_64";
#elif defined(__i386__)
return "x86 (32-bit)";
#else
return "Unknown Architecture";
#endif
}
int getCpuCores() {
return static_cast<int>(sysconf(_SC_NPROCESSORS_ONLN));
}
std::vector<std::string> getCpuFeatures() {
std::vector<std::string> features;
std::string cpuInfo = readFile("/proc/cpuinfo");
std::string featuresLine = extractValue(cpuInfo, "Features");
if (featuresLine == "Unknown") {
featuresLine = extractValue(cpuInfo, "flags");
}
std::istringstream iss(featuresLine);
std::string feature;
while (iss >> feature) {
features.push_back(feature);
}
return features;
}
std::vector<std::string> getSupportedAbis() {
std::vector<std::string> abis;
#if defined(__aarch64__)
abis.push_back("arm64-v8a");
#endif
#if defined(__arm__)
abis.push_back("armeabi-v7a");
#endif
#if defined(__x86_64__)
abis.push_back("x86_64");
#endif
#if defined(__i386__)
abis.push_back("x86");
#endif
return abis;
}
long getTotalMemoryMB() {
struct sysinfo info;
if (sysinfo(&info) == 0) {
return (info.totalram * info.mem_unit) / (1024 * 1024);
}
return -1;
}
long getAvailableMemoryMB() {
struct sysinfo info;
if (sysinfo(&info) == 0) {
return (info.freeram * info.mem_unit) / (1024 * 1024);
}
return -1;
}
std::string getCpuModel() {
std::string cpuInfo = readFile("/proc/cpuinfo");
std::string model = extractValue(cpuInfo, "model name");
if (model == "Unknown") {
model = extractValue(cpuInfo, "Hardware");
}
return model;
}
std::string getCpuVendor() {
std::string cpuInfo = readFile("/proc/cpuinfo");
std::string vendor = extractValue(cpuInfo, "vendor_id");
if (vendor == "Unknown") {
vendor = extractValue(cpuInfo, "CPU implementer");
// Translate common implementer codes
if (vendor == "0x41") vendor = "ARM";
else if (vendor == "0x51") vendor = "Qualcomm";
else if (vendor == "0x53") vendor = "Samsung";
else if (vendor == "0x4e") vendor = "NVIDIA";
}
return vendor;
}
int getCpuFrequencyMHz() {
std::string freqStr = readFile("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq");
if (freqStr.empty()) {
return -1;
}
try {
return std::stoi(freqStr) / 1000; // Convert KHz to MHz
} catch (...) {
return -1;
}
}
}
// JNI Implementations
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_hardwareinspector_NativeInspector_getCpuArchitecture(
JNIEnv* env, jobject /* this */
) {
return env->NewStringUTF(HardwareInspector::getCpuArchitecture().c_str());
}
extern "C" JNIEXPORT jint JNICALL
Java_com_example_hardwareinspector_NativeInspector_getCpuCores(
JNIEnv* env, jobject /* this */
) {
return HardwareInspector::getCpuCores();
}
extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_example_hardwareinspector_NativeInspector_getCpuFeatures(
JNIEnv* env, jobject /* this */
) {
std::vector<std::string> features = HardwareInspector::getCpuFeatures();
jclass stringClass = env->FindClass("java/lang/String");
jobjectArray result = env->NewObjectArray(
static_cast<jsize>(features.size()),
stringClass,
env->NewStringUTF("")
);
for (size_t i = 0; i < features.size(); i++) {
env->SetObjectArrayElement(
result,
static_cast<jsize>(i),
env->NewStringUTF(features[i].c_str())
);
}
return result;
}
extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_example_hardwareinspector_NativeInspector_getSupportedAbis(
JNIEnv* env, jobject /* this */
) {
std::vector<std::string> abis = HardwareInspector::getSupportedAbis();
jclass stringClass = env->FindClass("java/lang/String");
jobjectArray result = env->NewObjectArray(
static_cast<jsize>(abis.size()),
stringClass,
env->NewStringUTF("")
);
for (size_t i = 0; i < abis.size(); i++) {
env->SetObjectArrayElement(
result,
static_cast<jsize>(i),
env->NewStringUTF(abis[i].c_str())
);
}
return result;
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_example_hardwareinspector_NativeInspector_getTotalMemoryMB(
JNIEnv* env, jobject /* this */
) {
return HardwareInspector::getTotalMemoryMB();
}
extern "C" JNIEXPORT jlong JNICALL
Java_com_example_hardwareinspector_NativeInspector_getAvailableMemoryMB(
JNIEnv* env, jobject /* this */
) {
return HardwareInspector::getAvailableMemoryMB();
}
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_hardwareinspector_NativeInspector_getCpuModel(
JNIEnv* env, jobject /* this */
) {
return env->NewStringUTF(HardwareInspector::getCpuModel().c_str());
}
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_hardwareinspector_NativeInspector_getCpuVendor(
JNIEnv* env, jobject /* this */
) {
return env->NewStringUTF(HardwareInspector::getCpuVendor().c_str());
}
extern "C" JNIEXPORT jint JNICALL
Java_com_example_hardwareinspector_NativeInspector_getCpuFrequencyMHz(
JNIEnv* env, jobject /* this */
) {
return HardwareInspector::getCpuFrequencyMHz();
}
Step 4: Configure CMakeLists.txt
# CMakeLists.txt
cmake_minimum_required(VERSION 3.22.1)
project("hardware-inspector")
# Set C++ standard
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Add the native library
add_library(
hardware-inspector
SHARED
hardware_inspector.cpp
)
# Find the Android log library
find_library(
log-lib
log
)
# Link libraries
target_link_libraries(
hardware-inspector
${log-lib}
)
Step 5: Create the Compose UI
// ui/InfoCard.kt
package com.example.hardwareinspector.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun InfoCard(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
content()
}
}
}
@Composable
fun InfoRow(
label: String,
value: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}
// ui/HardwareScreen.kt
package com.example.hardwareinspector.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.hardwareinspector.HardwareInfo
import com.example.hardwareinspector.NativeInspector
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HardwareScreen() {
var hardwareInfo by remember { mutableStateOf<HardwareInfo?>(null) }
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
hardwareInfo = NativeInspector.getHardwareInfo()
isLoading = false
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Hardware Inspector") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
hardwareInfo?.let { info ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// CPU Info Card
item {
InfoCard(title = "🖥️ CPU Information") {
InfoRow("Architecture", info.cpuArchitecture)
InfoRow("Cores", "${info.cpuCores}")
InfoRow("Model", info.cpuModel)
InfoRow("Vendor", info.cpuVendor)
if (info.cpuFrequencyMHz > 0) {
InfoRow("Max Frequency", "${info.cpuFrequencyMHz} MHz")
}
}
}
// Memory Info Card
item {
InfoCard(title = "💾 Memory Information") {
InfoRow("Total RAM", "${info.totalMemoryMB} MB")
InfoRow("Available RAM", "${info.availableMemoryMB} MB")
val usedPercentage = if (info.totalMemoryMB > 0) {
((info.totalMemoryMB - info.availableMemoryMB) * 100 / info.totalMemoryMB).toInt()
} else 0
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { usedPercentage / 100f },
modifier = Modifier.fillMaxWidth(),
)
Text(
text = "Memory Usage: $usedPercentage%",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp)
)
}
}
// Supported ABIs Card
item {
InfoCard(title = "📦 Supported ABIs") {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(info.supportedAbis) { abi ->
AssistChip(
onClick = { },
label = { Text(abi) }
)
}
}
}
}
// CPU Features Card
item {
InfoCard(title = "⚡ CPU Features") {
Text(
text = info.cpuFeatures.take(20).joinToString(", "),
style = MaterialTheme.typography.bodySmall
)
if (info.cpuFeatures.size > 20) {
Text(
text = "... and ${info.cpuFeatures.size - 20} more",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
}
}
Step 6: Update MainActivity
// MainActivity.kt
package com.example.hardwareinspector
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import com.example.hardwareinspector.ui.HardwareScreen
import com.example.hardwareinspector.ui.theme.HardwareInspectorTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HardwareInspectorTheme {
HardwareScreen()
}
}
}
}
Step 7: Configure build.gradle.kts
// app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.hardwareinspector"
compileSdk = 35
defaultConfig {
applicationId = "com.example.hardwareinspector"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {
cmake {
cppFlags += "-std=c++17"
}
}
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
Summary
In this chapter, you've taken your first major steps into native Android development:
- Understood the NDK - What it is, when to use it, and how it fits into the Android stack
- Learned JNI fundamentals - How the bridge between Kotlin and C++ works
- Mastered naming conventions - How native methods map to C++ functions
- Set up your environment - Configured Android Studio for native development
- Built a real application - Created a Hardware Inspector using native code
Key Takeaways
- The NDK provides access to native C/C++ code from Android applications
- JNI is the bridge that connects Kotlin/Java to native code
- Use the NDK for performance-critical operations, existing C++ code, or low-level hardware access
- Native functions follow a specific naming convention:
Java_package_class_method - Always load native libraries before calling native functions
- CMake is the recommended build system for native code
What's Next
In Chapter 2, we'll dive deeper into data type handling. You'll learn how to pass primitives, strings, and complex data between Kotlin and C++. We'll build a Secure Text Encoder that implements multiple encoding algorithms in native code.
Exercises
Extend the Hardware Inspector: Add a function to read the device's kernel version from
/proc/versionCreate a Native Calculator: Implement basic arithmetic operations (add, subtract, multiply, divide) in native code and call them from Kotlin
Temperature Monitor: Create a native function that reads CPU temperature from
/sys/class/thermal/thermal_zone0/tempand displays it in CelsiusBenchmark Test: Create a function that runs both in Kotlin and native code, comparing execution times for a computationally intensive task (like calculating prime numbers)