Qt Allstack I – Setup Creating a Realtime Mobile Chat App
Writing mobile apps can be a lot of fun, especially with Qt and QML. But if your background is all about C++ and Qt, writing the entire required stack for a mobile app can be challenging. Adding push notifications, storing information, and having to write that with another language and framework might make you give up. But we got you covered!
This is going to be a series of tutorials on how to build a simple mobile chat application with push notifications. On the server side, it will will have a REST API powered by Cutelyst, featuring a database with async communication for storing conversations. Additionally, it will communicate with Firebase to send the notifications.
As you might have already noticed, we are using Qt everywhere (hence, Qt Allstack).
The client application will try to register a nickname. Then, it will connect to a global chat room. For the purpose of simplicity, it’s going to be a single chat room. Once someone sends a message with @some_nick within the string, the push notification will go only to that user app. Thanks to PostgreSQL IPC notifications, we get it in real time.
Setup
I’ll list here what I’m using on this project, but you can also adjust it to your OS or distro:
- Kubuntu 21.04
- PostgreSQL 13.3
- Qt 5.15 from the distro for the backend and Qt 5.15 from online installer for the mobile app to have Qt libraries for Android
- Cutelyst 3.1
- ASql 0.50
- CMake 3.18
Prebuilt packages for Cutelyst and ASql are available for download.
Backend
We will start by creating the backend:
cutelyst3-qt5 --create-app KDChatAppBack
This creates a bare minimum project to start with. Let’s compile it to see if it runs:
cd KDChatAppBack/build && cmake .. && make
If everything compiled correctly, we can run it with:
cutelyst3-qt5 --server --restart --app-file src/libKDChatAppBack
The –sever option starts a Cutelyst server listening by default on all addresses at port 3000. –restart will keep an eye on the Cutelyst application and restart the server when it changes, and –app-file specifies where our application file is located.
Open QtCreator and “Open New Project”, selecting the CMakeLists.txt file. Then, on the “Configure” step, choose the “build” directory in which we compiled the application. This way, QtCreator can compile in the same directory.
Now let’s add ASql dependency. In ChatAppBack/CMakeLists.txt, we add after find_package:
find_package(ASqlQt5 0.43 REQUIRED)
Then on src/CMakeLists.txt, make sure our target links to ASql as well:
target_link_libraries(ChatAppBack
Cutelyst::Core
ASqlQt5::Core # link to ASql
ASqlQt5::Pg # link to ASql Postgres driver
Qt5::Core
Qt5::Network
)
We are going to use the PostgreSQL database, as it’s very easy to get started, has great JSON support and, most importantly, it has IPC built-in — a critical feature for a real-time chat that even commercial players lack.
In order to have the nicest development environment, it’s recommended to create a database user with the same name as your machine login name, and grant it rights to create new databases. That can be done with this command:
sudo -u postgres createuser --createdb $USER
We can now create and manipulate databases without needing a password or changing configuration files. Just issue the following command as your regular user and we will have a new database to use:
createdb chat
ASql comes with a handy tool called migrations; we will use it to have our database versioned. On QtCreator, click to create a New File → Template “General” → “Empty File”. Name the new file db.sql at ChatAppBack/root, with the following content:
-- 1 up
CREATE TABLE users (
id serial PRIMARY KEY,
nick text NOT NULL UNIQUE,
data jsonb
);
-- 1 down
DROP TABLE users;
-- 2 up
CREATE TABLE messages (
id serial PRIMARY KEY,
created_at timestamp with time zone DEFAULT now(),
user_id integer NOT NULL REFERENCES users(id),
msg text NOT NULL
);
-- 2 down
DROP TABLE message;
-- 3 up
CREATE OR REPLACE FUNCTION messages_notify()
RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('new_message',
json_build_object('id', NEW.id,
'msg', NEW.msg,
'nick', nick,
'created_at', NEW.created_at)::text)
FROM users
WHERE id = NEW.user_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER messages_notify
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE PROCEDURE messages_notify();
-- 3 down
DROP FUNCTION messages_notify();
As you can see, there are 6 scripts. When version goes from 0 to 3, all up scripts are executed. If you want to rollback a migration, it uses the down scripts.
The scripts create a users table with a unique nick and some extra JSON metadata, then a messages table with each message, the user_id that sent the message, and the time the message was created. Script 3 creates a trigger that will notify all front-end servers (this way we can scale our simple app horizontally) that a new message is available.
ASql migrations can be used from the command line or by writing C++ code that makes use of its class. It’s easier to just issue it:
asql-migration0-qt5 --connection postgres:///chat --name chat root/db.sql
This will create a table called asql_migrations and track the version under the name “chat”. You can take a look at your tables with:
psql chat
chat=> \d users
Now let’s configure our backend to create a pool of database connections. We will override to postFork(). This method is called for each thread or process that is created. An ASql pool has thread affinity with the thread that created the pool, add in chatapp.h:
bool postFork() override;
Now add this implementation on the chatapp.cpp:
#include <apool.h>
#include <apg.h>
bool ChatAppBack::postFork()
{
APool::create(APg::factory("postgres:///chat"));
return true;
}
Compile and make sure the application restarts properly.
REST API
The REST API will be responsible for creating a new user and posting new messages. We won’t worry about authentication or nice URL APIs. Adding a JWT and properly naming methods is left as an exercise to the reader. So in root.h, we will add:
C_ATTR(users, :Local :AutoArgs :ActionClass(REST))
void users(Context *c) {};
C_ATTR(users_POST, :Private)
void users_POST(Context *c);
C_ATTR(users_PUT, :Private)
void users_PUT(Context *c);
C_ATTR(messages, :Local :AutoArgs :ActionClass(REST))
void messages(Context *c) {};
C_ATTR(messages_POST, :Private)
void messages_POST(Context *c);
The first :Local method will create a /users URL end point, since there are no more arguments after Context*. :AutoArgs will know the URL can’t have additional arguments. The special action class, REST, will take care of issuing the methods. It will look for users_METHOD. Once a new HTTP request arrives, it will know to which method to call,
In our case, we are only accepting POST and PUT methods. So if you try to GET/DELETE, it will fail but it will respond with the proper reply if the OPTIONS method is requested. The same applies to messages end point (ie /messages accepting POSTs only).
Now that Cutelyst knows how to route HTTP requests, it’s time to write real code in root.cpp:
#include <apool.h>
#include <aresult.h>
#include <QDebug>
#include <QJsonObject>
void Root::users_POST(Context *c)
{
const QJsonObject data = c->request()->bodyJsonObject();
ASync a(c);
APool::database().exec(u"INSERT INTO users (nick, data) VALUES ($1, $2) RETURNING id",
{
data["nick"],
data,
}, [a, c] (AResult &result) {
auto firstRow = result.begin();
if (!result.error() && firstRow != result.end()) {
// RETURN the new user ID
c->res()->setJsonObjectBody({
{"id", firstRow[0].toInt()},
});
} else {
qWarning() << "Failed to create user" << result.errorString();
c->res()->setStatus(Response::InternalServerError);
c->res()->setJsonObjectBody({
{"error_msg", "failed to create user"},
});
}
}, c);
}
When creating a user, we first get the JSON sent by the client. Then, we create a scoped ASync object that must be explicitly captured by the lambda. It’s responsible for telling Cutelyst that it should not send a reply for the client immediately. It will do so when the last ASync object goes out of scope.
Then, a ADatabase object is retrieved from pool, which we’ll then call exec(). Notice that we pass a QJsonObject to the database and ASql properly handles it. After the user is created, we get an ID and will use it on our app to send messages. That’s not safe! I know:
void Root::users_PUT(Context *c)
{
const QJsonObject data = c->request()->bodyJsonObject();
ASync a(c);
APool::database().exec(u"UPDATE users SET nick=$1, data=$2 WHERE id=$3",
{
data["nick"],
data,
data["user_id"],
}, [a, c, data] (AResult &result) {
if (!result.error() && result.numRowsAffected()) {
c->res()->setJsonObjectBody({
{"id", data["user_id"]},
});
} else {
qWarning() << "Failed to create user" << result.errorString();
c->res()->setStatus(Response::InternalServerError);
c->res()->setJsonObjectBody({
{"error_msg", "failed to create user"},
});
}
}, c);
}
The PUT method is about updating our already-created user. Later, we can use this to send the Firebase token that the server will need to send push notifications.
void Root::messages_POST(Context *c)
{
const QJsonObject data = c->request()->bodyJsonObject();
const QString msg = data["msg"].toString();
ASync a(c);
APool::database().exec(u"INSERT INTO messages (user_id, msg) VALUES ($1, $2) RETURNING id",
{
data["user_id"],
msg,
}, [a, c, msg] (AResult &result) {
auto firstRow = result.begin();
if (!result.error() && firstRow != result.end()) {
// RETURN the new message ID
c->res()->setJsonObjectBody({
{"id", firstRow[0].toInt()},
});
} else {
qWarning() << "Failed to create message" << result.errorString();
c->res()->setStatus(Response::InternalServerError);
c->res()->setJsonObjectBody({
{"error_msg", "failed to create message"},
});
}
}, c);
}
The message’s POST method is very similar, except we select which values we want from the JSON object. For a careful observer, it’s passing a QJsonValue and not an int or QString. This method also returns the new message ID. This way, our client can ignore its own message when it gets notified.
Websockets
An important part of any chat application is working in real-time. We can do this with WebSockets. The client app will stay connected to receive new messages. As soon as the database says there is a new one, we send it to the connected clients. In root.h, we add:
C_ATTR(websocket, :Path('ws') :AutoArgs)
void websocket(Context *c, const QString &user_id);
QHash<int, Context *> m_wsClients;
The :Path(‘ws’) tells Cutelyst to call this method when the URL is /ws, even though the method name is websocket. :AutoArgs now sees the QString argument. So, the end point is /ws/<user_id>. Finally, QHash will keep a pointer to the Context objects, using their user_id as key.
void Root::websocket(Context *c, const QString &user_id)
{
if (!c->response()->webSocketHandshake()) {
c->response()->webSocketClose(Response::CloseCodeNormal, QStringLiteral("internal-server-error"));
return;
}
if (m_wsClients.contains(user_id.toInt())) {
c->response()->webSocketClose(Response::CloseCodeNormal, QStringLiteral("already-logged-in"));
return;
}
m_wsClients.insert(user_id.toInt(), c);
connect(c, &Context::destroyed, this, [=] {
m_wsClients.remove(user_id.toInt());
});
APool::database().exec(uR"V0G0N(
SELECT m.id, m.created_at, u.nick, m.msg
FROM messages m
INNER JOIN users u ON m.user_id=u.id
ORDER BY 2 DESC
)V0G0N",
[c] (AResult &result) {
if (result.error()) {
c->response()->webSocketClose(Response::CloseCodeNormal, QStringLiteral("error-getting-msgs"));
} else {
c->response()->webSocketTextMessage(QJsonDocument(result.jsonArray()).toJson());
}
}, c);
}
Our new method needs to change the protocol HTTP → WebSocket. Then, it checks if the user is connected and drops the connection when logged in. It also removes the client connection when the client goes away. Last, it gets all server messages. Notice that the ASync class is not needed here. The WebSocket protocol is async by definition. So, now you need to work on its signals.
Another important point here is that the last parameter of ADatabase::exec() is a QObject pointer. This allows for cancellation of the query when the object gets destroyed, as well as not calling this lambda at all, which would crash if called with an invalid Context pointer.
Postgres Notifications
Now we need to listen for database notifications, which should sound when a new message is added. For this, we will need a dedicated DB connection for monitoring for states changes, so our clients don’t miss a notification due a broken connection with the database. On root.h, we will add a postFork() override. We won’t use the other one, as we need access to the QHash containing our connected clients. So, it’s easier to add it on root.h:
bool postFork(Application *app) override;
Notice the controller’s postFork() take an application pointer, then add the following code on root.cpp:
bool Root::postFork(Application *app)
{
auto db = APool::database();
auto subscribe = [=] () mutable {
db.subscribeToNotification("new_message", [=] (const ADatabaseNotification ¬ification) {
for (const auto &ws : qAsConst(m_wsClients)) {
ws->response()->webSocketTextMessage(notification.payload.toString());
}
}, this);
};
db.onStateChanged([=] (ADatabase::State state, const QString &msg) mutable {
if (state == ADatabase::State::Disconnected) {
qCritical() << "DB connection closed, disconnecting clients";
for (const auto &ws : qAsConst(m_wsClients)) {
ws->response()->webSocketClose(Response::CloseCodeNormal, "db-disconnected");
}
} else if (state == ADatabase::State::Connected) {
subscribe();
}
});
return true;
}
Here, we create a named lambda to subscribe to “new_message” notification. We do this in a lambda because, if the connection is closed, the subscription will be lost. So, we’d need to subscribe again. Once we get a new message, we send it to all of our connected clients. If the database connections is lost, we close the connection with all our clients. Notice that we don’t release their pointers from m_wsClients because that will happen with the code added on the WebSocket connection.
The backend code now only misses sending a notification with Firebase, which will be added later.
Client App
The client app now needs to talk to our backend server. It’s going to be a simple stack application with material design. When it’s done, we will be adding support for push notifications. The code that I currently have working with this was created on top of QMake. So to avoid issues, we won’t be using CMake for the mobile app.
Create a new project in Qt Creator. I always open a new Qt Creator, as switching between projects is slower than switching windows. Choose “Application (Qt Quick)” → “Qt Quick Application – Empty”, then name it “ChatApp” and choose qmake build system.
Since we chose the empty template, we need to manually define the Qt Quick Controls 2 theme. To do so, right click on the qml.rc resource and choose “Add new…” → “General” → “Empty file”, then name it qtquickcontrols2.conf and add the following content:
[Controls]
Style=Material
We also need to set the application and organization values, so we can store settings. Add the following to main.cpp:
QCoreApplication::setOrganizationDomain("com.kdab");
QCoreApplication::setOrganizationName("kdab");
QCoreApplication::setApplicationName("ChatApp");
The client app will have only 3 QML files, 2 of which you can now create with Qt Creator as Qt → QML File named PageUser.qml and PageMessages.qml. The third one will be main.qml, which already exists.
We’ll start with the main.qml code, which will be like this:
import QtQuick 2.12
import QtQuick.Controls 2.5
import Qt.labs.settings 1.0
ApplicationWindow {
id: window
width: 640
height: 480
visible: true
title: qsTr("ChatApp")
header: ToolBar {
contentHeight: toolButton.implicitHeight
ToolButton {
id: toolButton
visible: settings.user_id !== 0
text: stackView.depth > 1 ? "\u25C0" : "\u2630"
font.pixelSize: Qt.application.font.pixelSize * 1.6
onClicked: {
if (stackView.depth > 1) {
stackView.pop()
} else {
drawer.open()
}
}
}
Label {
text: stackView.currentItem.title
anchors.centerIn: parent
}
}
Settings {
id: settings
property int user_id: 0
property string server
property string nick
property string fullname
}
Drawer {
id: drawer
width: window.width * 0.66
height: window.height
Column {
anchors.fill: parent
ItemDelegate {
text: qsTr("Edit User")
width: parent.width
enabled: settings.user_id !== 0
onClicked: {
stackView.push("PageUser.qml")
drawer.close()
}
}
}
}
StackView {
id: stackView
initialItem: "PageMessages.qml"
anchors.fill: parent
}
Component.onCompleted: {
if (settings.user_id === 0) {
stackView.push("PageUser.qml")
}
}
}
Here we have a Settings object to hold our registered user_id, which defaults to zero when not registered, our server, nick, and fullname. When the app starts, if not registered, it shows the PageUser.qml for registration. Once registered, we will see PageMessages.
For PageUser, we will have:
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import com.kdab 1.0
Page {
title: settings.user_id === 0 ? "Create User" : "Update User"
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
Label {
text: "Server:Port"
}
TextField {
Layout.fillWidth: true
id: serverF
text: settings.server
}
Label {
text: "Nick"
}
TextField {
Layout.fillWidth: true
id: nickF
text: settings.nick
}
Label {
text: "Full Name"
}
TextField {
Layout.fillWidth: true
id: fullnameF
text: settings.fullname
}
Button {
text: settings.user_id === 0 ? "Create" : "Update"
onClicked: {
var nick = nickF.text
var fullname = fullnameF.text
var server = serverF.text
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
var json = JSON.parse(xhr.responseText)
settings.user_id = json.id
settings.nick = nick
settings.fullname = fullname
settings.server = server
stackView.pop()
} else {
console.error("Error creating/updating user: ", xhr.statusText)
}
}
}
xhr.open(settings.user_id === 0 ? "POST" : "PUT", "http://" + server + "/users");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({
user_id: settings.user_id,
nick: nick,
fullname: fullname
}));
}
}
Item {
Layout.fillHeight: true
}
}
}
The important part is the Button::clicked(). It will create a XMLHttpRequest that will POST or PUT, depending on whether we have a user_id. Set the proper Content-Type and send the JSON object with our data. The XMLHttpRequest.onreadystatechange callback will store the returned ID on our Settings object, as well as both nick and fullname.
The server string is also very important, as when testing on the mobile phone you will want to set it to your machine IP:Port instead of localhost:3000.
Now that we have a user_id, we are allowed to leave the PageUser and finally see the chat room. PageMessages.qml will feature the following code:
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import QtWebSockets 1.1
import QtQuick.Controls.Material 2.12
import QtQml 2.12
Page {
title: "Messages"
ListModel {
id: messages
}
WebSocket {
id: ws
url: "ws://" + settings.server + "/ws/" + settings.user_id
onTextMessageReceived: {
var json = JSON.parse(message)
if (Array.isArray(json)) {
json.forEach(element => messages.append(element))
} else {
messages.insert(0, json)
}
}
}
Timer {
interval: 5000
triggeredOnStart: true
running: settings.user_id !== 0 && ws.status !== WebSocket.Open
onTriggered: {
ws.active = false
ws.active = true
}
}
ColumnLayout {
anchors.fill: parent
ScrollView {
Layout.fillHeight: true
Layout.fillWidth: true
ListView {
width: parent.width
model: messages
verticalLayoutDirection: ListView.BottomToTop
spacing: 5
delegate: RowLayout {
width: ListView.view.width
spacing: 0
Item {
width: 5
}
Pane {
Layout.maximumWidth: parent.width - 10
Material.elevation: 6
Label {
anchors.fill: parent
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
text: "<b>" + nick + "</b> - " + new Date(created_at).toLocaleString(locale, Locale.ShortFormat) + "<br>" + msg
}
}
}
}
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
}
ToolBar {
Layout.fillWidth: true
enabled: ws.status === WebSocket.Open
RowLayout {
anchors.leftMargin: 10
anchors.fill: parent
TextField {
Layout.fillWidth: true
id: messageF
onAccepted: postButton.clicked()
}
ToolButton {
id: postButton
enabled: messageF.enabled
text: ">"
onClicked: {
messageF.enabled = false
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
messageF.enabled = true
if (xhr.status === 200) {
messageF.clear()
} else {
console.error("Error posting message: ", xhr.statusText)
}
}
}
xhr.open("POST", "http://" + settings.server + "
/messages");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({
user_id: settings.user_id,
msg: messageF.text
}));
}
}
}
}
}
}
Here we have a WebSocket object. It’ll will try to connect to the end point for WebSockets that we created. Once connected, the TextField will be enabled to send messages. From the WebSocket object, we only care about receiving messages. It could also be used to send messages, but we are sending them with plain HTTP so that you can see that the messages are being pushed by the server itself. The TextField is disabled while sending the message, to avoid sending duplicated messages.
Here is a demonstration video for this blog, in case it would help you to see the steps above being done:
The full code for the snippets shown in this blog can be found in GitHub.
Part two of this blog series walks you through adding Firebase. That’s another goodie that’s in store for you!
If you like this article and want to read similar material, consider subscribing via our RSS feed.
Subscribe to KDAB TV for similar informative short video content.
KDAB provides market leading software consulting and development services and training in Qt, C++ and 3D/OpenGL. Contact us.