This tutorial demonstrates how to use C/C++ code in your KMM shared module. You'll learn how to embed a simple SHA-256 implementation on C-language in the KMM library and use it for iOS and Android target platforms.
Kotlin Multiplatform Mobile (KMM) is an SDK designed to simplify the development of cross-platform mobile applications. You can share common code between iOS and Android apps and write platform-specific code only where it's necessary
You can use C and C++ code both with Android and iOS (Xcode supports C, C++, and out-of-the-box, and Android has Android NDK). KMM utilizes this functionality and allows you to use C and C++ code in your shared module.
It can be useful in app development; there are a lot of libraries that have already been written in C/C++. A great example of this technique is the Zipline project which embeds the QuickJs engine in the KMM code.
In this article, we won’t talk about how it works under the hood and will instead concentrate on writing the code and setting up the Gradle build.
The code for this article could be found in the repository
We won’t talk about how to create a KMM library as it is very well explained in the official Kotlin tutorial.
Let’s take the SHA-256 code written on C as an example, and copy it into our native/sha256
folder. The sample code for the SHA-256 implementation was taken from this repository.
Because Android and iOS applications support C-code differently, we need to use the expected/actual approach.
First of all, let’s set up our common part:
/**
* Encoder interface which can encode byte arrays to Sha256 format.
*/
interface Sha256Encoder {
fun encode(src: ByteArray): ByteArray
fun encodeToString(src: ByteArray): String {
val encoded = encode(src)
return buildString(encoded.size) {
encoded.forEach { append(it.toUByte().toString(16).padStart(2, '0')) }
}
}
}
expect object Sha256Factory {
/**
* Creates a new instance of [Sha256Encoder]
*/
fun createEncoder(): Sha256Encoder
}
Then, continue to platform-specific implementations.
For iOS target platforms, we will set up the Gradle C Klib plugin, which will build our C-code as LLVM bitcode.
C Klib is an experimental library with limited support from the authors.
All we need to do is provide a path to the source folder with C-code; in our example, it will be native/sha256
:
cklib {
config.kotlinVersion = libs.versions.kotlin.get()
create("sha256") {
language = C
srcDirs = project.files(file("native/sha256"))
}
}
Then we need to create a .def
file for the KMM cinterop
tool which generates Kotlin bindings from a C-header file.
To learn more about .def
, you could read the official cinterop tutorial
In our case, we just need to create our sha256.def
in the nativeInterop/cinterop
folder (default searching place for cinterop
tool) and name package where Kotlin bindings will be:
package = com.ttypic.clibs.sha256
Finally, we need to add the cinterop step for our target platforms and specify the header file:
kotlin {
// ...
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.compilations {
val main by getting {
cinterops {
create("sha256") {
header(file("native/sha256/sha256.h"))
}
}
}
}
}
// ...
}
After resyncing our Gradle build, we will see Kotlin bindings in the com.ttypic.clibs.sha256
package, and we are ready to invoke native code. Our actual
implementation will look like this:
actual object Sha256Factory {
actual fun createEncoder(): Sha256Encoder = NativeSha256Encoder
}
object NativeSha256Encoder : Sha256Encoder {
@OptIn(ExperimentalUnsignedTypes::class)
override fun encode(src: ByteArray): ByteArray =
memScoped {
val ctx = alloc<SHA256_CTX>()
sha256_init(ctx.ptr)
val srcPointer = src.toUByteArray().toCValues().ptr
sha256_update(ctx.ptr, srcPointer, src.size.toULong())
UByteArray(32).apply {
usePinned {
sha256_final(ctx.ptr, it.addressOf(0))
}
}.toByteArray()
}
}
That’s it; now we can run tests to check if everything is OK:
class NativeSha256Test {
@Test
fun `should pass library sha256 checks`() {
checkEncodeToString("Kotlin", "c78f6c97923e81a2f04f09c5e87b69e085c1e47066a1136b5f590bfde696e2eb")
checkEncodeToString("abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
checkEncodeToString("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1")
checkEncodeToString("aaaaaaaaaa", "bf2cb58a68f684d95a3b78ef8f661c9a4e5b09e82cc8f9cc88cce90528caeb27")
}
private fun checkEncodeToString(input: String, expectedOutput: String) {
assertEquals(expectedOutput, Sha256Factory.createEncoder().encodeToString(input.asciiToByteArray()))
}
}
private fun String.asciiToByteArray() = ByteArray(length) {
get(it).code.toByte()
}
For Android, we will be using Android NDK. Android NDK uses CMake under the hood; that’s why we need to provide the CMakeList.txt
build file:
cmake_minimum_required(VERSION 3.4.1)
# setup C standard we used
set(CMAKE_C_STANDARD 99)
# source code file mask
file(GLOB_RECURSE sources "../../native/*.c")
# build sources as dynamic library
add_library(sha256 SHARED ${sources})
# link library
target_link_libraries(sha256)
Then we have to specify this file in the Android plugin and setup the target platforms:
android {
// ...
defaultConfig {
// ...
ndk {
// target platforms
abiFilters += listOf("x86", "x86_64", "armeabi-v7a", "arm64-v8a")
}
}
// ...
externalNativeBuild {
cmake {
path = file("src/androidMain/CMakeLists.txt")
}
}
}
To invoke our native code, we need to use JNI to map native code to JVM. Android Studio has great JNI support. Let’s create sha256-jni.c
file, then include JNI and our library header files:
#include "jni.h"
#include "sha256/sha256.h"
Now, we are ready to write our actual
code:
actual object Sha256Factory {
actual fun createEncoder(): Sha256Encoder = AndroidSha256Encoder
}
object AndroidSha256Encoder : Sha256Encoder {
init {
System.loadLibrary("sha256")
}
external override fun encode(src: ByteArray): ByteArray
}
It is very straightforward. First, we load our dynamic library, and next, we use JNI to invoke native code. Now, all we need is to write JNI-bindings. You can do it yourself using the fully qualified class name or invoke the Android Studio context menu and choose create JNI funtion
JNI function in sha256-jni.c
JNIEXPORT jbyteArray JNICALL Java_com_ttypic_clibs_AndroidSha256Encoder_encode(JNIEnv *env, jclass _, jbyteArray src) {
BYTE hash[SHA256_BLOCK_SIZE];
SHA256_CTX ctx;
size_t len = (size_t) ((*env)->GetArrayLength(env, src));
jboolean copied;
jbyte* bytes = (*env)->GetByteArrayElements(env, src, &copied);
sha256_init(&ctx);
sha256_update(&ctx, (const BYTE *) bytes, len);
sha256_final(&ctx, hash);
(*env)->ReleaseByteArrayElements(env, src, bytes, JNI_ABORT);
jbyteArray result = (*env)->NewByteArray(env, SHA256_BLOCK_SIZE);
(*env)->SetByteArrayRegion(env, result, 0, SHA256_BLOCK_SIZE, (const jbyte *) hash);
return result;
}
And finally, we are ready to write tests:
We need to write the instrumental test on the simulator; regular Unit-test won’t load our native library.
class AndroidSha256Test {
@Test
fun should_pass_library_sha256_checks() {
checkEncodeToString("Kotlin", "c78f6c97923e81a2f04f09c5e87b69e085c1e47066a1136b5f590bfde696e2eb")
checkEncodeToString("abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
checkEncodeToString("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1")
checkEncodeToString("aaaaaaaaaa", "bf2cb58a68f684d95a3b78ef8f661c9a4e5b09e82cc8f9cc88cce90528caeb27")
}
private fun checkEncodeToString(input: String, expectedOutput: String) {
assertEquals(
expectedOutput,
Sha256Factory.createEncoder().encodeToString(input.asciiToByteArray())
)
}
}
private fun String.asciiToByteArray() = ByteArray(length) {
get(it).code.toByte()
}
We successfully embedded C-code in our Kotlin Multiplatform Library, which can be used in iOS and Android target platforms.