How to Extend a KMM Shared Module With C/C++ Code

Written by ttypic | Published 2023/02/08
Tech Story Tags: web-development | programming | coding | kotlin-multiplatform | c | c++ | kotlin | tutorial

TLDRYou'll learn how to embed a simple SHA-256 implementation on C-language in KMM library and use it for iOS and Android target platforms.via the TL;DR App

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

Start Building a Library for iOS and Android

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.

Embed C-code for iOS Target Platforms

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()
}

Embed C-code for Android Target Platforms

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.

Useful Links


Written by ttypic | TechLead @ Chatfuel
Published by HackerNoon on 2023/02/08