Skip to content

libQuotient overview

Snehit Sah edited this page Jun 2, 2022 · 9 revisions

Introduction

The Quotient SDK strives to provide an efficient high-performance base for a wide range of Matrix clients, be they UI-less (bots) or with command-line or graphical user interface. Below is a brief description of how libQuotient - the centrepiece of the project - is generally organised and working.

The library has two decoupled layers:

  • lower-level transport layer - it's basically a wrapper around the client-server "RESTful" API (aka CS API). This layer consists of a ConnectionData class, Jobs, and Events and some smaller supplementary data structures. Jobs and supplementary data structures are (mostly) generated from the machine-readable API definition; as of this writing, standard Events are not generated from their respective definitions but that's in plans. ConnectionData is the carrier of all API requests: it enables network interaction and queues requests when the server advises to not shoot too fast. What's important here is that there's absolutely no processing of events within this layer - they are just marshalled/unmarshalled to/from JSON, and that's it.
  • higher-level application layer - this primarily consists of Connection, Room, and User objects, again with addition of some supplementary structures reflecting the higher-level abstractions in the spec. Things are more structured and convenient for application programming here: instead of ids and string references like sender you can deal with objects and application-level containers like a room with its timeline and transferred files, a user with the avatar, and so on. This layer is responsible for taking information from Events and making sense of them as much as it can (apply room state changes, record and make available messages, redact events upon receiving) as well as invoking Jobs where needed (sending message and state events, joining/leaving rooms etc.).

The following sections describe the foundational classes of the library in a bit more detail.

Library classes

(This should become Doxygen instead)

Connection is the starting point for any client implementation as of now. It connects and logs in to the server, holds the identity of the connected user, runs the sync loop, maintains the list of known rooms and users on a given homeserver. It's also responsible for construction and running the network requests as of now (read on for the possible evolution of that).

It's possible to create several Connection objects; they can connect to the same homeserver, and they can even be for the same user with different access tokens - this has been a prominent feature of Quotient-backed clients since very early days, unlike most other clients. But using two Connection objects with the same access token set for both, while possible, is calling for trouble: at the very least, request rate limiting will not work as intended.

ConnectionData is the actual carrier of the server connection and scheduler of Jobs (network requests) execution. As of this writing, Connection encapsulates ConnectionData and the rest of the library can only interact with the server through Connection. Eventually ConnectionData will likely become Connection, taking over all responsibilities related to CS API and being exposed to the rest of the library; and Connection will be renamed to something like Account with responsibilities focused at user account-wide operations.

Being on the lower level as described above, ConnectionData is not aware of any application-level entities, such as Rooms (that may slightly change as encryption in Matrix involves rooms quite heavily), Users and even Events (also might budge a bit once encryption is implemented) - unlike Connection that works as a factory for Room and User objects and feeds rooms with events intended for them. Application-level signals (like Connection::joinedRoom()) are emitted after Events have been taken from the transport layer (e.g., from SyncJob) into the application layer (Room) and processed there (Room::updateData()). Some of these signals are made specific for specific application-level objects (Room has quite a few signals on timeline changes) while others are broadcast on the Connection scope.

(There's no plan, and in general it's not a good idea, to consolidate all application-level signals under one mediation class because that means everybody should talk to this class with no apparent benefit received from most of its interface; this class would become a huge registry of signals (there'd likely be 100+ signals just to deal with API calls completion) and be a major architectural bottleneck affected with any CS API change. Basically, it would implement a well-known "God class" antipattern. Early on when the library has just been created, Connection usurped a part of application layer. As a result, we have Connection::newRoom(), Connection::joinedRoom() etc. in it while, ideally, there should be a RoomManager class that would have those signals - and Connection would do session handling and general dispatching instead.)

Note that to get anything useful on rooms or users you need to first wait until the first sync is entirely complete - use Connection::syncDone() for that. After that you can get, e.g., the list of rooms from Connection::rooms().

As the name says, this is the base for job classes that represent API requests to a Matrix homeserver (generally speaking, BaseJob does not have much Matrix specifics and can be used to abstract any HTTP requests with mostly but not only JSON payloads - but I digress). BaseJob provides a means to validate the network request before submitting it, to actually initiate the request towards the homeserver, to check out the status of the pending or completed request, and to load and process the results upon completion. It requires ConnectionData (see above) to execute the request, but not before that; you can create a job object and leave it lying around until the right moment, at which point you pass it to Connection::run() object in order to schedule it for execution. This is less typical though, and most of jobs are short-lived: clients or the library itself create and immediately submit job objects for execution using Connection::callApi template.

After completing the request, successfully or unsuccessfully, a job object emits several signals:

  • finished() - in any outcome
  • success() or failure() - specific to the outcome

success()/failure() are emitted after finished() - this may be important in a situation when both finished() and success()/failure() have connected signals. Changes to internal library structures in response to the job completion is usually done in finished() and is connected as early as possible; however, this may not be the case when different parts of the client code connect to the job completion in different ways.

The job object queues self-destruction after all connected slots have processed these signals. An important exception to that is a class of errors when retrying a request makes sense (mostly with a 5xx HTTP code, including rate limiting). In that case, retryScheduled() is emitted instead of all the signals mentioned in the previous paragraph and the same job object is reused for a new network request with the same data. This new request is performed after a certain cool-down period that is determined depending on the situation and is increased with each subsequent retry. The default number of retries is 3 except SyncJob that has no limit of retries.

At any moment in the job lifecycle you can call BaseJob::status() to check its status. if (job->status().good()) is an idiom to check if the job did not end up with an error (assuming the job object still exists - usually in the slot for BaseJobe::finished()). The message field in the status structure, or the BaseJob::errorString() shortcut will give you a human readable description of the error, if there's one.

A job can be abandoned if the client no more cares about its result. It is generally recommended to abandon no more needed jobs to cut on unneeded processing of the result. After calling BaseJob::abandon() the object closes its network request and briefly enters the respective Abandoned status so that clients interested in job housekeeping could clean up their records; then it self-destructs.

Some of job classes are written manually but most are generated from machine-readable API files provided in matrix-doc repo. The tool used for code generation is GTAD - see the respective section of CONTRIBUTING.md for more details.

(Since it doesn't seem to have been documented elsewhere within this repo - the reason for making a specialised tool instead of Swagger Codegen provided by Swagger/OpenAPI authors has been that, historically, Swagger Codegen produced terrible Qt5 code and the Java-based codebase was hefty and somewhat difficult to adapt to "extensions" that Matrix.org imposed on the API language, as well as to the whims of libQuotient lead developer on how network request classes should look like and what they should offer. GTAD is still far from being feature-complete even for Swagger 2; but the implemented Swagger subset is enough for it to serve the purposes of libQuotient, and GTAD has been used on a project for a few years now. Although Swagger Codegen considerably improved on the code emitted for a Qt5 client, GTAD remains - arguably - more flexible in terms of what code to generate, and is also much more lightweight.)

This is the base class for all Matrix event types, standard or custom, known or unknown to Quotient. Events are usually received from the server but clients can create some of them in order to place a request to the homeserver. In fact, there are three base classes, the next deriving from the previous: Event, RoomEvent and StateEventBase, and one template class, StateEvent<>, that derives from StateEventBase. Their functions match what you would expect from their names after reading the Client-Server API specification. StateEvent<> is a bit different from others in that it actually keeps the whole event content deserialised into a C++ object (its template parameter), while other base event types expect derived classes to provide individual converting accessors to each field in the JSON content object. This difference is mostly due to historical reasons; however, one corollary of it is that it's slightly easier (thanks to more rigid checking by the compiler) to correctly create specialisations of StateEvent<> that would be convertible both ways (to JSON and from JSON).

The system of events is extensible: clients can create new event types and, as long as they are defined correctly, the library will create and handle event objects for these new types by the same token as for the predefined types. Moreover, since Event is effectively a wrapper around QJsonObject, even if an event type is totally unknown the library will attempt to make the best object for it (Event, RoomEvent or StateEventBase), providing a means to read the event content as a raw JSON object, placing room events in the right room and keeping state room events in the room's state map.

As of this writing, the library does not have a way of visualisation for Events. In the meantime, maybe the most comprehensive example to borrow from is the code of Quaternion and especially the entire contents of MessageEventModel::data().

This class encapsulates both the state and the timeline of a room (as seen through a given Connection - see above) and provides operations to interact with it.

Timeline

There are a few touch points to handle receiving new events. You may again find the code of Quaternion's MessageEventModel to be a useful reference - this time its constructor, the part where all the signals are connected.

  • To merely update the view with new messages, use Room::addedMessages().
  • If you need to catch events before adding (that is necessary to call beginInsertRows() in Qt models, e.g.) there's Room::aboutToAddNewMessages() ("newest" edge of the timeline) and Room::aboutToAddHistoricalMessages() ("oldest" edge of the timeline). The library does not, in principle, add events in the middle of the timeline.
  • Events can be hidden, replaced (with editing), redacted or otherwise updated - watch for Room::updatedEvent() in such cases.
  • Things become more entangled when you start sending messages and want to track pending events - that's where all the Room::pendingEvent*() signals come useful.

This class represents a Matrix user (as seen through a given Connection - see above), maintains its display names and avatars. Connection::user() accessors return a somewhat special User object representing the user identified by the connection's access token (aka "local user").

Example of usage

TODO: turn it to a minimalistic Matrix client

#include <connection.h>
#include <room.h>
#include <QCoreApplication>

int main(int argc, char* argv[]) {
    if (argc < 4)
        return -1;

    QCoreApplication app(argc, argv);
    const auto* userMxid = argv[1];
    const auto* password = argv[2];
    const auto* deviceName = argv[3];

    using Quotient::Connection;

    // You can create as many connections (to as many servers) as you need.
    // When logging in via mxid and password the connection gets a new access token.
    // Connection::connectWithToken() allows to connect using an existing access token.
    auto* c = new Connection(&app);
    c->loginWithPassword(userMxid, password, deviceName); // The homeserver is resolved from the user name
    app.connect(c, &Connection::connected, c, [c] {
        qInfo() << "Connected, server: " << c->homeserver().toDisplayString();
        c->syncLoop();
    });
    app.connect(c, &Connection::resolveError, c, [&](const QString& error) {
        qInfo() << "Failed to resolve the server: " << error;
        app.exit(-2);
    });
    // connectSingleShot() is Qt's connect() that triggers exactly once and then disconnects
    Quotient::connectSingleShot(c, &Connection::syncDone, c, [c] {
        const auto& allRooms = c->allRooms();
        qInfo() << "Sync done;" << allRooms.count() << "room(s) and"
                << c->users().count() << "user(s) received";
        // Schedule logout after all rooms already queued for updating are done
        QMetaObject::invokeMethod(c, [c] {
            qInfo() << "That's all, thank you";
            c->logout();
        }, Qt::QueuedConnection);
    });
    QObject::connect(c, &Connection::loadedRoomState, c, [c](Quotient::Room* room) {
        qInfo() << "\nRoom display name:" << room->displayName()
                << "\nRoom topic:" << room->topic()
                << "\nJoined members:" << room->joinedCount() << "\n";
    });
    QObject::connect(c, &Connection::loggedOut, &app, &QCoreApplication::quit);

    return app.exec(); // Start event loop through which things actually happen
}