1// SPDX-FileCopyrightText: 2016 Kitsune Ral <Kitsune-Ral@users.sf.net>
2// SPDX-FileCopyrightText: 2017 Roman Plášil <me@rplasil.name>
3// SPDX-FileCopyrightText: 2019 Ville Ranki <ville.ranki@iki.fi>
4// SPDX-FileCopyrightText: 2019 Alexey Andreyev <aa13q@ya.ru>
5// SPDX-License-Identifier: LGPL-2.1-or-later
6
7#include "connection.h"
8
9#include "logging_categories_p.h"
10
11#include "connection_p.h"
12#include "connectiondata.h"
13#include "qt_connection_util.h"
14#include "room.h"
15#include "settings.h"
16#include "user.h"
17
18// NB: since Qt 6, moc_connection.cpp needs Room and User fully defined
19#include "moc_connection.cpp" // NOLINT(bugprone-suspicious-include)
20
21#include "csapi/account-data.h"
22#include "csapi/capabilities.h"
23#include "csapi/joining.h"
24#include "csapi/leaving.h"
25#include "csapi/logout.h"
26#include "csapi/room_send.h"
27#include "csapi/to_device.h"
28#include "csapi/voip.h"
29#include "csapi/wellknown.h"
30#include "csapi/whoami.h"
31
32#include "events/directchatevent.h"
33#include "jobs/downloadfilejob.h"
34#include "jobs/mediathumbnailjob.h"
35#include "jobs/syncjob.h"
36
37#ifdef Quotient_E2EE_ENABLED
38# include "connectionencryptiondata_p.h"
39# include "database.h"
40
41# include "e2ee/qolminboundsession.h"
42#endif // Quotient_E2EE_ENABLED
43
44#if QT_VERSION_MAJOR >= 6
45# include <qt6keychain/keychain.h>
46#else
47# include <qt5keychain/keychain.h>
48#endif
49
50#include <QtCore/QCoreApplication>
51#include <QtCore/QDir>
52#include <QtCore/QElapsedTimer>
53#include <QtCore/QFile>
54#include <QtCore/QMimeDatabase>
55#include <QtCore/QRegularExpression>
56#include <QtCore/QStandardPaths>
57#include <QtCore/QStringBuilder>
58#include <QtNetwork/QDnsLookup>
59
60using namespace Quotient;
61
62// This is very much Qt-specific; STL iterators don't have key() and value()
63template <typename HashT, typename Pred>
64HashT remove_if(HashT& hashMap, Pred pred)
65{
66 HashT removals;
67 for (auto it = hashMap.begin(); it != hashMap.end();) {
68 if (pred(it)) {
69 removals.insert(it.key(), it.value());
70 it = hashMap.erase(it);
71 } else
72 ++it;
73 }
74 return removals;
75}
76
77Connection::Connection(const QUrl& server, QObject* parent)
78 : QObject(parent)
79 , d(makeImpl<Private>(args: std::make_unique<ConnectionData>(args: server)))
80{
81#ifdef Quotient_E2EE_ENABLED
82 //connect(qApp, &QCoreApplication::aboutToQuit, this, &Connection::saveOlmAccount);
83#endif
84 d->q = this; // All d initialization should occur before this line
85 setObjectName(server.toString());
86}
87
88Connection::Connection(QObject* parent) : Connection({}, parent) {}
89
90Connection::~Connection()
91{
92 qCDebug(MAIN) << "deconstructing connection object for" << userId();
93 stopSync();
94}
95
96void Connection::resolveServer(const QString& mxid)
97{
98 if (isJobPending(job: d->resolverJob))
99 d->resolverJob->abandon();
100
101 auto maybeBaseUrl = QUrl::fromUserInput(userInput: serverPart(mxId: mxid));
102 maybeBaseUrl.setScheme("https"_ls); // Instead of the Qt-default "http"
103 if (maybeBaseUrl.isEmpty() || !maybeBaseUrl.isValid()) {
104 emit resolveError(error: tr(s: "%1 is not a valid homeserver address")
105 .arg(a: maybeBaseUrl.toString()));
106 return;
107 }
108
109 qCDebug(MAIN) << "Finding the server" << maybeBaseUrl.host();
110
111 const auto& oldBaseUrl = d->data->baseUrl();
112 d->data->setBaseUrl(maybeBaseUrl); // Temporarily set it for this one call
113 d->resolverJob = callApi<GetWellknownJob>();
114 // Connect to finished() to make sure baseUrl is restored in any case
115 connect(sender: d->resolverJob, signal: &BaseJob::finished, context: this, slot: [this, maybeBaseUrl, oldBaseUrl] {
116 // Revert baseUrl so that setHomeserver() below triggers signals
117 // in case the base URL actually changed
118 d->data->setBaseUrl(oldBaseUrl);
119 if (d->resolverJob->error() == BaseJob::Abandoned)
120 return;
121
122 if (d->resolverJob->error() != BaseJob::NotFound) {
123 if (!d->resolverJob->status().good()) {
124 qCWarning(MAIN)
125 << "Fetching .well-known file failed, FAIL_PROMPT";
126 emit resolveError(error: tr(s: "Failed resolving the homeserver"));
127 return;
128 }
129 const QUrl baseUrl { d->resolverJob->data().homeserver.baseUrl };
130 if (baseUrl.isEmpty()) {
131 qCWarning(MAIN) << "base_url not provided, FAIL_PROMPT";
132 emit resolveError(
133 error: tr(s: "The homeserver base URL is not provided"));
134 return;
135 }
136 if (!baseUrl.isValid()) {
137 qCWarning(MAIN) << "base_url invalid, FAIL_ERROR";
138 emit resolveError(error: tr(s: "The homeserver base URL is invalid"));
139 return;
140 }
141 qCInfo(MAIN) << ".well-known URL for" << maybeBaseUrl.host() << "is"
142 << baseUrl.toString();
143 setHomeserver(baseUrl);
144 } else {
145 qCInfo(MAIN) << "No .well-known file, using" << maybeBaseUrl
146 << "for base URL";
147 setHomeserver(maybeBaseUrl);
148 }
149 Q_ASSERT(d->loginFlowsJob != nullptr); // Ensured by setHomeserver()
150 });
151}
152
153inline UserIdentifier makeUserIdentifier(const QString& id)
154{
155 return { QStringLiteral("m.id.user"), .additionalProperties: { { QStringLiteral("user"), id } } };
156}
157
158inline UserIdentifier make3rdPartyIdentifier(const QString& medium,
159 const QString& address)
160{
161 return { QStringLiteral("m.id.thirdparty"),
162 .additionalProperties: { { QStringLiteral("medium"), medium },
163 { QStringLiteral("address"), address } } };
164}
165
166void Connection::loginWithPassword(const QString& userId,
167 const QString& password,
168 const QString& initialDeviceName,
169 const QString& deviceId)
170{
171 d->checkAndConnect(userId, connectFn: [=,this] {
172 d->loginToServer(loginArgs: LoginFlows::Password.type, loginArgs: makeUserIdentifier(id: userId),
173 loginArgs: password, /*token*/ loginArgs: QString(), loginArgs: deviceId, loginArgs: initialDeviceName);
174 }, flow: LoginFlows::Password);
175}
176
177SsoSession* Connection::prepareForSso(const QString& initialDeviceName,
178 const QString& deviceId)
179{
180 return new SsoSession(this, initialDeviceName, deviceId);
181}
182
183void Connection::loginWithToken(const QString& loginToken,
184 const QString& initialDeviceName,
185 const QString& deviceId)
186{
187 Q_ASSERT(d->data->baseUrl().isValid() && d->loginFlows.contains(LoginFlows::Token));
188 d->loginToServer(loginArgs: LoginFlows::Token.type,
189 loginArgs: none /*user is encoded in loginToken*/, loginArgs: QString() /*password*/,
190 loginArgs: loginToken, loginArgs: deviceId, loginArgs: initialDeviceName);
191}
192
193void Connection::assumeIdentity(const QString& mxId, const QString& accessToken,
194 [[maybe_unused]] const QString& deviceId)
195{
196 assumeIdentity(mxId, accessToken);
197}
198
199void Connection::assumeIdentity(const QString& mxId, const QString& accessToken)
200{
201 d->checkAndConnect(userId: mxId, connectFn: [this, mxId, accessToken] {
202 d->data->setToken(accessToken.toLatin1());
203 auto* job = callApi<GetTokenOwnerJob>();
204 connect(sender: job, signal: &BaseJob::success, context: this, slot: [this, job, mxId] {
205 if (mxId != job->userId())
206 qCWarning(MAIN).nospace()
207 << "The access_token owner (" << job->userId()
208 << ") is different from passed MXID (" << mxId << ")!";
209 d->data->setDeviceId(job->deviceId());
210 d->completeSetup(mxId: job->userId());
211 });
212 connect(sender: job, signal: &BaseJob::failure, context: this, slot: [this, job] {
213 if (job->error() == BaseJob::StatusCode::NetworkError)
214 emit networkError(message: job->errorString(), details: job->rawDataSample(),
215 retriesTaken: job->maxRetries(), nextRetryInMilliseconds: -1);
216 else
217 emit loginError(message: job->errorString(), details: job->rawDataSample());
218 });
219 });
220}
221
222void Connection::reloadCapabilities()
223{
224 d->capabilitiesJob = callApi<GetCapabilitiesJob>(runningPolicy: BackgroundRequest);
225 connect(sender: d->capabilitiesJob, signal: &BaseJob::success, context: this, slot: [this] {
226 d->capabilities = d->capabilitiesJob->capabilities();
227
228 if (d->capabilities.roomVersions) {
229 qCDebug(MAIN) << "Room versions:" << defaultRoomVersion()
230 << "is default, full list:" << availableRoomVersions();
231 emit capabilitiesLoaded();
232 for (auto* r: std::as_const(t&: d->roomMap))
233 r->checkVersion();
234 } else
235 qCWarning(MAIN)
236 << "The server returned an empty set of supported versions;"
237 " disabling version upgrade recommendations to reduce noise";
238 });
239 connect(sender: d->capabilitiesJob, signal: &BaseJob::failure, context: this, slot: [this] {
240 if (d->capabilitiesJob->error() == BaseJob::IncorrectRequest)
241 qCDebug(MAIN) << "Server doesn't support /capabilities;"
242 " version upgrade recommendations won't be issued";
243 });
244}
245
246bool Connection::loadingCapabilities() const
247{
248 // (Ab)use the fact that room versions cannot be omitted after
249 // the capabilities have been loaded (see reloadCapabilities() above).
250 return !d->capabilities.roomVersions;
251}
252
253void Connection::Private::saveAccessTokenToKeychain() const
254{
255 qCDebug(MAIN) << "Saving access token to keychain for" << q->userId();
256 auto job = new QKeychain::WritePasswordJob(qAppName());
257 job->setKey(q->userId());
258 job->setBinaryData(data->accessToken());
259 job->start();
260 QObject::connect(sender: job, signal: &QKeychain::Job::finished, context: q, slot: [job] {
261 if (job->error() == QKeychain::Error::NoError)
262 return;
263 qWarning(catFunc: MAIN).noquote()
264 << "Could not save access token to the keychain:"
265 << qUtf8Printable(job->errorString());
266 // TODO: emit a signal
267 });
268
269}
270
271void Connection::Private::dropAccessToken()
272{
273 // TODO: emit a signal on important (i.e. access denied) keychain errors
274 using namespace QKeychain;
275 qCDebug(MAIN) << "Removing access token from keychain for" << q->userId();
276 auto job = new DeletePasswordJob(qAppName());
277 job->setKey(q->userId());
278 job->start();
279 QObject::connect(sender: job, signal: &Job::finished, context: q, slot: [job] {
280 if (job->error() == Error::NoError
281 || job->error() == Error::EntryNotFound)
282 return;
283 qWarning(catFunc: MAIN).noquote()
284 << "Could not delete access token from the keychain:"
285 << qUtf8Printable(job->errorString());
286 });
287
288 auto pickleJob = new DeletePasswordJob(qAppName());
289 pickleJob->setKey(q->userId() + "-Pickle"_ls);
290 pickleJob->start();
291 QObject::connect(sender: job, signal: &Job::finished, context: q, slot: [job] {
292 if (job->error() == Error::NoError
293 || job->error() == Error::EntryNotFound)
294 return;
295 qWarning(catFunc: MAIN).noquote()
296 << "Could not delete account pickle from the keychain:"
297 << qUtf8Printable(job->errorString());
298 });
299
300 data->setToken({});
301}
302
303template <typename... LoginArgTs>
304void Connection::Private::loginToServer(LoginArgTs&&... loginArgs)
305{
306 auto loginJob =
307 q->callApi<LoginJob>(std::forward<LoginArgTs>(loginArgs)...);
308 connect(loginJob, &BaseJob::success, q, [this, loginJob] {
309 data->setToken(loginJob->accessToken().toLatin1());
310 data->setDeviceId(loginJob->deviceId());
311 completeSetup(mxId: loginJob->userId());
312 saveAccessTokenToKeychain();
313#ifdef Quotient_E2EE_ENABLED
314 if (encryptionData)
315 encryptionData->database.clear();
316#endif
317 });
318 connect(loginJob, &BaseJob::failure, q, [this, loginJob] {
319 emit q->loginError(message: loginJob->errorString(), details: loginJob->rawDataSample());
320 });
321}
322
323void Connection::Private::completeSetup(const QString& mxId, bool mock)
324{
325 data->setUserId(mxId);
326 if (!mock)
327 q->user()->load(); // Load the local user's profile
328 q->setObjectName(data->userId() % u'/' % data->deviceId());
329 qCDebug(MAIN) << "Using server" << data->baseUrl().toDisplayString()
330 << "by user" << data->userId()
331 << "from device" << data->deviceId();
332 connect(qApp, signal: &QCoreApplication::aboutToQuit, context: q, slot: &Connection::saveState);
333
334 static auto callOnce [[maybe_unused]] = //
335 (qInfo(catFunc: MAIN) << "The library is built"
336 << (E2EE_Enabled ? "with" : "without")
337 << "end-to-end encryption (E2EE)",
338 0);
339#ifdef Quotient_E2EE_ENABLED
340 if (useEncryption) {
341 if (auto&& maybeEncryptionData =
342 _impl::ConnectionEncryptionData::setup(q, mock)) {
343 encryptionData = std::move(*maybeEncryptionData);
344 } else {
345 Q_ASSERT(false);
346 useEncryption = false;
347 emit q->encryptionChanged(false);
348 }
349 } else
350 qCInfo(E2EE) << "End-to-end encryption (E2EE) support is off for"
351 << q->objectName();
352#endif
353
354 emit q->stateChanged();
355 emit q->connected();
356 if (!mock)
357 q->reloadCapabilities();
358}
359
360void Connection::Private::checkAndConnect(const QString& userId,
361 const std::function<void()>& connectFn,
362 const std::optional<LoginFlow>& flow)
363{
364 if (data->baseUrl().isValid() && (!flow || loginFlows.contains(t: *flow))) {
365 q->setObjectName(userId % u"(?)");
366 connectFn();
367 return;
368 }
369 // Not good to go, try to ascertain the homeserver URL and flows
370 if (userId.startsWith(c: u'@') && userId.indexOf(c: u':') != -1) {
371 q->setObjectName(userId % u"(?)");
372 q->resolveServer(mxid: userId);
373 if (flow)
374 connectSingleShot(sender: q, signal: &Connection::loginFlowsChanged, context: q,
375 slot: [this, flow, connectFn] {
376 if (loginFlows.contains(t: *flow))
377 connectFn();
378 else
379 emit q->loginError(
380 message: tr(s: "Unsupported login flow"),
381 details: tr(s: "The homeserver at %1 does not support"
382 " the login flow '%2'")
383 .arg(args: data->baseUrl().toDisplayString(),
384 args: flow->type));
385 });
386 else
387 connectSingleShot(sender: q, signal: &Connection::homeserverChanged, context: q, slot: connectFn);
388 } else
389 emit q->resolveError(error: tr(s: "Please provide the fully-qualified user id"
390 " (such as @user:example.org) so that the"
391 " homeserver could be resolved; the current"
392 " homeserver URL(%1) is not good")
393 .arg(a: data->baseUrl().toDisplayString()));
394}
395
396void Connection::logout()
397{
398 // If there's an ongoing sync job, stop it (this also suspends sync loop)
399 const auto wasSyncing = bool(d->syncJob);
400 if (wasSyncing)
401 {
402 d->syncJob->abandon();
403 d->syncJob = nullptr;
404 }
405
406 d->logoutJob = callApi<LogoutJob>();
407 emit stateChanged(); // isLoggedIn() == false from now
408
409 connect(sender: d->logoutJob, signal: &LogoutJob::finished, context: this, slot: [this, wasSyncing] {
410 if (d->logoutJob->status().good()
411 || d->logoutJob->error() == BaseJob::Unauthorised
412 || d->logoutJob->error() == BaseJob::ContentAccessError) {
413 if (d->syncLoopConnection)
414 disconnect(d->syncLoopConnection);
415 SettingsGroup("Accounts"_ls).remove(key: userId());
416 d->dropAccessToken();
417 emit loggedOut();
418 deleteLater();
419 } else { // logout() somehow didn't proceed - restore the session state
420 emit stateChanged();
421 if (wasSyncing)
422 syncLoopIteration(); // Resume sync loop (or a single sync)
423 }
424 });
425}
426
427void Connection::sync(int timeout)
428{
429 if (d->syncJob) {
430 qCInfo(MAIN) << d->syncJob << "is already running";
431 return;
432 }
433 if (!isLoggedIn()) {
434 qCWarning(MAIN) << "Not logged in, not going to sync";
435 return;
436 }
437
438 d->syncTimeout = timeout;
439 Filter filter;
440 filter.room.timeline.limit.emplace(val: 100);
441 filter.room.state.lazyLoadMembers.emplace(args&: d->lazyLoading);
442 auto job = d->syncJob =
443 callApi<SyncJob>(runningPolicy: BackgroundRequest, jobArgs: d->data->lastEvent(), jobArgs&: filter,
444 jobArgs&: timeout);
445 connect(sender: job, signal: &SyncJob::success, context: this, slot: [this, job] {
446 onSyncSuccess(data: job->takeData());
447 d->syncJob = nullptr;
448 emit syncDone();
449 });
450 connect(sender: job, signal: &SyncJob::retryScheduled, context: this,
451 slot: [this, job](int retriesTaken, int nextInMilliseconds) {
452 emit networkError(message: job->errorString(), details: job->rawDataSample(),
453 retriesTaken, nextRetryInMilliseconds: nextInMilliseconds);
454 });
455 connect(sender: job, signal: &SyncJob::failure, context: this, slot: [this, job] {
456 // SyncJob persists with retries on transient errors; if it fails,
457 // there's likely something serious enough to stop the loop.
458 stopSync();
459 if (job->error() == BaseJob::Unauthorised) {
460 qCWarning(SYNCJOB)
461 << "Sync job failed with Unauthorised - login expired?";
462 emit loginError(message: job->errorString(), details: job->rawDataSample());
463 } else
464 emit syncError(message: job->errorString(), details: job->rawDataSample());
465 });
466}
467
468void Connection::syncLoop(int timeout)
469{
470 if (d->syncLoopConnection && d->syncTimeout == timeout) {
471 qCInfo(MAIN) << "Attempt to run sync loop but there's one already "
472 "running; nothing will be done";
473 return;
474 }
475 std::swap(a&: d->syncTimeout, b&: timeout);
476 if (d->syncLoopConnection) {
477 qCInfo(MAIN) << "Timeout for next syncs changed from" << timeout //
478 << "to" << d->syncTimeout;
479 } else {
480 d->syncLoopConnection = connect(sender: this, signal: &Connection::syncDone,
481 context: this, slot: &Connection::syncLoopIteration,
482 type: Qt::QueuedConnection);
483 syncLoopIteration(); // initial sync to start the loop
484 }
485}
486
487void Connection::syncLoopIteration()
488{
489 if (isLoggedIn())
490 sync(timeout: d->syncTimeout);
491 else
492 qCInfo(MAIN) << "Logged out, sync loop will stop now";
493}
494
495QJsonObject toJson(const DirectChatsMap& directChats)
496{
497 QJsonObject json;
498 for (auto it = directChats.begin(); it != directChats.end();) {
499 QJsonArray roomIds;
500 const auto* user = it.key();
501 for (; it != directChats.end() && it.key() == user; ++it)
502 roomIds.append(value: *it);
503 json.insert(key: user->id(), value: roomIds);
504 }
505 return json;
506}
507
508void Connection::onSyncSuccess(SyncData&& data, bool fromCache)
509{
510#ifdef Quotient_E2EE_ENABLED
511 if (d->encryptionData) {
512 d->encryptionData->onSyncSuccess(data);
513 }
514#endif
515 d->consumeToDeviceEvents(toDeviceEvents: data.takeToDeviceEvents());
516 d->data->setLastEvent(data.nextBatch());
517 d->consumeRoomData(roomDataList: data.takeRoomData(), fromCache);
518 d->consumeAccountData(accountDataEvents: data.takeAccountData());
519 d->consumePresenceData(presenceData: data.takePresenceData());
520#ifdef Quotient_E2EE_ENABLED
521 if(d->encryptionData && d->encryptionData->encryptionUpdateRequired) {
522 d->encryptionData->loadOutdatedUserDevices();
523 d->encryptionData->encryptionUpdateRequired = false;
524 }
525#endif
526 Q_UNUSED(std::move(data)) // Tell static analysers `data` is consumed now
527}
528
529void Connection::Private::consumeRoomData(SyncDataList&& roomDataList,
530 bool fromCache)
531{
532 for (auto&& roomData: roomDataList) {
533 const auto forgetIdx = roomIdsToForget.indexOf(str: roomData.roomId);
534 if (forgetIdx != -1) {
535 roomIdsToForget.removeAt(i: forgetIdx);
536 if (roomData.joinState == JoinState::Leave) {
537 qDebug(catFunc: MAIN)
538 << "Room" << roomData.roomId
539 << "has been forgotten, ignoring /sync response for it";
540 continue;
541 }
542 qWarning(catFunc: MAIN) << "Room" << roomData.roomId
543 << "has just been forgotten but /sync returned it in"
544 << terse << roomData.joinState
545 << "state - suspiciously fast turnaround";
546 }
547 if (auto* r = q->provideRoom(roomId: roomData.roomId, joinState: roomData.joinState)) {
548 pendingStateRoomIds.removeOne(t: roomData.roomId);
549 // Update rooms one by one, giving time to update the UI.
550 QMetaObject::invokeMethod(
551 object: r,
552 function: [r, rd = std::move(roomData), fromCache] () mutable {
553 r->updateData(data: std::move(rd), fromCache);
554 },
555 type: Qt::QueuedConnection);
556 }
557 }
558}
559
560void Connection::Private::consumeAccountData(Events&& accountDataEvents)
561{
562 // After running this loop, the account data events not saved in
563 // accountData (see the end of the loop body) are auto-cleaned away
564 for (auto&& eventPtr: accountDataEvents) {
565 switchOnType(event: *eventPtr,
566 fn1: [this](const DirectChatEvent& dce) {
567 // https://github.com/quotient-im/libQuotient/wiki/Handling-direct-chat-events
568 const auto& usersToDCs = dce.usersToDirectChats();
569 DirectChatsMap remoteRemovals =
570 remove_if(hashMap&: directChats, pred: [&usersToDCs, this](auto it) {
571 return !(
572 usersToDCs.contains(it.key()->id(), it.value())
573 || dcLocalAdditions.contains(it.key(), it.value()));
574 });
575 remove_if(hashMap&: directChatUsers, pred: [&remoteRemovals](auto it) {
576 return remoteRemovals.contains(it.value(), it.key());
577 });
578 // Remove from dcLocalRemovals what the server already has.
579 remove_if(hashMap&: dcLocalRemovals, pred: [&remoteRemovals](auto it) {
580 return remoteRemovals.contains(it.key(), it.value());
581 });
582 if (MAIN().isDebugEnabled())
583 for (auto it = remoteRemovals.begin();
584 it != remoteRemovals.end(); ++it) {
585 qCDebug(MAIN)
586 << it.value() << "is no more a direct chat with"
587 << it.key()->id();
588 }
589
590 DirectChatsMap remoteAdditions;
591 for (auto it = usersToDCs.begin(); it != usersToDCs.end(); ++it) {
592 if (auto* u = q->user(uId: it.key())) {
593 if (!directChats.contains(key: u, value: it.value())
594 && !dcLocalRemovals.contains(key: u, value: it.value())) {
595 Q_ASSERT(!directChatUsers.contains(it.value(), u));
596 remoteAdditions.insert(key: u, value: it.value());
597 directChats.insert(key: u, value: it.value());
598 directChatUsers.insert(key: it.value(), value: u);
599 qCDebug(MAIN) << "Marked room" << it.value()
600 << "as a direct chat with" << u->id();
601 }
602 } else
603 qCWarning(MAIN)
604 << "Couldn't get a user object for" << it.key();
605 }
606 // Remove from dcLocalAdditions what the server already has.
607 remove_if(hashMap&: dcLocalAdditions, pred: [&remoteAdditions](auto it) {
608 return remoteAdditions.contains(it.key(), it.value());
609 });
610 if (!remoteAdditions.isEmpty() || !remoteRemovals.isEmpty())
611 emit q->directChatsListChanged(additions: remoteAdditions,
612 removals: remoteRemovals);
613 },
614 // catch-all, passing eventPtr for a possible take-over
615 fns: [this, &eventPtr](const Event& accountEvent) {
616 if (is<IgnoredUsersEvent>(e: accountEvent))
617 qCDebug(MAIN)
618 << "Users ignored by" << data->userId() << "updated:"
619 << QStringList(q->ignoredUsers().values()).join(sep: u',');
620
621 auto& currentData = accountData[accountEvent.matrixType()];
622 // A polymorphic event-specific comparison might be a bit
623 // more efficient; maaybe do it another day
624 if (!currentData
625 || currentData->contentJson() != accountEvent.contentJson()) {
626 currentData = std::move(eventPtr);
627 qCDebug(MAIN) << "Updated account data of type"
628 << currentData->matrixType();
629 emit q->accountDataChanged(type: currentData->matrixType());
630 }
631 });
632 }
633 if (!dcLocalAdditions.isEmpty() || !dcLocalRemovals.isEmpty()) {
634 qDebug(catFunc: MAIN) << "Sending updated direct chats to the server:"
635 << dcLocalRemovals.size() << "removal(s),"
636 << dcLocalAdditions.size() << "addition(s)";
637 q->callApi<SetAccountDataJob>(jobArgs: data->userId(), QStringLiteral("m.direct"),
638 jobArgs: toJson(directChats));
639 dcLocalAdditions.clear();
640 dcLocalRemovals.clear();
641 }
642}
643
644void Connection::Private::consumePresenceData(Events&& presenceData)
645{
646 // To be implemented
647}
648
649void Connection::Private::consumeToDeviceEvents(Events&& toDeviceEvents)
650{
651#ifdef Quotient_E2EE_ENABLED
652 if (encryptionData)
653 encryptionData->consumeToDeviceEvents(std::move(toDeviceEvents));
654#endif
655}
656
657void Connection::stopSync()
658{
659 // If there's a sync loop, break it
660 disconnect(d->syncLoopConnection);
661 if (d->syncJob) // If there's an ongoing sync job, stop it too
662 {
663 if (d->syncJob->status().code == BaseJob::Pending)
664 d->syncJob->abandon();
665 d->syncJob = nullptr;
666 }
667}
668
669QString Connection::nextBatchToken() const { return d->data->lastEvent(); }
670
671JoinRoomJob* Connection::joinRoom(const QString& roomAlias,
672 const QStringList& serverNames)
673{
674 auto* const job = callApi<JoinRoomJob>(jobArgs: roomAlias, jobArgs: serverNames);
675 // Upon completion, ensure a room object is created in case it hasn't come
676 // with a sync yet. If the room object is not there, provideRoom() will
677 // create it in Join state. finished() is used here instead of success()
678 // to overtake clients that may add their own slots to finished().
679 connect(sender: job, signal: &BaseJob::finished, context: this, slot: [this, job] {
680 if (job->status().good())
681 provideRoom(roomId: job->roomId());
682 });
683 return job;
684}
685
686LeaveRoomJob* Connection::leaveRoom(Room* room)
687{
688 const auto& roomId = room->id();
689 const auto job = callApi<LeaveRoomJob>(jobArgs: roomId);
690 if (room->joinState() == JoinState::Invite) {
691 // Workaround matrix-org/synapse#2181 - if the room is in invite state
692 // the invite may have been cancelled but Synapse didn't send it in
693 // `/sync`. See also #273 for the discussion in the library context.
694 d->pendingStateRoomIds.push_back(t: roomId);
695 connect(sender: job, signal: &LeaveRoomJob::success, context: this, slot: [this, roomId] {
696 if (d->pendingStateRoomIds.removeOne(t: roomId)) {
697 qCDebug(MAIN) << "Forcing the room to Leave status";
698 provideRoom(roomId, joinState: JoinState::Leave);
699 }
700 });
701 }
702 return job;
703}
704
705inline auto splitMediaId(const QString& mediaId)
706{
707 auto idParts = mediaId.split(sep: u'/');
708 Q_ASSERT_X(idParts.size() == 2, __FUNCTION__,
709 qPrintable("'"_ls % mediaId
710 % "' doesn't look like 'serverName/localMediaId'"_ls));
711 return idParts;
712}
713
714QUrl Connection::makeMediaUrl(QUrl mxcUrl) const
715{
716 Q_ASSERT(mxcUrl.scheme() == "mxc"_ls);
717 QUrlQuery q(mxcUrl.query());
718 q.addQueryItem(QStringLiteral("user_id"), value: userId());
719 mxcUrl.setQuery(q);
720 return mxcUrl;
721}
722
723MediaThumbnailJob* Connection::getThumbnail(const QString& mediaId,
724 QSize requestedSize,
725 RunningPolicy policy)
726{
727 auto idParts = splitMediaId(mediaId);
728 return callApi<MediaThumbnailJob>(runningPolicy: policy, jobArgs&: idParts.front(), jobArgs&: idParts.back(),
729 jobArgs&: requestedSize);
730}
731
732MediaThumbnailJob* Connection::getThumbnail(const QUrl& url, QSize requestedSize,
733 RunningPolicy policy)
734{
735 return getThumbnail(mediaId: url.authority() + url.path(), requestedSize, policy);
736}
737
738MediaThumbnailJob* Connection::getThumbnail(const QUrl& url, int requestedWidth,
739 int requestedHeight,
740 RunningPolicy policy)
741{
742 return getThumbnail(url, requestedSize: QSize(requestedWidth, requestedHeight), policy);
743}
744
745UploadContentJob*
746Connection::uploadContent(QIODevice* contentSource, const QString& filename,
747 const QString& overrideContentType)
748{
749 Q_ASSERT(contentSource != nullptr);
750 auto contentType = overrideContentType;
751 if (contentType.isEmpty()) {
752 contentType = QMimeDatabase()
753 .mimeTypeForFileNameAndData(fileName: filename, device: contentSource)
754 .name();
755 if (!contentSource->open(mode: QIODevice::ReadOnly)) {
756 qCWarning(MAIN) << "Couldn't open content source" << filename
757 << "for reading:" << contentSource->errorString();
758 return nullptr;
759 }
760 }
761 return callApi<UploadContentJob>(jobArgs&: contentSource, jobArgs: filename, jobArgs&: contentType);
762}
763
764UploadContentJob* Connection::uploadFile(const QString& fileName,
765 const QString& overrideContentType)
766{
767 auto sourceFile = new QFile(fileName);
768 return uploadContent(contentSource: sourceFile, filename: QFileInfo(*sourceFile).fileName(),
769 overrideContentType);
770}
771
772GetContentJob* Connection::getContent(const QString& mediaId)
773{
774 auto idParts = splitMediaId(mediaId);
775 return callApi<GetContentJob>(jobArgs&: idParts.front(), jobArgs&: idParts.back());
776}
777
778GetContentJob* Connection::getContent(const QUrl& url)
779{
780 return getContent(mediaId: url.authority() + url.path());
781}
782
783DownloadFileJob* Connection::downloadFile(const QUrl& url,
784 const QString& localFilename)
785{
786 auto mediaId = url.authority() + url.path();
787 auto idParts = splitMediaId(mediaId);
788 auto* job =
789 callApi<DownloadFileJob>(jobArgs&: idParts.front(), jobArgs&: idParts.back(), jobArgs: localFilename);
790 return job;
791}
792
793#ifdef Quotient_E2EE_ENABLED
794DownloadFileJob* Connection::downloadFile(
795 const QUrl& url, const EncryptedFileMetadata& fileMetadata,
796 const QString& localFilename)
797{
798 auto mediaId = url.authority() + url.path();
799 auto idParts = splitMediaId(mediaId);
800 return callApi<DownloadFileJob>(idParts.front(), idParts.back(),
801 fileMetadata, localFilename);
802}
803#endif
804
805CreateRoomJob*
806Connection::createRoom(RoomVisibility visibility, const QString& alias,
807 const QString& name, const QString& topic,
808 QStringList invites, const QString& presetName,
809 const QString& roomVersion, bool isDirect,
810 const QVector<CreateRoomJob::StateEvent>& initialState,
811 const QVector<CreateRoomJob::Invite3pid>& invite3pids,
812 const QJsonObject& creationContent)
813{
814 invites.removeOne(t: userId()); // The creator is by definition in the room
815 auto job = callApi<CreateRoomJob>(jobArgs: visibility == PublishRoom
816 ? QStringLiteral("public")
817 : QStringLiteral("private"),
818 jobArgs: alias, jobArgs: name, jobArgs: topic, jobArgs&: invites, jobArgs: invite3pids,
819 jobArgs: roomVersion, jobArgs: creationContent,
820 jobArgs: initialState, jobArgs: presetName, jobArgs&: isDirect);
821 connect(sender: job, signal: &BaseJob::success, context: this, slot: [this, job, invites, isDirect] {
822 auto* room = provideRoom(roomId: job->roomId(), joinState: JoinState::Join);
823 if (!room) {
824 Q_ASSERT_X(room, "Connection::createRoom",
825 "Failed to create a room");
826 return;
827 }
828 emit createdRoom(room);
829 if (isDirect)
830 for (const auto& i : invites)
831 addToDirectChats(room, user: user(uId: i));
832 });
833 return job;
834}
835
836void Connection::requestDirectChat(const QString& userId)
837{
838 doInDirectChat(userId, operation: [this](Room* r) { emit directChatAvailable(directChat: r); });
839}
840
841void Connection::requestDirectChat(User* u)
842{
843 doInDirectChat(u, operation: [this](Room* r) { emit directChatAvailable(directChat: r); });
844}
845
846void Connection::doInDirectChat(const QString& userId,
847 const std::function<void(Room*)>& operation)
848{
849 if (auto* u = user(uId: userId))
850 doInDirectChat(u, operation);
851 else
852 qCCritical(MAIN)
853 << "Connection::doInDirectChat: Couldn't get a user object for"
854 << userId;
855}
856
857void Connection::doInDirectChat(User* u,
858 const std::function<void(Room*)>& operation)
859{
860 Q_ASSERT(u);
861 const auto& otherUserId = u->id();
862 // There can be more than one DC; find the first valid (existing and
863 // not left), and delete inexistent (forgotten?) ones along the way.
864 DirectChatsMap removals;
865 for (auto it = d->directChats.constFind(key: u);
866 it != d->directChats.cend() && it.key() == u; ++it) {
867 const auto& roomId = *it;
868 if (auto r = room(roomId, states: JoinState::Join)) {
869 Q_ASSERT(r->id() == roomId);
870 // A direct chat with yourself should only involve yourself :)
871 if (otherUserId == userId() && r->totalMemberCount() > 1)
872 continue;
873 qCDebug(MAIN) << "Requested direct chat with" << otherUserId
874 << "is already available as" << r->id();
875 operation(r);
876 return;
877 }
878 if (auto ir = invitation(roomId)) {
879 Q_ASSERT(ir->id() == roomId);
880 auto j = joinRoom(roomAlias: ir->id());
881 connect(sender: j, signal: &BaseJob::success, context: this,
882 slot: [this, roomId, otherUserId, operation] {
883 qCDebug(MAIN)
884 << "Joined the already invited direct chat with"
885 << otherUserId << "as" << roomId;
886 operation(room(roomId, states: JoinState::Join));
887 });
888 return;
889 }
890 // Avoid reusing previously left chats but don't remove them
891 // from direct chat maps, either.
892 if (room(roomId, states: JoinState::Leave))
893 continue;
894
895 qCWarning(MAIN) << "Direct chat with" << otherUserId << "known as room"
896 << roomId << "is not valid and will be discarded";
897 // Postpone actual deletion until we finish iterating d->directChats.
898 removals.insert(key: it.key(), value: it.value());
899 // Add to the list of updates to send to the server upon the next sync.
900 d->dcLocalRemovals.insert(key: it.key(), value: it.value());
901 }
902 if (!removals.isEmpty()) {
903 for (auto it = removals.cbegin(); it != removals.cend(); ++it) {
904 d->directChats.remove(key: it.key(), value: it.value());
905 d->directChatUsers.remove(key: it.value(),
906 value: const_cast<User*>(it.key())); // FIXME
907 }
908 emit directChatsListChanged(additions: {}, removals);
909 }
910
911 auto j = createDirectChat(userId: otherUserId);
912 connect(sender: j, signal: &BaseJob::success, context: this, slot: [this, j, otherUserId, operation] {
913 qCDebug(MAIN) << "Direct chat with" << otherUserId << "has been created as"
914 << j->roomId();
915 operation(room(roomId: j->roomId(), states: JoinState::Join));
916 });
917}
918
919CreateRoomJob* Connection::createDirectChat(const QString& userId,
920 const QString& topic,
921 const QString& name)
922{
923 return createRoom(visibility: UnpublishRoom, alias: {}, name, topic, invites: { userId },
924 QStringLiteral("trusted_private_chat"), roomVersion: {}, isDirect: true);
925}
926
927ForgetRoomJob* Connection::forgetRoom(const QString& id)
928{
929 // To forget is hard :) First we should ensure the local user is not
930 // in the room (by leaving it, if necessary); once it's done, the /forget
931 // endpoint can be called; and once this is through, the local Room object
932 // (if any existed) is deleted. At the same time, we still have to
933 // (basically immediately) return a pointer to ForgetRoomJob. Therefore
934 // a ForgetRoomJob is created in advance and can be returned in a probably
935 // not-yet-started state (it will start once /leave completes).
936 auto forgetJob = new ForgetRoomJob(id);
937 auto room = d->roomMap.value(key: { id, false });
938 if (!room)
939 room = d->roomMap.value(key: { id, true });
940 if (room && room->joinState() != JoinState::Leave) {
941 auto leaveJob = leaveRoom(room);
942 connect(sender: leaveJob, signal: &BaseJob::result, context: this,
943 slot: [this, leaveJob, forgetJob, room] {
944 if (leaveJob->error() == BaseJob::Success
945 || leaveJob->error() == BaseJob::NotFound) {
946 run(job: forgetJob);
947 // If the matching /sync response hasn't arrived yet,
948 // mark the room for explicit deletion
949 if (room->joinState() != JoinState::Leave)
950 d->roomIdsToForget.push_back(t: room->id());
951 } else {
952 qCWarning(MAIN).nospace()
953 << "Error leaving room " << room->objectName()
954 << ": " << leaveJob->errorString();
955 forgetJob->abandon();
956 }
957 });
958 } else
959 run(job: forgetJob);
960 connect(sender: forgetJob, signal: &BaseJob::result, context: this, slot: [this, id, forgetJob] {
961 // Leave room in case of success, or room not known by server
962 if (forgetJob->error() == BaseJob::Success
963 || forgetJob->error() == BaseJob::NotFound)
964 d->removeRoom(roomId: id); // Delete the room from roomMap
965 else
966 qCWarning(MAIN).nospace() << "Error forgetting room " << id << ": "
967 << forgetJob->errorString();
968 });
969 return forgetJob;
970}
971
972SendToDeviceJob* Connection::sendToDevices(
973 const QString& eventType, const UsersToDevicesToContent& contents)
974{
975 return callApi<SendToDeviceJob>(runningPolicy: BackgroundRequest, jobArgs: eventType,
976 jobArgs: generateTxnId(), jobArgs: contents);
977}
978
979SendMessageJob* Connection::sendMessage(const QString& roomId,
980 const RoomEvent& event)
981{
982 const auto txnId = event.transactionId().isEmpty() ? generateTxnId()
983 : event.transactionId();
984 return callApi<SendMessageJob>(jobArgs: roomId, jobArgs: event.matrixType(), jobArgs: txnId,
985 jobArgs: event.contentJson());
986}
987
988QUrl Connection::homeserver() const { return d->data->baseUrl(); }
989
990QString Connection::domain() const { return userId().section(asep: u':', astart: 1); }
991
992bool Connection::isUsable() const { return !loginFlows().isEmpty(); }
993
994QVector<GetLoginFlowsJob::LoginFlow> Connection::loginFlows() const
995{
996 return d->loginFlows;
997}
998
999bool Connection::supportsPasswordAuth() const
1000{
1001 return d->loginFlows.contains(t: LoginFlows::Password);
1002}
1003
1004bool Connection::supportsSso() const
1005{
1006 return d->loginFlows.contains(t: LoginFlows::SSO);
1007}
1008
1009Room* Connection::room(const QString& roomId, JoinStates states) const
1010{
1011 Room* room = d->roomMap.value(key: { roomId, false }, defaultValue: nullptr);
1012 if (states.testFlag(flag: JoinState::Join) && room
1013 && room->joinState() == JoinState::Join)
1014 return room;
1015
1016 if (states.testFlag(flag: JoinState::Invite))
1017 if (Room* invRoom = invitation(roomId))
1018 return invRoom;
1019
1020 if (states.testFlag(flag: JoinState::Leave) && room
1021 && room->joinState() == JoinState::Leave)
1022 return room;
1023
1024 return nullptr;
1025}
1026
1027Room* Connection::roomByAlias(const QString& roomAlias, JoinStates states) const
1028{
1029 const auto id = d->roomAliasMap.value(key: roomAlias);
1030 if (!id.isEmpty())
1031 return room(roomId: id, states);
1032
1033 qCWarning(MAIN) << "Room for alias" << roomAlias
1034 << "is not found under account" << userId();
1035 return nullptr;
1036}
1037
1038bool Connection::roomSucceeds(const QString& maybePredecessorId,
1039 const QString& maybeSuccessorId) const
1040{
1041 static constexpr auto AnyJoinStateMask = JoinState::Invite | JoinState::Join
1042 | JoinState::Knock
1043 | JoinState::Leave;
1044
1045 for (auto r = room(roomId: maybePredecessorId, states: AnyJoinStateMask); r != nullptr;) {
1046 const auto& currentSuccId = r->successorId(); // Search forward
1047 if (currentSuccId.isEmpty())
1048 break;
1049 if (currentSuccId == maybeSuccessorId)
1050 return true;
1051 r = room(roomId: currentSuccId, states: AnyJoinStateMask);
1052 }
1053 for (auto r = room(roomId: maybeSuccessorId, states: AnyJoinStateMask); r != nullptr;) {
1054 const auto& currentPredId = r->predecessorId(); // Search backward
1055 if (currentPredId.isEmpty())
1056 break;
1057 if (currentPredId == maybePredecessorId)
1058 return true;
1059 r = room(roomId: currentPredId, states: AnyJoinStateMask);
1060 }
1061 return false; // Can't ascertain succession
1062}
1063
1064void Connection::updateRoomAliases(const QString& roomId,
1065 const QStringList& previousRoomAliases,
1066 const QStringList& roomAliases)
1067{
1068 for (const auto& a : previousRoomAliases)
1069 if (d->roomAliasMap.remove(key: a) == 0)
1070 qCWarning(MAIN) << "Alias" << a << "is not found (already deleted?)";
1071
1072 for (const auto& a : roomAliases) {
1073 auto& mappedId = d->roomAliasMap[a];
1074 if (!mappedId.isEmpty()) {
1075 if (mappedId == roomId)
1076 qCDebug(MAIN)
1077 << "Alias" << a << "is already mapped to" << roomId;
1078 else if (roomSucceeds(maybePredecessorId: roomId, maybeSuccessorId: mappedId)) {
1079 qCDebug(MAIN) << "Not remapping alias" << a << "from"
1080 << mappedId << "to predecessor" << roomId;
1081 continue;
1082 } else if (roomSucceeds(maybePredecessorId: mappedId, maybeSuccessorId: roomId))
1083 qCDebug(MAIN) << "Remapping alias" << a << "from" << mappedId
1084 << "to successor" << roomId;
1085 else
1086 qCWarning(MAIN) << "Alias" << a << "will be force-remapped from"
1087 << mappedId << "to" << roomId;
1088 }
1089 mappedId = roomId;
1090 }
1091}
1092
1093Room* Connection::invitation(const QString& roomId) const
1094{
1095 return d->roomMap.value(key: { roomId, true }, defaultValue: nullptr);
1096}
1097
1098User* Connection::user(const QString& uId)
1099{
1100 if (uId.isEmpty())
1101 return nullptr;
1102 if (const auto v = d->userMap.value(key: uId, defaultValue: nullptr))
1103 return v;
1104 // Before creating a user object, check that the user id is well-formed
1105 // (it's faster to just do a lookup above before validation)
1106 if (!uId.startsWith(c: u'@') || serverPart(mxId: uId).isEmpty()) {
1107 qCCritical(MAIN) << "Malformed userId:" << uId;
1108 return nullptr;
1109 }
1110 auto* user = userFactory()(this, uId);
1111 d->userMap.insert(key: uId, value: user);
1112 emit newUser(user);
1113 return user;
1114}
1115
1116const User* Connection::user() const
1117{
1118 return d->userMap.value(key: userId(), defaultValue: nullptr);
1119}
1120
1121User* Connection::user() { return user(uId: userId()); }
1122
1123QString Connection::userId() const { return d->data->userId(); }
1124
1125QString Connection::deviceId() const { return d->data->deviceId(); }
1126
1127QByteArray Connection::accessToken() const
1128{
1129 // The logout job needs access token to do its job; so the token is
1130 // kept inside d->data but no more exposed to the outside world.
1131 return isJobPending(job: d->logoutJob) ? QByteArray() : d->data->accessToken();
1132}
1133
1134bool Connection::isLoggedIn() const { return !accessToken().isEmpty(); }
1135
1136#ifdef Quotient_E2EE_ENABLED
1137QOlmAccount* Connection::olmAccount() const
1138{
1139 return d->encryptionData ? &d->encryptionData->olmAccount : nullptr;
1140}
1141#endif // Quotient_E2EE_ENABLED
1142
1143SyncJob* Connection::syncJob() const { return d->syncJob; }
1144
1145int Connection::millisToReconnect() const
1146{
1147 return d->syncJob ? d->syncJob->millisToRetry() : 0;
1148}
1149
1150QVector<Room*> Connection::allRooms() const
1151{
1152 QVector<Room*> result;
1153 result.resize(size: d->roomMap.size());
1154 std::copy(first: d->roomMap.cbegin(), last: d->roomMap.cend(), result: result.begin());
1155 return result;
1156}
1157
1158QVector<Room*> Connection::rooms(JoinStates joinStates) const
1159{
1160 QVector<Room*> result;
1161 for (auto* r: qAsConst(t&: d->roomMap))
1162 if (joinStates.testFlag(flag: r->joinState()))
1163 result.push_back(t: r);
1164 return result;
1165}
1166
1167int Connection::roomsCount(JoinStates joinStates) const
1168{
1169 // Using int to maintain compatibility with QML
1170 // (consider also that QHash<>::size() returns int anyway).
1171 return int(std::count_if(first: d->roomMap.cbegin(), last: d->roomMap.cend(),
1172 pred: [joinStates](Room* r) {
1173 return joinStates.testFlag(flag: r->joinState());
1174 }));
1175}
1176
1177bool Connection::hasAccountData(const QString& type) const
1178{
1179 return d->accountData.find(x: type) != d->accountData.cend();
1180}
1181
1182const EventPtr& Connection::accountData(const QString& type) const
1183{
1184 static EventPtr NoEventPtr {};
1185 auto it = d->accountData.find(x: type);
1186 return it == d->accountData.end() ? NoEventPtr : it->second;
1187}
1188
1189QJsonObject Connection::accountDataJson(const QString& type) const
1190{
1191 const auto& eventPtr = accountData(type);
1192 return eventPtr ? eventPtr->contentJson() : QJsonObject();
1193}
1194
1195void Connection::setAccountData(EventPtr&& event)
1196{
1197 d->packAndSendAccountData(event: std::move(event));
1198}
1199
1200void Connection::setAccountData(const QString& type, const QJsonObject& content)
1201{
1202 d->packAndSendAccountData(event: loadEvent<Event>(matrixType: type, otherBasicJsonParams: content));
1203}
1204
1205QHash<QString, QVector<Room*>> Connection::tagsToRooms() const
1206{
1207 QHash<QString, QVector<Room*>> result;
1208 for (auto* r : qAsConst(t&: d->roomMap)) {
1209 const auto& tagNames = r->tagNames();
1210 for (const auto& tagName : tagNames)
1211 result[tagName].push_back(t: r);
1212 }
1213 for (auto it = result.begin(); it != result.end(); ++it)
1214 std::sort(first: it->begin(), last: it->end(), comp: [t = it.key()](Room* r1, Room* r2) {
1215 return r1->tags().value(key: t) < r2->tags().value(key: t);
1216 });
1217 return result;
1218}
1219
1220QStringList Connection::tagNames() const
1221{
1222 QStringList tags({ FavouriteTag });
1223 for (auto* r : qAsConst(t&: d->roomMap)) {
1224 const auto& tagNames = r->tagNames();
1225 for (const auto& tag : tagNames)
1226 if (tag != LowPriorityTag && !tags.contains(str: tag))
1227 tags.push_back(t: tag);
1228 }
1229 tags.push_back(t: LowPriorityTag);
1230 return tags;
1231}
1232
1233QVector<Room*> Connection::roomsWithTag(const QString& tagName) const
1234{
1235 QVector<Room*> rooms;
1236 std::copy_if(first: d->roomMap.cbegin(), last: d->roomMap.cend(),
1237 result: std::back_inserter(x&: rooms),
1238 pred: [&tagName](Room* r) { return r->tags().contains(key: tagName); });
1239 return rooms;
1240}
1241
1242DirectChatsMap Connection::directChats() const
1243{
1244 return d->directChats;
1245}
1246
1247// Removes room with given id from roomMap
1248void Connection::Private::removeRoom(const QString& roomId)
1249{
1250 for (auto f : { false, true })
1251 if (auto r = roomMap.take(key: { roomId, f })) {
1252 qCDebug(MAIN) << "Room" << r->objectName() << "in state" << terse
1253 << r->joinState() << "will be deleted";
1254 emit r->beforeDestruction(r);
1255 r->deleteLater();
1256 }
1257}
1258
1259void Connection::addToDirectChats(const Room* room, User* user)
1260{
1261 Q_ASSERT(room != nullptr && user != nullptr);
1262 if (d->directChats.contains(key: user, value: room->id()))
1263 return;
1264 Q_ASSERT(!d->directChatUsers.contains(room->id(), user));
1265 d->directChats.insert(key: user, value: room->id());
1266 d->directChatUsers.insert(key: room->id(), value: user);
1267 d->dcLocalAdditions.insert(key: user, value: room->id());
1268 emit directChatsListChanged(additions: { { user, room->id() } }, removals: {});
1269}
1270
1271void Connection::removeFromDirectChats(const QString& roomId, User* user)
1272{
1273 Q_ASSERT(!roomId.isEmpty());
1274 if ((user != nullptr && !d->directChats.contains(key: user, value: roomId))
1275 || d->directChats.key(value: roomId) == nullptr)
1276 return;
1277
1278 DirectChatsMap removals;
1279 if (user != nullptr) {
1280 d->directChats.remove(key: user, value: roomId);
1281 d->directChatUsers.remove(key: roomId, value: user);
1282 removals.insert(key: user, value: roomId);
1283 d->dcLocalRemovals.insert(key: user, value: roomId);
1284 } else {
1285 removals = remove_if(hashMap&: d->directChats,
1286 pred: [&roomId](auto it) { return it.value() == roomId; });
1287 d->directChatUsers.remove(key: roomId);
1288 d->dcLocalRemovals += removals;
1289 }
1290 emit directChatsListChanged(additions: {}, removals);
1291}
1292
1293bool Connection::isDirectChat(const QString& roomId) const
1294{
1295 return d->directChatUsers.contains(key: roomId);
1296}
1297
1298QList<User*> Connection::directChatUsers(const Room* room) const
1299{
1300 Q_ASSERT(room != nullptr);
1301 return d->directChatUsers.values(key: room->id());
1302}
1303
1304bool Connection::isIgnored(const QString& userId) const
1305{
1306 return ignoredUsers().contains(value: userId);
1307}
1308
1309bool Connection::isIgnored(const User* user) const
1310{
1311 Q_ASSERT(user != nullptr);
1312 return isIgnored(userId: user->id());
1313}
1314
1315IgnoredUsersList Connection::ignoredUsers() const
1316{
1317 const auto* event = accountData<IgnoredUsersEvent>();
1318 return event ? event->ignoredUsers() : IgnoredUsersList();
1319}
1320
1321void Connection::addToIgnoredUsers(const User* user)
1322{
1323 Q_ASSERT(user != nullptr);
1324
1325 auto ignoreList = ignoredUsers();
1326 if (!ignoreList.contains(value: user->id())) {
1327 ignoreList.insert(value: user->id());
1328 d->packAndSendAccountData<IgnoredUsersEvent>(content&: ignoreList);
1329 emit ignoredUsersListChanged(additions: { { user->id() } }, removals: {});
1330 }
1331}
1332
1333void Connection::removeFromIgnoredUsers(const User* user)
1334{
1335 Q_ASSERT(user != nullptr);
1336
1337 auto ignoreList = ignoredUsers();
1338 if (ignoreList.remove(value: user->id()) != 0) {
1339 d->packAndSendAccountData<IgnoredUsersEvent>(content&: ignoreList);
1340 emit ignoredUsersListChanged(additions: {}, removals: { { user->id() } });
1341 }
1342}
1343
1344QMap<QString, User*> Connection::users() const { return d->userMap; }
1345
1346const ConnectionData* Connection::connectionData() const
1347{
1348 return d->data.get();
1349}
1350
1351Room* Connection::provideRoom(const QString& id, Omittable<JoinState> joinState)
1352{
1353 // TODO: This whole function is a strong case for a RoomManager class.
1354 Q_ASSERT_X(!id.isEmpty(), __FUNCTION__, "Empty room id");
1355
1356 // If joinState is empty, all joinState == comparisons below are false.
1357 const std::pair roomKey { id, joinState == JoinState::Invite };
1358 auto* room = d->roomMap.value(key: roomKey, defaultValue: nullptr);
1359 if (room) {
1360 // Leave is a special case because in transition (5a) (see the .h file)
1361 // joinState == room->joinState but we still have to preempt the Invite
1362 // and emit a signal. For Invite and Join, there's no such problem.
1363 if (room->joinState() == joinState && joinState != JoinState::Leave)
1364 return room;
1365 } else if (!joinState) {
1366 // No Join and Leave, maybe Invite?
1367 room = d->roomMap.value(key: { id, true }, defaultValue: nullptr);
1368 if (room)
1369 return room;
1370 // No Invite either, setup a new room object in Join state
1371 joinState = JoinState::Join;
1372 }
1373
1374 if (!room) {
1375 Q_ASSERT(joinState.has_value());
1376 room = roomFactory()(this, id, *joinState);
1377 if (!room) {
1378 qCCritical(MAIN) << "Failed to create a room" << id;
1379 return nullptr;
1380 }
1381 d->roomMap.insert(key: roomKey, value: room);
1382 connect(sender: room, signal: &Room::beforeDestruction, context: this,
1383 slot: &Connection::aboutToDeleteRoom);
1384 connect(sender: room, signal: &Room::baseStateLoaded, context: this, slot: [this, room] {
1385 emit loadedRoomState(room);
1386 if (d->capabilities.roomVersions)
1387 room->checkVersion();
1388 // Otherwise, the version will be checked in reloadCapabilities()
1389 });
1390 emit newRoom(room);
1391 }
1392 if (!joinState)
1393 return room;
1394
1395 if (*joinState == JoinState::Invite) {
1396 // prev is either Leave or nullptr
1397 auto* prev = d->roomMap.value(key: { id, false }, defaultValue: nullptr);
1398 emit invitedRoom(room, prev);
1399 } else {
1400 room->setJoinState(*joinState);
1401 // Preempt the Invite room (if any) with a room in Join/Leave state.
1402 auto* prevInvite = d->roomMap.take(key: { id, true });
1403 if (*joinState == JoinState::Join)
1404 emit joinedRoom(room, prev: prevInvite);
1405 else if (*joinState == JoinState::Leave)
1406 emit leftRoom(room, prev: prevInvite);
1407 if (prevInvite) {
1408 const auto dcUsers = prevInvite->directChatUsers();
1409 for (auto* u : dcUsers)
1410 addToDirectChats(room, user: u);
1411 qCDebug(MAIN) << "Deleting Invite state for room"
1412 << prevInvite->id();
1413 emit prevInvite->beforeDestruction(prevInvite);
1414 prevInvite->deleteLater();
1415 }
1416 }
1417
1418 return room;
1419}
1420
1421#ifdef Quotient_E2EE_ENABLED
1422void Connection::setEncryptionDefault(bool useByDefault)
1423{
1424 Private::encryptionDefault = useByDefault;
1425}
1426#endif
1427
1428void Connection::setRoomFactory(room_factory_t f)
1429{
1430 _roomFactory = std::move(f);
1431}
1432
1433void Connection::setUserFactory(user_factory_t f)
1434{
1435 _userFactory = std::move(f);
1436}
1437
1438room_factory_t Connection::roomFactory() { return _roomFactory; }
1439
1440user_factory_t Connection::userFactory() { return _userFactory; }
1441
1442room_factory_t Connection::_roomFactory = defaultRoomFactory<>;
1443user_factory_t Connection::_userFactory = defaultUserFactory<>;
1444
1445QString Connection::generateTxnId() const
1446{
1447 return d->data->generateTxnId();
1448}
1449
1450void Connection::setHomeserver(const QUrl& url)
1451{
1452 if (isJobPending(job: d->resolverJob))
1453 d->resolverJob->abandon();
1454 if (isJobPending(job: d->loginFlowsJob))
1455 d->loginFlowsJob->abandon();
1456 d->loginFlows.clear();
1457
1458 if (homeserver() != url) {
1459 d->data->setBaseUrl(url);
1460 emit homeserverChanged(baseUrl: homeserver());
1461 }
1462
1463 // Whenever a homeserver is updated, retrieve available login flows from it
1464 d->loginFlowsJob = callApi<GetLoginFlowsJob>(runningPolicy: BackgroundRequest);
1465 connect(sender: d->loginFlowsJob, signal: &BaseJob::result, context: this, slot: [this] {
1466 if (d->loginFlowsJob->status().good())
1467 d->loginFlows = d->loginFlowsJob->flows();
1468 else
1469 d->loginFlows.clear();
1470 emit loginFlowsChanged();
1471 });
1472}
1473
1474void Connection::saveRoomState(Room* r) const
1475{
1476 Q_ASSERT(r);
1477 if (!d->cacheState)
1478 return;
1479
1480 QFile outRoomFile { stateCacheDir().filePath(
1481 fileName: SyncData::fileNameForRoom(roomId: r->id())) };
1482 if (outRoomFile.open(flags: QFile::WriteOnly)) {
1483 const auto data =
1484 d->cacheToBinary
1485 ? QCborValue::fromJsonValue(v: r->toJson()).toCbor()
1486 : QJsonDocument(r->toJson()).toJson(format: QJsonDocument::Compact);
1487 outRoomFile.write(data: data.data(), len: data.size());
1488 qCDebug(MAIN) << "Room state cache saved to" << outRoomFile.fileName();
1489 } else {
1490 qCWarning(MAIN) << "Error opening" << outRoomFile.fileName() << ":"
1491 << outRoomFile.errorString();
1492 }
1493}
1494
1495void Connection::saveState() const
1496{
1497 if (!d->cacheState)
1498 return;
1499
1500 QElapsedTimer et;
1501 et.start();
1502
1503 QFile outFile { d->topLevelStatePath() };
1504 if (!outFile.open(flags: QFile::WriteOnly)) {
1505 qCWarning(MAIN) << "Error opening" << outFile.fileName() << ":"
1506 << outFile.errorString();
1507 qCWarning(MAIN) << "Caching the rooms state disabled";
1508 d->cacheState = false;
1509 return;
1510 }
1511
1512 QJsonObject rootObj {
1513 { QStringLiteral("cache_version"),
1514 QJsonObject {
1515 { QStringLiteral("major"), SyncData::cacheVersion().first },
1516 { QStringLiteral("minor"), SyncData::cacheVersion().second } } }
1517 };
1518 {
1519 QJsonObject roomsJson;
1520 QJsonObject inviteRoomsJson;
1521 for (const auto* r: qAsConst(t&: d->roomMap)) {
1522 if (r->joinState() == JoinState::Leave)
1523 continue;
1524 (r->joinState() == JoinState::Invite ? inviteRoomsJson : roomsJson)
1525 .insert(key: r->id(), value: QJsonValue::Null);
1526 }
1527
1528 QJsonObject roomObj;
1529 if (!roomsJson.isEmpty())
1530 roomObj.insert(QStringLiteral("join"), value: roomsJson);
1531 if (!inviteRoomsJson.isEmpty())
1532 roomObj.insert(QStringLiteral("invite"), value: inviteRoomsJson);
1533
1534 rootObj.insert(QStringLiteral("next_batch"), value: d->data->lastEvent());
1535 rootObj.insert(QStringLiteral("rooms"), value: roomObj);
1536 }
1537 {
1538 QJsonArray accountDataEvents {
1539 Event::basicJson(matrixType: DirectChatEvent::TypeId, content: toJson(directChats: d->directChats))
1540 };
1541 for (const auto& e : d->accountData)
1542 accountDataEvents.append(
1543 value: Event::basicJson(matrixType: e.first, content: e.second->contentJson()));
1544
1545 rootObj.insert(QStringLiteral("account_data"),
1546 value: QJsonObject {
1547 { QStringLiteral("events"), accountDataEvents } });
1548 }
1549
1550#ifdef Quotient_E2EE_ENABLED
1551 if (d->encryptionData) {
1552 QJsonObject keysJson = toJson(d->encryptionData->oneTimeKeysCount);
1553 rootObj.insert(QStringLiteral("device_one_time_keys_count"), keysJson);
1554 }
1555#endif
1556
1557 const auto data =
1558 d->cacheToBinary ? QCborValue::fromJsonValue(v: rootObj).toCbor()
1559 : QJsonDocument(rootObj).toJson(format: QJsonDocument::Compact);
1560 qCDebug(PROFILER) << "Cache for" << userId() << "generated in" << et;
1561
1562 outFile.write(data: data.data(), len: data.size());
1563 qCDebug(MAIN) << "State cache saved to" << outFile.fileName();
1564}
1565
1566void Connection::loadState()
1567{
1568 if (!d->cacheState)
1569 return;
1570
1571 QElapsedTimer et;
1572 et.start();
1573
1574 SyncData sync { d->topLevelStatePath() };
1575 if (sync.nextBatch().isEmpty()) // No token means no cache by definition
1576 return;
1577
1578 if (!sync.unresolvedRooms().isEmpty()) {
1579 qCWarning(MAIN) << "State cache incomplete, discarding";
1580 return;
1581 }
1582 // TODO: to handle load failures, instead of the above block:
1583 // 1. Do initial sync on failed rooms without saving the nextBatch token
1584 // 2. Do the sync across all rooms as normal
1585 onSyncSuccess(data: std::move(sync), fromCache: true);
1586 qCDebug(PROFILER) << "*** Cached state for" << userId() << "loaded in" << et;
1587}
1588
1589QString Connection::stateCachePath() const
1590{
1591 return stateCacheDir().path() % u'/';
1592}
1593
1594QDir Connection::stateCacheDir() const
1595{
1596 auto safeUserId = userId();
1597 safeUserId.replace(before: u':', after: u'_');
1598 return cacheLocation(dirName: safeUserId);
1599}
1600
1601bool Connection::cacheState() const { return d->cacheState; }
1602
1603void Connection::setCacheState(bool newValue)
1604{
1605 if (d->cacheState != newValue) {
1606 d->cacheState = newValue;
1607 emit cacheStateChanged();
1608 }
1609}
1610
1611bool Connection::lazyLoading() const { return d->lazyLoading; }
1612
1613void Connection::setLazyLoading(bool newValue)
1614{
1615 if (d->lazyLoading != newValue) {
1616 d->lazyLoading = newValue;
1617 emit lazyLoadingChanged();
1618 }
1619}
1620
1621BaseJob* Connection::run(BaseJob* job, RunningPolicy runningPolicy)
1622{
1623 // Reparent to protect from #397, #398 and to prevent BaseJob* from being
1624 // garbage-collected if made by or returned to QML/JavaScript.
1625 job->setParent(this);
1626 connect(sender: job, signal: &BaseJob::failure, context: this, slot: &Connection::requestFailed);
1627 job->initiate(connData: d->data.get(), inBackground: runningPolicy & BackgroundRequest);
1628 return job;
1629}
1630
1631void Connection::getTurnServers()
1632{
1633 auto job = callApi<GetTurnServerJob>();
1634 connect(sender: job, signal: &GetTurnServerJob::success, context: this,
1635 slot: [this,job] { emit turnServersChanged(servers: job->data()); });
1636}
1637
1638const QString Connection::SupportedRoomVersion::StableTag =
1639 QStringLiteral("stable");
1640
1641QString Connection::defaultRoomVersion() const
1642{
1643 return d->capabilities.roomVersions
1644 ? d->capabilities.roomVersions->defaultVersion
1645 : QString();
1646}
1647
1648QStringList Connection::stableRoomVersions() const
1649{
1650 QStringList l;
1651 if (d->capabilities.roomVersions) {
1652 const auto& allVersions = d->capabilities.roomVersions->available;
1653 for (auto it = allVersions.begin(); it != allVersions.end(); ++it)
1654 if (it.value() == SupportedRoomVersion::StableTag)
1655 l.push_back(t: it.key());
1656 }
1657 return l;
1658}
1659
1660bool Connection::canChangePassword() const
1661{
1662 // By default assume we can
1663 return d->capabilities.changePassword
1664 ? d->capabilities.changePassword->enabled
1665 : true;
1666}
1667
1668bool Connection::encryptionEnabled() const
1669{
1670 return d->useEncryption;
1671}
1672
1673void Connection::enableEncryption(bool enable)
1674{
1675 if (enable == d->useEncryption)
1676 return;
1677
1678 if (isLoggedIn()) {
1679 qWarning(catFunc: E2EE) << "It's only possible to enable/disable E2EE "
1680 "before logging in; the account"
1681 << objectName()
1682 << "is already logged in, the E2EE state will remain"
1683 << d->useEncryption;
1684 return;
1685 }
1686
1687#ifdef Quotient_E2EE_ENABLED
1688 d->useEncryption = enable;
1689 emit encryptionChanged(enable);
1690#else
1691 Q_UNUSED(enable)
1692 qWarning(catFunc: E2EE) << "The library is compiled without E2EE support, "
1693 "enabling encryption has no effect";
1694#endif
1695}
1696
1697inline bool roomVersionLess(const Connection::SupportedRoomVersion& v1,
1698 const Connection::SupportedRoomVersion& v2)
1699{
1700 bool ok1 = false, ok2 = false;
1701 const auto vNum1 = v1.id.toFloat(ok: &ok1);
1702 const auto vNum2 = v2.id.toFloat(ok: &ok2);
1703 return ok1 && ok2 ? vNum1 < vNum2 : v1.id < v2.id;
1704}
1705
1706QVector<Connection::SupportedRoomVersion> Connection::availableRoomVersions() const
1707{
1708 QVector<SupportedRoomVersion> result;
1709 if (d->capabilities.roomVersions) {
1710 const auto& allVersions = d->capabilities.roomVersions->available;
1711 result.reserve(asize: allVersions.size());
1712 for (auto it = allVersions.begin(); it != allVersions.end(); ++it)
1713 result.push_back(t: { .id: it.key(), .status: it.value() });
1714 // Put stable versions over unstable; within each group,
1715 // sort numeric versions as numbers, the rest as strings.
1716 const auto mid =
1717 std::partition(first: result.begin(), last: result.end(),
1718 pred: std::mem_fn(pm: &SupportedRoomVersion::isStable));
1719 std::sort(first: result.begin(), last: mid, comp: roomVersionLess);
1720 std::sort(first: mid, last: result.end(), comp: roomVersionLess);
1721 }
1722 return result;
1723}
1724
1725#ifdef Quotient_E2EE_ENABLED
1726bool Connection::isQueryingKeys() const
1727{
1728 return d->encryptionData
1729 && d->encryptionData->currentQueryKeysJob != nullptr;
1730}
1731
1732void Connection::encryptionUpdate(const Room* room, const QList<User*>& invited)
1733{
1734 if (d->encryptionData)
1735 d->encryptionData->encryptionUpdate(room->users() + invited);
1736}
1737
1738QJsonObject Connection::decryptNotification(const QJsonObject& notification)
1739{
1740 if (auto r = room(notification[RoomIdKey].toString()))
1741 if (auto event =
1742 loadEvent<EncryptedEvent>(notification["event"_ls].toObject()))
1743 if (const auto decrypted = r->decryptMessage(*event))
1744 return decrypted->fullJson();
1745 return {};
1746}
1747
1748Database* Connection::database() const
1749{
1750 return d->encryptionData ? &d->encryptionData->database : nullptr;
1751}
1752
1753UnorderedMap<QByteArray, QOlmInboundGroupSession>
1754Connection::loadRoomMegolmSessions(const Room* room) const
1755{
1756 return database()->loadMegolmSessions(room->id());
1757}
1758
1759void Connection::saveMegolmSession(const Room* room,
1760 const QOlmInboundGroupSession& session) const
1761{
1762 database()->saveMegolmSession(room->id(), session);
1763}
1764
1765QStringList Connection::devicesForUser(const QString& userId) const
1766{
1767 return d->encryptionData->deviceKeys.value(userId).keys();
1768}
1769
1770QString Connection::edKeyForUserDevice(const QString& userId,
1771 const QString& deviceId) const
1772{
1773 return d->encryptionData->deviceKeys[userId][deviceId]
1774 .keys["ed25519:"_ls + deviceId];
1775}
1776
1777bool Connection::hasOlmSession(const QString& user,
1778 const QString& deviceId) const
1779{
1780 return d->encryptionData && d->encryptionData->hasOlmSession(user, deviceId);
1781}
1782
1783void Connection::sendSessionKeyToDevices(
1784 const QString& roomId, const QOlmOutboundGroupSession& outboundSession,
1785 const QMultiHash<QString, QString>& devices)
1786{
1787 Q_ASSERT(d->encryptionData != nullptr);
1788 d->encryptionData->sendSessionKeyToDevices(roomId, outboundSession, devices);
1789}
1790
1791Omittable<QOlmOutboundGroupSession> Connection::loadCurrentOutboundMegolmSession(
1792 const QString& roomId) const
1793{
1794 const auto& db = database();
1795 Q_ASSERT_X(
1796 db, __FUNCTION__,
1797 "Check encryptionData() or database() before calling this method");
1798 return db ? db->loadCurrentOutboundMegolmSession(roomId) : none;
1799}
1800
1801void Connection::saveCurrentOutboundMegolmSession(
1802 const QString& roomId, const QOlmOutboundGroupSession& session) const
1803{
1804 if (const auto& db = database())
1805 db->saveCurrentOutboundMegolmSession(roomId, session);
1806 else
1807 Q_ASSERT_X(
1808 false, __FUNCTION__,
1809 "Check encryptionData() or database() before calling this method");
1810}
1811
1812KeyVerificationSession* Connection::startKeyVerificationSession(
1813 const QString& userId, const QString& deviceId)
1814{
1815 if (!d->encryptionData) {
1816 qWarning(E2EE) << "E2EE is switched off on" << objectName()
1817 << "- you can't start a verification session on it";
1818 return nullptr;
1819 }
1820 return d->encryptionData->setupKeyVerificationSession(userId, deviceId,
1821 this);
1822}
1823
1824void Connection::sendToDevice(const QString& targetUserId,
1825 const QString& targetDeviceId, const Event& event,
1826 bool encrypted)
1827{
1828 if (encrypted && !d->encryptionData) {
1829 qWarning(E2EE) << "E2EE is off for" << objectName()
1830 << "- no encrypted to-device message will be sent";
1831 return;
1832 }
1833
1834 const auto contentJson =
1835 encrypted
1836 ? d->encryptionData->assembleEncryptedContent(event.fullJson(),
1837 targetUserId,
1838 targetDeviceId)
1839 : event.contentJson();
1840 sendToDevices(encrypted ? EncryptedEvent::TypeId : event.matrixType(),
1841 { { targetUserId, { { targetDeviceId, contentJson } } } });
1842}
1843
1844bool Connection::isVerifiedSession(const QByteArray& megolmSessionId) const
1845{
1846 auto query = database()->prepareQuery("SELECT olmSessionId FROM inbound_megolm_sessions WHERE sessionId=:sessionId;"_ls);
1847 query.bindValue(":sessionId"_ls, megolmSessionId);
1848 database()->execute(query);
1849 if (!query.next()) {
1850 return false;
1851 }
1852 auto olmSessionId = query.value("olmSessionId"_ls).toString();
1853 query.prepare("SELECT senderKey FROM olm_sessions WHERE sessionId=:sessionId;"_ls);
1854 query.bindValue(":sessionId"_ls, olmSessionId.toLatin1());
1855 database()->execute(query);
1856 if (!query.next()) {
1857 return false;
1858 }
1859 auto curveKey = query.value("senderKey"_ls).toString();
1860 query.prepare("SELECT verified FROM tracked_devices WHERE curveKey=:curveKey;"_ls);
1861 query.bindValue(":curveKey"_ls, curveKey);
1862 database()->execute(query);
1863 return query.next() && query.value("verified"_ls).toBool();
1864}
1865
1866bool Connection::isVerifiedDevice(const QString& userId, const QString& deviceId) const
1867{
1868 auto query = database()->prepareQuery("SELECT verified FROM tracked_devices WHERE deviceId=:deviceId AND matrixId=:matrixId;"_ls);
1869 query.bindValue(":deviceId"_ls, deviceId);
1870 query.bindValue(":matrixId"_ls, userId);
1871 database()->execute(query);
1872 return query.next() && query.value("verified"_ls).toBool();
1873}
1874
1875bool Connection::isKnownE2eeCapableDevice(const QString& userId, const QString& deviceId) const
1876{
1877 auto query = database()->prepareQuery("SELECT verified FROM tracked_devices WHERE deviceId=:deviceId AND matrixId=:matrixId;"_ls);
1878 query.bindValue(":deviceId"_ls, deviceId);
1879 query.bindValue(":matrixId"_ls, userId);
1880 database()->execute(query);
1881 return query.next();
1882}
1883
1884#endif
1885
1886Connection* Connection::makeMockConnection(const QString& mxId,
1887 bool enableEncryption)
1888{
1889 auto* c = new Connection;
1890 c->enableEncryption(enable: enableEncryption);
1891 c->d->completeSetup(mxId, mock: true);
1892 return c;
1893}
1894