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
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::CoreASqlQt5::Core# link to ASqlASqlQt5::Pg# link to ASql Postgres driverQt5::CoreQt5::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 upCREATETABLE users ( id serial PRIMARY KEY, nick text NOTNULLUNIQUE,data jsonb
);-- 1 downDROPTABLE users;-- 2 upCREATETABLE messages ( id serial PRIMARY KEY, created_at timestampwithtimezoneDEFAULTnow(), user_id integer NOTNULL REFERENCES users(id), msg text NOTNULL);-- 2 downDROPTABLE message;-- 3 upCREATEOR 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;RETURNNEW;END;$$ LANGUAGE plpgsql;CREATE TRIGGER messages_notify
AFTER INSERTON messages
FOR EACH ROWEXECUTEPROCEDURE messages_notify();-- 3 downDROPFUNCTION 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:
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:
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:
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>voidRoot::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:
voidRoot::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.
voidRoot::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:
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.
voidRoot::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:
boolpostFork(Application *app)override;
Notice the controller’s postFork() take an application pointer, then add the following code on root.cpp:
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:
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:
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:parentanchors.margins:10Label{text:"Server:Port"}TextField{Layout.fillWidth:trueid:serverFtext:settings.server}Label{text:"Nick"}TextField{Layout.fillWidth:trueid:nickFtext:settings.nick}Label{text:"Full Name"}TextField{Layout.fillWidth:trueid:fullnameFtext: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:
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:
To watch this video on our website please or view it directly on YouTube
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!
The KDAB Group is a globally recognized provider for software consulting, development and training, specializing in embedded devices and complex cross-platform desktop applications. In addition to being leading experts in Qt, C++ and 3D technologies for over two decades, KDAB provides deep expertise across the stack, including Linux, Rust and modern UI frameworks. With 100+ employees from 20 countries and offices in Sweden, Germany, USA, France and UK, we serve clients around the world.
Our hands-on Modern C++ training courses are designed to quickly familiarize newcomers with the language. They also update professional C++ developers on the latest changes in the language and standard library introduced in recent C++ editions.