Android + OpenCV: Part 5 — JNI Instrument Test: getMessage() and askQuestions()

Homan Huang
8 min readApr 30, 2020

--

Before you move on the Face Recognition, we need NDK support. Unlike to open a new project with C++ support in Android Studio. You need to add files manually for testing the NDK ability. In this part, I will write a Cpp program as a server and a Kotlin class as a liaison to communicate between the Cpp and MainActivity.

Now, you knew my plan. However, I don’t suggest that you move on the easy part, the Java class, like the other sites. You need to build the bone at the beginning. Otherwise, the java call is NOT working and the function name will MESS up. In fact, there is no javah you can call for the Kotlin file.

👍Menu:

1. CMakeLists.txt — Bone of Cpp
2. Create Cpp Files
3. Link CMakeLists.txt to Build.Gradle
4. Kotlin Class: Native Call + Cpp Function Name
5. Message Code and 1st Instrument Test
6. Send Questions and Take Answers
7. Second Test

😅1. CMakeLists.txt

This is the bone between Cpp and Java, CMakeLists.txt, to tell CMake what to do.

If you have spelled correctly, you shall see a tri-color triangle. Let’s add these lines.

# The minimum version
cmake_minimum_required(VERSION 3.4.1)
# Your libraries
add_library(
# Library:
jnitest
# Shared library:
SHARED
# Source file(s):
jnitest.cpp )
# Logcat
find_library(
# Path:
log-lib
# Locate the NDK library:
log )
# Link yours to log
target_link_libraries(
# Your library:
jnitest
# To the log library:
${log-lib} )

It specifically points to your library with source files, the log library, and their relationship. Above is the example to create a link between jnitest library and log-lib.

😃2. Create Cpp Files

If you have not created the JNI folder, please add a new one now.

We need to create a Cpp file in JNI.

I don’t know why Android Studio changes the folder name from “jni” to “cpp” in the Android view. So, don’t complain; Google is the big boss. Let’s add a new Cpp class.

Right-click at the cpp => New => C++ Class

The class is called jnitest. This will create one class and its header file. Here is jnitest.cpp. Pretty blank, isn’t it? 👶

//
// Created by Homan on 4/30/2020.
//

#include "jnitest.h"

😄3. Link CMakeLists.txt to Build.Gradle

Now, you have bone 🍖 and meat 🍤(some blank files). Let’s sell them to the system 🍯.

Right-click CMakeLists.txt => Link C++ Project with Gradle

Let’s find the file.

Yeah, it’s in jni folder. After that, the Gradle will automatically sync the file.

In build.gradle(Module: app), you will find the new content 🏅:

android {
...
externalNativeBuild {
cmake {
path "src/main/jni/CMakeLists.txt"
version "3.10.2"
}
}
}

Also, you may need multiple CPUs support. Let’s add:

android {
...


defaultConfig {
...


ndk{
moduleName "jnitest" //create so file
//32bit and 64bit cpu
abiFilters "arm64-v8a", "armeabi-v7a", "x86", "x86_64"
}
}
}

That will create some “*.so” files in the CMake directory.

😜4. Kotlin Class: Native Call + Cpp Function Name

Finally, let’s make a Kotlin class. I call it NativeCall.

Let’s add a function, msgFromJNI(), and the library, jnitest.

class NativeCall {

// call JNI function to export the message
external fun msgFromJNI(): String?

companion object {
init {
// Cpp library file
System.loadLibrary("jnitest")
}
}
}

Don’t worry. Let’s add this function to Cpp with the same name.

First, please add these lines into jnitest.cpp.

#include <jni.h>

extern "C"
JNIEXPORT jstring JNICALL
// Java_packageName_className_funName() {}
Java_

Next, you only need to copy the reference name to the Cpp file. Yeah, you don’t need to compile a class file by javac and translate the function name into the Cpp header file by javah. By the way, you don’t have those abilities with Kotlin.

...
Java_com.homan.huang.opencvcamerademo.NativeCall.msgFromJNI

Highlight the line of function name and Ctrl+R to replace “.” to “_” in Selection. Now, you have the correct name. Please add “() {}” at the end to fix it. Like this,

Java_com_homan_huang_opencvcamerademo_NativeCall_msgFromJNI(){}

Let’s insert the missing parameters. Finally, you have the link between Kotlin and Cpp.

Java_com_homan_huang_opencvcamerademo_NativeCall_msgFromJNI(
JNIEnv *env,
jobject thiz) {

}

You can check NativeCall.kt. The red color shall be gone 😆. Next time, you DON’T need to redo all the steps again. Just Right-Click the 2nd or later new function name to create a new JNI function in the Cpp file.

😒5. Message Code and 1st Instrument Test

We shall not place any test case in the real java folder. That will be mess up your project. There are two extra folders in the project for you to test some data. Today, I will use androidTest folder for the instrument test.

Let’s rename the ExampleInstrumentedTest to Jni_instrument_test by Shift+F6.

Please check the build.gradle(Module: app) for test dependencies like this:

dependencies {
...

// test
testImplementation "junit:junit:4.13"
//Kotlin coroutines
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
// Hamcrest library
androidTestImplementation 'org.hamcrest:hamcrest-library:1.3'
// UI testing with Espresso
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0-beta01'
// UI testing with UI Automator
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'

...
}

Jni_instrument_test.kt: Remove the old content and add the new test case.

@RunWith(AndroidJUnit4::class)
class Jni_instrument_test {

@get:Rule
val activityRule = ActivityTestRule(MainActivity::class.java)

@Test
fun JniMessegeTest() {
val respString = "Welcome OpenCV Camera Demo"

//JNI test
val testMsg = NativeCall().msgFromJNI()
assertThat(respString, equalToIgnoringWhiteSpace(testMsg))
}

}

The jniTest.cpp: Return the SAME message in the test case.

Java_com_homan_huang_opencvcamerademo_NativeCall_msgFromJNI(
JNIEnv *env,
jobject thiz) {
std::string hello = "Welcome OpenCV Camera Demo";
return env->NewStringUTF(hello.c_str());
}

Now, please connect your phone and run the test case…

Result:

Testing started at 10:23 PM ...05/01 22:23:19: Launching 'JniMessegeTest()' on motorola moto g(7) supra.
Running tests
$ adb shell am instrument -w -r -e debug false -e class 'com.homan.huang.opencvcamerademo.Jni_instrument_test#JniMessegeTest' com.homan.huang.opencvcamerademo.test/androidx.test.runner.AndroidJUnitRunner
Connected to process 5262 on device 'motorola-moto_g_7__supra-ZY326LVCZQ'.
Started running testsTests ran to completion.

😤6. Send Questions and Take Answers

That’s only a passive message. Now, let’s send Cpp some data. I will ask Cpp three questions with CPU temperature. And I expect to get answers in a string array. The NativeCall.kt,

class NativeCall {

// call JNI function to export the message
external fun msgFromJNI(): String?

// ask questions: String Array
// get answers: String Array
external fun askQuestions(
questions: Array<String>,
temperature: Float): Array<String>?
companion object {
init {
// Cpp library file
System.loadLibrary("jnitest")
}
}
}

Right-Click the new function to add a new Cpp function:

/*
* Get three questions from Java and return three answers to Java
*/
extern "C"
JNIEXPORT jobjectArray JNICALL
Java_com_homan_huang_opencvcamerademo_NativeCall_askQuestions(
JNIEnv *env,
jobject thiz,
jobjectArray questions,
jfloat temperature) {

}

This time, it’s automatically. Sound great, right?!

I get the size of the questions to return with the same size.

// array size
jsize stringCount = env->GetArrayLength( questions );

// create string array but JNI hasn't a jstringArray object
jobjectArray retArray;
retArray = (jobjectArray)env->NewObjectArray(
stringCount,
env->FindClass("java/lang/String"),
env->NewStringUTF(""));
/* Search answers *//* End search answers */return retArray;

There’s no jstringArray type, so I have to use objectArray.

// search phrases
std::string phrase1 = "How are you";
std::string phrase2 = "temperature";
std::string phrase3 = "Can you run";
std::string cpuTemperature = std::to_string(temperature);
// Answers
std::string generalAnswer = "Cpu Bob: I\'m fine. Thank you!";
std::string tempAnswer = "Cpu Bob: "+ cpuTemperature;
std::string overheatAnswer = "Cpu Bob: Sorry! I\'m overheated.";
std::string coolAnswer = "Cpu Bob: Yes! I\'m cool with that.";

I set up some search phrases and answers. Next, I need to search phrases in question and get an answer one by one.

// get answsers
for (jint i=0; i<stringCount; i++) {
jstring mString = (jstring)
(env->GetObjectArrayElement( questions, i));

// convert jstring to C string
const char *convertedValue =
env->GetStringUTFChars(mString, 0);
int strLen = sizeof(convertedValue);
std::string question = std::string(convertedValue);

// answer to Q: How are you...
if (question.find(phrase1) != std::string::npos) {
env->SetObjectArrayElement(
retArray,
i,
env->NewStringUTF( generalAnswer.c_str() ));
} // answer to Q: temperature...
else if (question.find(phrase2) != std::string::npos) {
env->SetObjectArrayElement(
retArray, i,
env->NewStringUTF( tempAnswer.c_str() ));
} // answer to Q: Can you run...
else if (question.find(phrase3) != std::string::npos) {
if (temperature < 80.0) {
env->SetObjectArrayElement(
retArray, i,
env->NewStringUTF( coolAnswer.c_str() ));
} else {
env->SetObjectArrayElement(
retArray, i,
env->NewStringUTF( overheatAnswer.c_str() ));
}
}


// Release memory
env->ReleaseStringUTFChars(mString, convertedValue);
env->DeleteLocalRef(mString);
}

The strange thing in JNI, I have to use jint instead of int in the for…loop. And data IO starts with “env->”. Indeed, I can mix C variable and java variable; but their sizes are different, such as jint( 32 bits ) and int( 16 bits ). Also, I have manually release memory to avoid the data leak.

😋7. Second Test.

In Jni_instrument_test.kt, let’s add a new test case.

@Test
fun JniQnATest() {
//JNI test
val questions = arrayOf<String>(
"How are you, Bob?",
"What is your temperature?",
"Can you run face recognition?")
val answers = NativeCall().askQuestions(questions, 90F)
}
  1. Let’s check the object type of answers.
// check answers: shall not be NULL
assertNotNull(answers)

👌Run the JniQnATest.

Tests ran to completion.

2. Let’s check the size of the array. I input a mistake.

assertThat("Expected answers == 3.", 4 , equalTo(3))

Result:

java.lang.AssertionError: Expected answers == 3.
Expected: <3>
but: was <4>

😄Good. Let’s insert the real one.

assertThat("Expected answers == 3.", answers!!.size , equalTo(3))

Result:

Tests ran to completion.

3. Let’s test the data.

I’ll test the 1st string contained “thank you”.

// check 1st answer: contained "thank you"
val thx = "thank you"
assertThat("Expected answer has \"$thx\"",
answers[0],
containsString(thx))

Result:

java.lang.AssertionError: Expected answer has "thank you"
Expected: a string containing "thank you"
but: was "Cpu Bob: I'm fine. Thank you!"

😢Case sensitive. Let’s fix.

val thx = "Thank you"

Result:

Tests ran to completion.

👌, move on to the 2nd string.

val tempNum = "90.0"
assertThat("Expected answer has \"$tempNum\"",
answers[1],
containsString(tempNum))

Result:

Tests ran to completion.

😁, move on to the 3rd string.

val overheat = "overheat"
assertThat("Expected answer has \"$overheat\"",
answers[2],
containsString(overheat))

Result:

Tests ran to completion.

😅. That’s for today. Everything has passed.

--

--

Homan Huang
Homan Huang

Written by Homan Huang

Computer Science BS from SFSU. I studied and worked on Android system since 2017. If you are interesting in my past works, please go to my LinkedIn.

No responses yet