Un-Stringifying Android Development with Qt 6.4

The Java Native Interface (JNI) makes it possible to call Java from C++ and vice versa, and in Qt we have a pair of classes, QJniObject and QJniEnvironment, that provide convenient, Qt-friendly APIs. Until recently, I have had little opportunity to develop for Android, but porting Qt Speech to Qt 6 and the face-lift of its Android implementation gave me a chance to get some experience. I also wanted to experiment with some of the new capabilities we are planning to introduce after 6.4, which involved adding a few more callbacks and passing a few more parameters between C++ and Java.

Even with the convenient Qt classes, calling APIs via JNI requires signature strings. This makes it time-consuming and error-prone to develop for the Android platform. After spending more time than I should have on putting together the correct strings, I wondered whether we could make developing against the JNI easier using modern C++.

Let's start with the status quo.

Calling Java methods

When calling a Java method from C++, we have to specify the method, which might be a static class method or an instance method, by name. And since Java, like C++, supports method overloading, we also have to specify which overload we want to use through a signature string.

QJniObject string = QJniObject::fromString(QLatin1String("Hello, Java"));
QJniObject subString = string.callObjectMethod("substring", "(II)Ljava/lang/String;", 0, 4);

Here we create a Java string from a QString, and then call the substring method of the Java string class to get another string with the first 5 characters. The signature string informs the runtime that we want to call the overload that accepts two integers as parameters (that's the (II)) and that returns a reference to a Java object (the L prefix and ; suffix) of type String from the java/lang package. We then have to use the callObjectMethod from QJniObject because we want to get a QJniObject back, rather than a primitive type.

QJniObject string2 = QJniObject::fromString(QLatin1String("HELLO"));
jint ret = string.callMethod<jint>("compareToIgnoreCase", "(Ljava/lang/String;)I",
string2.object<jstring>());

Here we instantiate a second Java string, and then call the compareToIgnoreCase method on the first string, passing the second string as a parameter. The method returns a primitive jint, which is just a JNI typedef to int, so we don't have to use callObjectMethod but can use callMethod<jint> instead.

Evidently, calling Java functions from C++ requires that we pass information to the compiler multiple times: we already have the types of the parameters we want to pass, and the compiler knows those: 0 and 4 are two integers in the first example, and string2.object<jstring> is a jstring. Nevertheless we also need to encode this information into the signature string, either remembering or regularly looking up the correct string for dozens of different types. The longest signature string I found in our repos is 118 characters long:

(Ljava/lang/String;ILjava/lang/Object;Ljava/lang/Object;FFFLjava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;FF)V

And we need to remember to use callObjectMethod when we call a method that returns an object-type, while with callMethod we have to explicitly specify the return type of the method.

Native callbacks

C/C++ functions that are callable from Java must accept a JNIEnv * and a jclass (or jobject) as the first arguments, and any number of additional arguments of a JNI-compatible type, including a return type:

static jint nativeCallback(JNIEnv *env, jclass thiz, jstring value)
{
    // ...
    return 0;
}

JNI provides a type JNINativeMethod that needs to be populated with the name of the function as a string, the signature string, and the pointer to the free function as void *.

static const JNINativeMethod nativeMethods[] = {
    "nativeCallback", "(Ljava/lang/String;)I", reinterpret_cast<void *>(nativeCallback));
}

An array of such JNINativeMethod structures can then be registered with a Java class:

static void initOnce()
{
    QJniEnvironment env;
    env.registerNativeMethods("className", nativeMethods, std::size(nativeMethods));
}

That's a lot of duplicate information again. Not only do we have to provide a signature string (when the compiler already knows the parameter types of the nativeCallback function), we also have to pass the name of the function as a string when in almost all practical cases it will be the exact same as the name of the function itself. And we have to pass the size of the array, which the compiler evidently knows already as well.

Improvements with Qt 6.4

In the spirit of Don't Repeat Yourself, the goal for Qt 6.4 was to get rid of the explicit signature strings and of the explicit call to callObjectMethod when calling Java methods from C++.

QJniObject subString = string.callObjectMethod("substring", "(II)Ljava/lang/String;", 0, 4);

can now be

QJniObject subString = string.callMethod<jstring>("substring", 0, 4);

And we also wanted to get rid of the signature string and other duplicate information when registering native callbacks with the JVM. Here we have to use a macro to declare a free function as a native JNI method and to register a list of such methods with the JVM:

static jint nativeCallback(JNIEnv *env, jclass thiz, jstring value)
{
    // ...
    return 0;
}
Q_DECLARE_JNI_NATIVE_METHOD(nativeCallback)

static void initOnce()
{
    QJniEnvironment env;
    env.registerNativeMethods("className", {
        Q_JNI_NATIVE_METHOD(nativeCallback)
    });
}

Let's have a look at how this is implemented.

Argument deduction and compile-time if

We need to solve three problems: we need to deduce the types from the parameters passed into the callMethod function; we need to return a QJniObject instance that wraps the Java reference if the return type is a reference type; and we need to assemble a complete signature string from the individual type strings for each parameter.

The first two parts of this problem are solved for us by C++ 17:

template<typename Ret, typename ...Args>
auto callMethod(const char *methodName, Args &&...args) const
{
    const char *signature = "(?...?)?"; // TODO
    if constexpr (std::is_convertible<Ret, jobject>::value) {
        return callObjectMethod(methodName, signature, std::forward<Args>(args)...);
    } else {
        return callMethod<Ret>(methodName, signature, std::forward<Args>(args)...);
    }
}

We use compile-time if to call the old callObjectMethod if the return type is a type that converts to jobject; otherwise we use callMethod. And the template return type is now auto, so automatically deduced for us based on which branch gets compiled.

The last part of the problem remains. The types in the args parameter pack are automatically deduced at compile time as well based on the values we pass into it:

QJniObject subString = string.callMethod<jstring>("substring", 0, 4);

Args will be two integers, and Ret is explicitly specified as jstring. So the compiler has all the information it needs, just in the wrong form. Now we need to turn that list of types into a single signature string, and we need to do it at compile time.

Deducing the signature string at compile time

Before Qt 6.4, QJniObject already allowed us to omit the signature string in a few situations:

QJniObject obj("org/qtproject/qt/android/QtActivityDelegate");

QVERIFY(obj.isValid());
QVERIFY(!obj.getField<jboolean>("m_fullScreen"));

Here, we don't provide any signature string to access the m_fullScreen field of the QtActivityDelegate Java object. Instead, Qt will use a template function that maps the type of the field, jboolean in this case, to the signature string, which would be "Z".

template<typename T>
static constexpr const char* getTypeSignature()
{
    if constexpr(std::is_same<T, jobject>::value)
        return "Ljava/lang/Object;";
    // ...
    else if constexpr(std::is_same<T, int>::value)
        return "I";
    else if constexpr(std::is_same<T, bool>::value)
        return "Z";
    // ...
    else
        assertNoSuchType("No type signature known");
}

This happens at compile time: the compiler knows which type we instantiate QJniObject::getField with, and can use the getTypeSignature template function to generate a call of the JNI function with the correct const char *, "Z". But we can hardly extend this template, or specialize it, for any arbitrary combination of types. What we need is a string type that can hold a fixed-size character array, but also supports compile-time concatenation of multiple such strings, and compile-time access to the string data as a const char*.

template<size_t N_WITH_NULL>
struct String
{
    char m_data[N_WITH_NULL] = {};
    constexpr const char *data() const noexcept { return m_data; }
    static constexpr size_t size() noexcept { return N_WITH_NULL; }

To allow constructing such a type from a string literal we need to add a constructor that accepts a reference to a character array, and use a constexpr compliant method to copy the string:

    constexpr explicit String(const char (&data)[N_WITH_NULL]) noexcept
    {
        for (size_t i = 0; i < N_WITH_NULL - 1; ++i)
            m_data[i] = data[i];
    }

To concatenate two such strings and create a new String object that holds the characters from both, we need an operator+ implementation that takes two String objects with sizes N_WITH_NULL and N2_WITH_NULL and returns a new String with the size N_WITH_NULL + N2_WITH_NULL - 1:

    template<size_t N2_WITH_NULL>
    friend inline constexpr auto operator+(const String<N_WITH_NULL> &lhs,
                                           const String<N2_WITH_NULL> &rhs) noexcept
    {
        char data[N_WITH_NULL + N2_WITH_NULL - 1] = {};
        for (size_t i = 0; i < N_WITH_NULL - 1; ++i)
            data[i] = lhs[i];
        for (size_t i = 0; i < N2_WITH_NULL - 1; ++i)
            data[N_WITH_NULL - 1 + i] = rhs[i];
        return String<N_WITH_NULL + N2_WITH_NULL - 1>(data);
    }

And to make testing of our type possible we can add index-access and the usual set of comparison operators, such as:

    constexpr char at(size_t i) const { return m_data[i]; }

    template<size_t N2_WITH_NULL>
    friend inline constexpr bool operator==(const String<N_WITH_NULL> &lhs,
                                            const String<N2_WITH_NULL> &rhs) noexcept
    {
        if constexpr (N_WITH_NULL != N2_WITH_NULL) {
            return false;
        } else {
            for (size_t i = 0; i < N_WITH_NULL - 1; ++i) {
                if (lhs.at(i) != rhs.at(i))
                    return false;
            }
        }
        return true;
    }
};

with trivial overloads.

We can now test this type using compile-time assertion, which then also proves that we didn't introduce any runtime overhead:

constexpr auto signature = String("(") + String("I") + String("I") + String(")")
+ String("Ljava/lang/String;"); static_assert(signature == "(II)Ljava/lang/String;");

Now we have a string type that can be concatenated at compile time, and we can use it in the getTypeSignature template:

template<typename T>
static constexpr auto getTypeSignature()
{
    if constexpr(std::is_same<T, jobject>::value)
        return String("Ljava/lang/Object;");
    // ...
    else if constexpr(std::is_same<T, int>::value)
        return String("I");
    else if constexpr(std::is_same<T, bool>::value)
        return String("Z");
    // ...
    else
        assertNoSuchType("No type signature known");
}

Note that the return type of the getTypeSiganture template has changed from const char * to auto, as a String<2> holding a single character (plus null) is a different C++ type from String<19>.

We now need a method that generates a single signature string from a template parameter pack by concatenating all the strings for all the types. For this we can use a fold expression:

template<typename Ret, typename ...Args>
static constexpr auto getMethodSignature()
{
    return (String("(") +
                ... + getTypeSignature<std::decay_t<Args>>())
            + String(")")
            + typeSignature<Ret>();
}

With this helper, our new callMethod function becomes:

template<typename Ret, typename ...Args>
auto callMethod(const char *methodName, Args &&...args) const
{
    constexpr auto signature = getMethodSignature<Ret, Args...>();
    if constexpr (std::is_convertible<Ret, jobject>::value) {
        return callObjectMethod(methodName, signature.data(), std::forward<Args>(args)...);
    } else {
        return callMethod<Ret>(methodName, signature.data(), std::forward<Args>(args)...);
    }
}

We can now call Java methods without providing an explicit signature string:

QJniObject subString = string.callMethod<jstring>("substring", 0, 4);

To be able to use our getMethodSignature helper with the native callbacks that we want to register with the JVM we need to get compile-time access to the return type and parameter types of a free function:

template<typename Ret, typename ...Args>
static constexpr auto getNativeMethodSignature(Ret (*)(JNIEnv *, jclass, Args...))
{
    return getMethodSignature<Ret, Args...>();
}

With that helper it would now be tempting to set up a JNINativeMethod array like this:

static const JNINativeMethod nativeMethods[] = {
    "nativeCallback", getNativeMethodSignature(nativeCallback),
reinterpret_cast<void *>(nativeCallback)); }

However, while the signature string is created at compile time, the String object and the JNINativeMethod struct instances have a life time like every other object in C++. We need to keep the String object that holds the the native method signature string alive. And we also would still need the nativeCallback both as a void * and in its stringified version.

To get rid of the duplication and boiler plate involved in this we define a macro through which we can declare any matching function as a JNINativeMethod:

static jint nativeCallback(JNIEnv *env, jclass thiz, jstring value)
{
    // ...
    return 0;
}
Q_DECLARE_JNI_NATIVE_METHOD(nativeCallback)

This macro expands to definitions of signature and method objects in a dedicated namespace:

namespace QtJniMethods {
static constexpr auto nativeCallback_signature =
    QtJniTypes::nativeMethodSignature(nativeCallback);
static const JNINativeMethod nativeCallback_method = {
    "nativeCallback", nativeCallback_signature.data(),
reinterpret_cast<void *>(nativeCallback) }; }

Lastly, we can add a QJniEnvironment::registerNativeMethods overload that takes an initializer list, which we populate in-place with the help of a second macro that unwraps the data structures declared by Q_DECLARE_JNI_NATIVE_METHOD:

static void initOnce()
{
    QJniEnvironment env;
    env.registerNativeMethods("className", {
        Q_JNI_NATIVE_METHOD(nativeCallback)
    });
}

Representing more Java types in C++

We now have the pieces in place to simplify the interface between native C++ code and the JVM. However, we are limited to the types that are known to the type-mapping function template getTypeSignature. In Qt code, we often have to work with additional types, like a Java File or an Android Context. For JNI these are all passed around as jobjects and will be wrapped in a QJniObject, but we do need to specify the correct type in the signature strings. This is fortunate, because all we have to do now is to provide a specialization of the getTypeSignature template function for a C++ type that represents our Java type in the C++ code. This can be a simple tag type:

struct MyCustomJavaType {};
template<>
constexpr auto getTypeSignature<MyCustomJavaType>
{
    return String("Lmy/custom/java/type;");
}

This is again made easy with the help of a few macros:

Q_DECLARE_JNI_TYPE(Context, "Landroid/content/Context;")
Q_DECLARE_JNI_TYPE(File, "Ljava/io/File;")
Q_DECLARE_JNI_CLASS(QtTextToSpeech, "org/qtproject/qt/android/speech/QtTextToSpeech")

With all this in place, the Android implementation of the Qt TextToSpeech engine could be simplified quite a bit. Have a look at the complete change on gerrit.

Next steps

The new APIs in QJniObject and QJniEnvironment are available and documented from Qt 6.4 on. The enablers and the macros for extending this type system with custom types are declared in the qjnitypes.h header file, but are at the time of writing not fully documented. We will start rolling out the new APIs in Qt, and perhaps we will identify a number of Java types that we want to register centrally, and make further improvements to the templates and macros introduced here. So for the moment we are leaving some of the features introduced here as preliminary or internal APIs until we are confident that they are ready.

But if you are working on the Android port of Qt, or on Android Automotive, or if you need to use native Android APIs in your mobile application and don't want to bother with signature strings anymore, then we'd like to hear from you. Send us your feedback, and do let us know if you run into any issues with these new APIs in your projects.


Blog Topics:

Comments