QtGrpc Chat | Qt GRPC (original) (raw)

A chat application to share messages of any kind in a chat room.

The Chat example demonstrates advanced usage of the QtGrpc client API. The server enables users to register and authenticate, allowing them to join the ChatRoom. Once joined, users can share various message types in the ChatRoom, such as text messages, images, user activity or any other files from their disk with all other participants.

Some key topics covered in this example are:

Protobuf Schema

The Protobuf schema defines the structure of messages and services used in the chat application. The schema is split into two files:

syntax = "proto3";

package chat;

import "chatmessages.proto";

service QtGrpcChat { // Register a user with \a Credentials. rpc Register(Credentials) returns (None); // Join as a registered user and exchange \a ChatMessage(s) rpc ChatRoom(stream ChatMessage) returns (stream ChatMessage) {} }

The qtgrpcchat.proto file specifies the QtGrpcChat service, which provides two RPC methods:

syntax = "proto3";

package chat;

import "QtCore/QtCore.proto";

message ChatMessage { string username = 1; int64 timestamp = 2; oneof content { TextMessage text = 3; FileMessage file = 4; UserStatus user_status = 5; } }

The chatmessages.proto file defines ChatMessage, which is a tagged union (also known as a sum type). It represents all the individual messages that can be sent through the ChatRoom streaming RPC. Every ChatMessage must include a username and timestamp to identify the sender.

We include the QtCore/QtCore.proto import to enable the types of the QtProtobufQtCoreTypes module, allowing seamless conversion between QtCore-specific types and their Protobuf equivalents.

message FileMessage { enum Type { UNKNOWN = 0; IMAGE = 1; AUDIO = 2; VIDEO = 3; TEXT = 4; } Type type = 1; string name = 2; bytes content = 3; uint64 size = 4;

message Continuation {
    uint64 index = 1;
    uint64 count = 2;
    QtCore.QUuid uuid = 3;
}
optional Continuation continuation = 5;

}

FileMessage is one of the supported message types for the ChatMessage sum type. It allows wrapping any local file into a message. The optional Continuation field ensures reliable delivery by handling large file transfers in chunks.

Note: For more details on using the ProtobufQtCoreTypes module in your Protobuf schema and application code, see Qt Core usage.

Server

Note: The server application described here uses the gRPC library.

The server application uses the asynchronous gRPC callback API. This allows us to benefit from the performance advantages of the async API without the complexity of manually managing completion queues.

class QtGrpcChatService final : public chat::QtGrpcChat::CallbackService

We declare the QtGrpcChatService class, which subclasses the CallbackService of the generated QtGrpcChat service.

grpc::ServerBidiReactor<chat::ChatMessage, chat::ChatMessage> *
ChatRoom(grpc::CallbackServerContext *context) override
{
    return new ChatRoomReactor(this, context);
}

grpc::ServerUnaryReactor *Register(grpc::CallbackServerContext *context,
                                   const chat::Credentials *request,
                                   chat::None * /*response*/) override

We override the virtual functions to implement the functionality for the two gRPC methods provided by the service:

The service implementation tracks all active clients that connect or disconnect through the ChatRoom method. This enables the broadcast functionality, which shares messages with all connected clients. To reduce storage and overhead, the ChatMessage is wrapped in a shared_ptr.

// Share \a response. It will be kept alive until the last write operation finishes.
void startSharedWrite(std::shared_ptr<chat::ChatMessage> response)
{
    std::scoped_lock lock(m_writeMtx);
    if (m_response) {
        m_responseQueue.emplace(std::move(response));
    } else {
        m_response = std::move(response);
        StartWrite(m_response.get());
    }
}

The startSharedWrite method is a member function of the ChatRoomReactor. If the reactor (i.e. the client) is currently writing, the message is buffered in a queue. Otherwise, a write operation is initiated. There is a single and unique message shared between all clients. Each copy of the response message increases the use_count. Once all clients have finished writing the message, and its use_count drops to 0 its resources are freed.

// Distribute the incoming message to all other clients.
m_service->broadcast(m_request, this);
m_request = std::make_shared<chat::ChatMessage>(); // detach
StartRead(m_request.get());

This snippet is part of the ChatRoomReactor::OnReadDone virtual method. Each time this method is called, a new message has been received from the client. The message is broadcast to all other clients, skipping the sender.

std::scoped_lock lock(m_writeMtx);

if (!m_responseQueue.empty()) {
    m_response = std::move(m_responseQueue.front());
    m_responseQueue.pop();
    StartWrite(m_response.get());
    return;
}

m_response.reset();

This snippet is part of the ChatRoomReactor::OnWriteDone virtual method. Each time this method is called, a message has been written to the client. If there are buffered messages in the queue, the next message is written. Otherwise, m_response is reset to signal that no write operation is in progress. A lock is used to protect against contention with the broadcast method.

Client

The client application uses the provided Protobuf schema to communicate with the server. It provides both front-end and back-end capabilities for registering users and handling the long-lived bidirectional stream of the ChatRoom gRPC method. This enables the visualization and communication of ChatMessages.

Setup

add_library(qtgrpc_chat_client_proto STATIC) qt_add_protobuf(qtgrpc_chat_client_proto QML QML_URI QtGrpcChat.Proto PROTO_FILES ../proto/chatmessages.proto PROTO_INCLUDES $<TARGET_PROPERTY:Qt6::ProtobufQtCoreTypes,QT_PROTO_INCLUDES> )

qt_add_grpc(qtgrpc_chat_client_proto CLIENT PROTO_FILES ../proto/qtgrpcchat.proto PROTO_INCLUDES $<TARGET_PROPERTY:Qt6::ProtobufQtCoreTypes,QT_PROTO_INCLUDES> )

First, we generate the source files from the Protobuf schema. Since the qtgrpcchat.proto file does not contain any message definitions, only qtgrpcgen generation is required. We also provide the PROTO_INCLUDES of the ProtobufQtCoreTypes module to ensure the "QtCore/QtCore.proto" import is valid.

target_link_libraries(qtgrpc_chat_client_proto PUBLIC Qt6::Protobuf Qt6::ProtobufQtCoreTypes Qt6::Grpc )

We ensure that the independent qtgrpc_chat_client_proto target is publicly linked against its dependencies, including the ProtobufQtCoreTypes module. The application target is then linked against this library.

Backend Logic

The backend of the application is built around four crucial elements:

The snippet above shows some of the Q_INVOKABLE functionality that is called from QML to interact with the server.

explicit ClientWorker([QObject](qobject.html) *parent = nullptr);
~ClientWorker() override;

public Q_SLOTS: void registerUser(const chat::Credentials &credentials); void login(const chat::Credentials &credentials); void logout(); void sendFile(const QUrl &url); void sendFiles(const QList<QUrl> &urls); void sendMessage(const chat::ChatMessage &message);

The slots provided by the ClientWorker somewhat mirror the API exposed by the ChatEngine. The ClientWorker operates in a dedicated thread to handle expensive operations, such as transmitting or receiving large files, in the background.

m_clientWorker->moveToThread(&m_clientThread); m_clientThread.start(); connect(&m_clientThread, &QThread::finished, m_clientWorker, &QObject::deleteLater); connect(m_clientWorker, &ClientWorker::registerFinished, this, &ChatEngine::registerFinished); connect(m_clientWorker, &ClientWorker::chatError, this, &ChatEngine::chatError); ...

In the ChatEngine constructor, we assign the ClientWorker to its dedicated worker thread and continue handling and forwarding its signals to make them available on the QML side.

void ChatEngine::registerUser(const chat::Credentials &credentials) { QMetaObject::invokeMethod(m_clientWorker, &ClientWorker::registerUser, credentials); } ... void ClientWorker::registerUser(const chat::Credentials &credentials) { if (credentials.name().isEmpty() || credentials.password().isEmpty()) { emit chatError(tr("Invalid credentials for registration")); return; }

if ((!m_client || m_hostUriDirty) && !initializeClient()) {
    emit chatError(tr("Failed registration: unabled to initialize client"));
    return;
}

auto reply = m_client->Register(credentials, [QGrpcCallOptions](qgrpccalloptions.html){}.setDeadlineTimeout(5s));
const auto *replyPtr = reply.get();
connect(
    replyPtr, &[QGrpcCallReply](qgrpccallreply.html)::finished, this,
    [this, reply = std::move(reply)](const [QGrpcStatus](qgrpcstatus.html) &status) {
        emit registerFinished(status);
    },
    [Qt](qt.html)::SingleShotConnection);

}

This demonstrates how the ChatEngine interacts with the ClientWorker to register users. Since the ClientWorker runs in its own thread, it is important to use invokeMethod to call its member functions safely.

In the ClientWorker, we check whether the client is uninitialized or if the host URI has changed. If either condition is met, we call initializeClient, which creates a new QGrpcHttp2Channel. Since this is an expensive operation, we minimize its occurrences.

To handle the Register RPC, we use the setDeadlineTimeout option to guard against server inactivity. It is generally recommended to set a deadline for unary RPCs.

void ClientWorker::login(const chat::Credentials &credentials) { if (credentials.name().isEmpty() || credentials.password().isEmpty()) { emit chatError(tr("Invalid credentials for login")); return; } ... QGrpcCallOptions opts; opts.setMetadata({ { "user-name", credentials.name().toUtf8() }, { "user-password", credentials.password().toUtf8() }, }); connectStream(opts); }

When logging into the ChatRoom, we use the setMetadata option to provide user credentials, as required by the server for authentication. The actual call and connection setup are handled in the connectStream method.

void ClientWorker::connectStream(const QGrpcCallOptions &opts) { ... m_chatStream = m_client->ChatRoom(*initialMessage, opts); ... connect(m_chatStream.get(), &QGrpcBidiStream::finished, this, [this, opts](const QGrpcStatus &status) { if (m_chatState == ChatState::Connected) { // If we're connected retry again in 250 ms, no matter the error. QTimer::singleShot(250, this, opts { connectStream(opts); }); } else { setState(ChatState::Disconnected); m_chatResponse = {}; m_userCredentials = {}; m_chatStream.reset(); emit chatStreamFinished(status); } }); ...

We implement basic reconnection logic in case the stream finishes abruptly while we are still connected. This is done by simply calling connectStream again with the QGrpcCallOptions from the initial call. This ensures that all required connections are also updated.

Note: Android’s Doze/App-Standby mode can be triggered, e.g., by using the FileDialog or switching to another app. This mode shuts down network access, closing all active QTcpSocket connections and causing the stream to be finished. We address this issue with the reconnection logic.

connect(m_chatStream.get(), &[QGrpcBidiStream](qgrpcbidistream.html)::messageReceived, this, [this] {
    ...
    switch (m_chatResponse.contentField()) {
    case chat::ChatMessage::ContentFields::UninitializedField:
        [qDebug](qtlogging.html#qDebug)("Received uninitialized message");
        return;
    case chat::ChatMessage::ContentFields::Text:
        if (m_chatResponse.text().content().isEmpty())
            return;
        break;
    case chat::ChatMessage::ContentFields::File:
        // Download any file messages and store the downloaded URL in the
        // content, allowing the model to reference it from there.
        m_chatResponse.file()
            .setContent(saveFileRequest(m_chatResponse.file()).toString().toUtf8());
        break;
    ...
    emit chatStreamMessageReceived(m_chatResponse);
});
setState(Backend::ChatState::Connecting);

}

When messages are received, the ClientWorker performs some pre-processing, such as saving the FileMessage content, so that the ChatEngine only needs to focus on the models. We use the ContentFields enum to safely check the oneof content field of our ChatMessage sum type.

void ChatEngine::sendText(const QString &message) { if (message.trimmed().isEmpty()) return;

if (auto request = m_clientWorker->createMessage()) {
    chat::TextMessage tmsg;
    tmsg.setContent(message.toUtf8());
    request->setText(std::move(tmsg));
    [QMetaObject](qmetaobject.html)::invokeMethod(m_clientWorker, &ClientWorker::sendMessage, *request);
    m_chatMessageModel->appendMessage(*request);
}

} ... void ClientWorker::sendMessage(const chat::ChatMessage &message) { if (!m_chatStream || m_chatState != ChatState::Connected) { emit chatError(tr("Unable to send message")); return; } m_chatStream->writeMessage(message); }

When sending messages, the ChatEngine creates properly formatted requests. For example, the sendText method accepts a QString and uses the createMessage function to generate a valid message with the username and timestamp fields set. The client is then invoked to send the message, and a copy is enqueued into our own ChatMessageModel.

QML Frontend

import QtGrpc import QtGrpcChat import QtGrpcChat.Proto

The following imports are used in the QML code:

In Main.qml, we handle core signals emitted by the ChatEngine. Most of these signals are handled globally and are visualized in any state of the application.

Rectangle { id: root

property credentials creds
...
[ColumnLayout](qml-qtquick-layouts-columnlayout.html) {
    id: credentialsItem
    ...
    [RowLayout](qml-qtquick-layouts-rowlayout.html) {
        id: buttonLayout
        ...
        [Button](qml-qtquick-controls-button.html) {
            id: loginButton
            ...
            enabled: nameField.text && passwordField.text
            text: qsTr("Login")
            onPressed: {
                root.creds.name = nameField.text
                root.creds.password = passwordField.text
                ChatEngine.login(root.creds)
            }
        }

The generated message types from the protobuf schema are accessible in QML as they're QML_VALUE_TYPEs (a camelCase version of the message definition). The LoginView.qml uses the credentials value type property to initiate the login on the ChatEngine.

    [ListView](qml-qtquick-listview.html) {
        id: chatMessageView
        ...
        component DelegateBase: [Item](qml-qtquick-item.html) {
            id: base

            required property chatMessage display
            default property alias data: chatLayout.data
            ...
        }
        ...
        // We use the DelegateChooser and the 'whatThis' role to determine
        // the correct delegate for any ChatMessage
        delegate: DelegateChooser {
            role: "whatsThis"
            ...
            DelegateChoice {
                roleValue: "text"
                delegate: DelegateBase {
                    id: dbt
                    TextDelegate {
                        Layout.fillWidth: true
                        Layout.maximumWidth: root.maxMessageBoxWidth
                        Layout.preferredHeight: implicitHeight
                        Layout.bottomMargin: root.margin
                        Layout.leftMargin: root.margin
                        Layout.rightMargin: root.margin

                        message: dbt.display.text
                        selectionColor: dbt.lightColor
                        selectedTextColor: dbt.darkColor
                    }
                }
            }

In ChatView.qml, the ListView displays messages in the ChatRoom. This is slightly more complex, as we need to handle the ChatMessage sum type conditionally.

To handle this, we use a DelegateChooser, which allows us to select the appropriate delegate based on the type of message. We use the default whatThis role in the model, which provides the message type for each ChatMessage instance. The DelegateBase component then accesses the display role of the model, making the chatMessage data available for rendering.

TextEdit { id: root

required property textMessage message

text: message.content
color: "#f3f3f3"
font.pointSize: 14
wrapMode: TextEdit.Wrap
readOnly: true
selectByMouse: true

}

Here is one of the components that visualizes the TextMessage type. It uses the textMessage value type from the protobuf module to visualize the text.

            TextArea.flickable: TextArea {
                id: inputField

                function sendTextMessage() : void {
                    if (text === "")
                        return
                    ChatEngine.sendText(text)
                    text = ""
                }
                ...
                Keys.onPressed: (event) => {
                    if (event.key === Qt.Key_Return && event.modifiers & Qt.ControlModifier) {
                        sendTextMessage()
                        event.accepted = true
                    } else if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
                        if (ChatEngine.sendFilesFromClipboard())
                            event.accepted = true
                    }
                }

The Chat client provides various access points in sending messages like:

SSL

To secure communication between the server and clients, SSL/TLS encryption is used. This requires the following at a minimum:

We used OpenSSL to create these files and set up our gRPC communication to use SSL/TLS.

    grpc::SslServerCredentialsOptions sslOpts;
    sslOpts.pem_key_cert_pairs.emplace_back(grpc::SslServerCredentialsOptions::PemKeyCertPair{
        LocalhostKey,
        LocalhostCert,
    });
    builder.AddListeningPort(QtGrpcChatService::httpsAddress(), grpc::SslServerCredentials(sslOpts));
    builder.AddListeningPort(QtGrpcChatService::httpAddress(), grpc::InsecureServerCredentials());

We provide the Private Key and Certificate to the gRPC server. With that, we construct the SslServerCredentials to enable TLS on the server-side. In addition to secure communication, we also allow unencrypted access.

The server listens on the following addresses:

The server binds to 0.0.0.0 to listen on all network interfaces, allowing access from any device on the same network.

if (m_hostUri.scheme() == "https") { if (QSslSocket::supportsSsl()) { emit chatError(tr("The device doesn't support SSL. Please use the 'http' scheme.")); return false; } QFile crtFile(":/res/root.crt"); if (!crtFile.open(QFile::ReadOnly)) { qFatal("Unable to load root certificate"); return false; }

[QSslConfiguration](qsslconfiguration.html) sslConfig;
[QSslCertificate](qsslcertificate.html) crt(crtFile.readAll());
sslConfig.addCaCertificate(crt);
sslConfig.setProtocol([QSsl](qssl.html)::TlsV1_2OrLater);
sslConfig.setAllowedNextProtocols({ "h2" }); // Allow HTTP/2

// Disable hostname verification to allow connections from any local IP.
// Acceptable for development but avoid in production for security.
sslConfig.setPeerVerifyMode([QSslSocket](qsslsocket.html)::VerifyNone);
opts.setSslConfiguration(sslConfig);

}

The client loads the Root CA Certificate, as we self-signed the CA. This certificate is used to create the QSslCertificate. It is important to provide the "h2" protocol with setAllowedNextProtocols, as we are using HTTP/2.

Running the example

To run the example from Qt Creator, open the Welcome mode and select the example from Examples. For more information, see Qt Creator: Tutorial: Build and run.

Example project @ code.qt.io