| 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 | |
| 60 | using namespace Quotient; |
| 61 | |
| 62 | // This is very much Qt-specific; STL iterators don't have key() and value() |
| 63 | template <typename HashT, typename Pred> |
| 64 | HashT 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 | |
| 77 | Connection::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 | |
| 88 | Connection::Connection(QObject* parent) : Connection({}, parent) {} |
| 89 | |
| 90 | Connection::~Connection() |
| 91 | { |
| 92 | qCDebug(MAIN) << "deconstructing connection object for" << userId(); |
| 93 | stopSync(); |
| 94 | } |
| 95 | |
| 96 | void 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 | |
| 153 | inline UserIdentifier makeUserIdentifier(const QString& id) |
| 154 | { |
| 155 | return { QStringLiteral("m.id.user" ), .additionalProperties: { { QStringLiteral("user" ), id } } }; |
| 156 | } |
| 157 | |
| 158 | inline 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 | |
| 166 | void 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 | |
| 177 | SsoSession* Connection::(const QString& initialDeviceName, |
| 178 | const QString& deviceId) |
| 179 | { |
| 180 | return new SsoSession(this, initialDeviceName, deviceId); |
| 181 | } |
| 182 | |
| 183 | void 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 | |
| 193 | void Connection::assumeIdentity(const QString& mxId, const QString& accessToken, |
| 194 | [[maybe_unused]] const QString& deviceId) |
| 195 | { |
| 196 | assumeIdentity(mxId, accessToken); |
| 197 | } |
| 198 | |
| 199 | void 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 | |
| 222 | void 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 | |
| 246 | bool 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 | |
| 253 | void 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 | |
| 271 | void 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 | |
| 303 | template <typename... LoginArgTs> |
| 304 | void 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 | |
| 323 | void 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 | |
| 360 | void 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(ch: 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 | |
| 396 | void 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 | |
| 427 | void 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 | |
| 468 | void 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 | |
| 487 | void 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 | |
| 495 | QJsonObject 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 | |
| 508 | void 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 | |
| 529 | void 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 | |
| 560 | void 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 | |
| 644 | void Connection::Private::consumePresenceData(Events&& presenceData) |
| 645 | { |
| 646 | // To be implemented |
| 647 | } |
| 648 | |
| 649 | void Connection::Private::consumeToDeviceEvents(Events&& toDeviceEvents) |
| 650 | { |
| 651 | #ifdef Quotient_E2EE_ENABLED |
| 652 | if (encryptionData) |
| 653 | encryptionData->consumeToDeviceEvents(std::move(toDeviceEvents)); |
| 654 | #endif |
| 655 | } |
| 656 | |
| 657 | void 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 | |
| 669 | QString Connection::nextBatchToken() const { return d->data->lastEvent(); } |
| 670 | |
| 671 | JoinRoomJob* 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 | |
| 686 | LeaveRoomJob* 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 | |
| 705 | inline 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 | |
| 714 | QUrl 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 | |
| 723 | MediaThumbnailJob* 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 | |
| 732 | MediaThumbnailJob* Connection::getThumbnail(const QUrl& url, QSize requestedSize, |
| 733 | RunningPolicy policy) |
| 734 | { |
| 735 | return getThumbnail(mediaId: url.authority() + url.path(), requestedSize, policy); |
| 736 | } |
| 737 | |
| 738 | MediaThumbnailJob* 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 | |
| 745 | UploadContentJob* |
| 746 | Connection::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 | |
| 764 | UploadContentJob* 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 | |
| 772 | GetContentJob* Connection::getContent(const QString& mediaId) |
| 773 | { |
| 774 | auto idParts = splitMediaId(mediaId); |
| 775 | return callApi<GetContentJob>(jobArgs&: idParts.front(), jobArgs&: idParts.back()); |
| 776 | } |
| 777 | |
| 778 | GetContentJob* Connection::getContent(const QUrl& url) |
| 779 | { |
| 780 | return getContent(mediaId: url.authority() + url.path()); |
| 781 | } |
| 782 | |
| 783 | DownloadFileJob* 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 |
| 794 | DownloadFileJob* 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 | |
| 805 | CreateRoomJob* |
| 806 | Connection::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 | |
| 836 | void Connection::requestDirectChat(const QString& userId) |
| 837 | { |
| 838 | doInDirectChat(userId, operation: [this](Room* r) { emit directChatAvailable(directChat: r); }); |
| 839 | } |
| 840 | |
| 841 | void Connection::requestDirectChat(User* u) |
| 842 | { |
| 843 | doInDirectChat(u, operation: [this](Room* r) { emit directChatAvailable(directChat: r); }); |
| 844 | } |
| 845 | |
| 846 | void 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 | |
| 857 | void 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 | |
| 919 | CreateRoomJob* 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 | |
| 927 | ForgetRoomJob* 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 | |
| 972 | SendToDeviceJob* 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 | |
| 979 | SendMessageJob* 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 | |
| 988 | QUrl Connection::homeserver() const { return d->data->baseUrl(); } |
| 989 | |
| 990 | QString Connection::domain() const { return userId().section(asep: u':', astart: 1); } |
| 991 | |
| 992 | bool Connection::isUsable() const { return !loginFlows().isEmpty(); } |
| 993 | |
| 994 | QVector<GetLoginFlowsJob::LoginFlow> Connection::loginFlows() const |
| 995 | { |
| 996 | return d->loginFlows; |
| 997 | } |
| 998 | |
| 999 | bool Connection::supportsPasswordAuth() const |
| 1000 | { |
| 1001 | return d->loginFlows.contains(t: LoginFlows::Password); |
| 1002 | } |
| 1003 | |
| 1004 | bool Connection::supportsSso() const |
| 1005 | { |
| 1006 | return d->loginFlows.contains(t: LoginFlows::SSO); |
| 1007 | } |
| 1008 | |
| 1009 | Room* 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 | |
| 1027 | Room* 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 | |
| 1038 | bool 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 | |
| 1064 | void 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 | |
| 1093 | Room* Connection::invitation(const QString& roomId) const |
| 1094 | { |
| 1095 | return d->roomMap.value(key: { roomId, true }, defaultValue: nullptr); |
| 1096 | } |
| 1097 | |
| 1098 | User* 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 | |
| 1116 | const User* Connection::user() const |
| 1117 | { |
| 1118 | return d->userMap.value(key: userId(), defaultValue: nullptr); |
| 1119 | } |
| 1120 | |
| 1121 | User* Connection::user() { return user(uId: userId()); } |
| 1122 | |
| 1123 | QString Connection::userId() const { return d->data->userId(); } |
| 1124 | |
| 1125 | QString Connection::deviceId() const { return d->data->deviceId(); } |
| 1126 | |
| 1127 | QByteArray 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 | |
| 1134 | bool Connection::isLoggedIn() const { return !accessToken().isEmpty(); } |
| 1135 | |
| 1136 | #ifdef Quotient_E2EE_ENABLED |
| 1137 | QOlmAccount* Connection::olmAccount() const |
| 1138 | { |
| 1139 | return d->encryptionData ? &d->encryptionData->olmAccount : nullptr; |
| 1140 | } |
| 1141 | #endif // Quotient_E2EE_ENABLED |
| 1142 | |
| 1143 | SyncJob* Connection::syncJob() const { return d->syncJob; } |
| 1144 | |
| 1145 | int Connection::millisToReconnect() const |
| 1146 | { |
| 1147 | return d->syncJob ? d->syncJob->millisToRetry() : 0; |
| 1148 | } |
| 1149 | |
| 1150 | QVector<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 | |
| 1158 | QVector<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 | |
| 1167 | int 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 | |
| 1177 | bool Connection::hasAccountData(const QString& type) const |
| 1178 | { |
| 1179 | return d->accountData.find(x: type) != d->accountData.cend(); |
| 1180 | } |
| 1181 | |
| 1182 | const 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 | |
| 1189 | QJsonObject Connection::accountDataJson(const QString& type) const |
| 1190 | { |
| 1191 | const auto& eventPtr = accountData(type); |
| 1192 | return eventPtr ? eventPtr->contentJson() : QJsonObject(); |
| 1193 | } |
| 1194 | |
| 1195 | void Connection::setAccountData(EventPtr&& event) |
| 1196 | { |
| 1197 | d->packAndSendAccountData(event: std::move(event)); |
| 1198 | } |
| 1199 | |
| 1200 | void Connection::setAccountData(const QString& type, const QJsonObject& content) |
| 1201 | { |
| 1202 | d->packAndSendAccountData(event: loadEvent<Event>(matrixType: type, otherBasicJsonParams: content)); |
| 1203 | } |
| 1204 | |
| 1205 | QHash<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 | |
| 1220 | QStringList 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 | |
| 1233 | QVector<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 | |
| 1242 | DirectChatsMap Connection::directChats() const |
| 1243 | { |
| 1244 | return d->directChats; |
| 1245 | } |
| 1246 | |
| 1247 | // Removes room with given id from roomMap |
| 1248 | void 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 | |
| 1259 | void 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 | |
| 1271 | void 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 | |
| 1293 | bool Connection::isDirectChat(const QString& roomId) const |
| 1294 | { |
| 1295 | return d->directChatUsers.contains(key: roomId); |
| 1296 | } |
| 1297 | |
| 1298 | QList<User*> Connection::directChatUsers(const Room* room) const |
| 1299 | { |
| 1300 | Q_ASSERT(room != nullptr); |
| 1301 | return d->directChatUsers.values(key: room->id()); |
| 1302 | } |
| 1303 | |
| 1304 | bool Connection::isIgnored(const QString& userId) const |
| 1305 | { |
| 1306 | return ignoredUsers().contains(value: userId); |
| 1307 | } |
| 1308 | |
| 1309 | bool Connection::isIgnored(const User* user) const |
| 1310 | { |
| 1311 | Q_ASSERT(user != nullptr); |
| 1312 | return isIgnored(userId: user->id()); |
| 1313 | } |
| 1314 | |
| 1315 | IgnoredUsersList Connection::ignoredUsers() const |
| 1316 | { |
| 1317 | const auto* event = accountData<IgnoredUsersEvent>(); |
| 1318 | return event ? event->ignoredUsers() : IgnoredUsersList(); |
| 1319 | } |
| 1320 | |
| 1321 | void 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 | |
| 1333 | void 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 | |
| 1344 | QMap<QString, User*> Connection::users() const { return d->userMap; } |
| 1345 | |
| 1346 | const ConnectionData* Connection::connectionData() const |
| 1347 | { |
| 1348 | return d->data.get(); |
| 1349 | } |
| 1350 | |
| 1351 | Room* 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 |
| 1422 | void Connection::setEncryptionDefault(bool useByDefault) |
| 1423 | { |
| 1424 | Private::encryptionDefault = useByDefault; |
| 1425 | } |
| 1426 | #endif |
| 1427 | |
| 1428 | void Connection::setRoomFactory(room_factory_t f) |
| 1429 | { |
| 1430 | _roomFactory = std::move(f); |
| 1431 | } |
| 1432 | |
| 1433 | void Connection::setUserFactory(user_factory_t f) |
| 1434 | { |
| 1435 | _userFactory = std::move(f); |
| 1436 | } |
| 1437 | |
| 1438 | room_factory_t Connection::roomFactory() { return _roomFactory; } |
| 1439 | |
| 1440 | user_factory_t Connection::userFactory() { return _userFactory; } |
| 1441 | |
| 1442 | room_factory_t Connection::_roomFactory = defaultRoomFactory<>; |
| 1443 | user_factory_t Connection::_userFactory = defaultUserFactory<>; |
| 1444 | |
| 1445 | QString Connection::generateTxnId() const |
| 1446 | { |
| 1447 | return d->data->generateTxnId(); |
| 1448 | } |
| 1449 | |
| 1450 | void 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 | |
| 1474 | void 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 | |
| 1495 | void 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 | |
| 1566 | void 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 | |
| 1589 | QString Connection::stateCachePath() const |
| 1590 | { |
| 1591 | return stateCacheDir().path() % u'/'; |
| 1592 | } |
| 1593 | |
| 1594 | QDir Connection::stateCacheDir() const |
| 1595 | { |
| 1596 | auto safeUserId = userId(); |
| 1597 | safeUserId.replace(before: u':', after: u'_'); |
| 1598 | return cacheLocation(dirName: safeUserId); |
| 1599 | } |
| 1600 | |
| 1601 | bool Connection::cacheState() const { return d->cacheState; } |
| 1602 | |
| 1603 | void Connection::setCacheState(bool newValue) |
| 1604 | { |
| 1605 | if (d->cacheState != newValue) { |
| 1606 | d->cacheState = newValue; |
| 1607 | emit cacheStateChanged(); |
| 1608 | } |
| 1609 | } |
| 1610 | |
| 1611 | bool Connection::lazyLoading() const { return d->lazyLoading; } |
| 1612 | |
| 1613 | void Connection::setLazyLoading(bool newValue) |
| 1614 | { |
| 1615 | if (d->lazyLoading != newValue) { |
| 1616 | d->lazyLoading = newValue; |
| 1617 | emit lazyLoadingChanged(); |
| 1618 | } |
| 1619 | } |
| 1620 | |
| 1621 | BaseJob* 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 | |
| 1631 | void 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 | |
| 1638 | const QString Connection::SupportedRoomVersion::StableTag = |
| 1639 | QStringLiteral("stable" ); |
| 1640 | |
| 1641 | QString Connection::defaultRoomVersion() const |
| 1642 | { |
| 1643 | return d->capabilities.roomVersions |
| 1644 | ? d->capabilities.roomVersions->defaultVersion |
| 1645 | : QString(); |
| 1646 | } |
| 1647 | |
| 1648 | QStringList 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 | |
| 1660 | bool Connection::canChangePassword() const |
| 1661 | { |
| 1662 | // By default assume we can |
| 1663 | return d->capabilities.changePassword |
| 1664 | ? d->capabilities.changePassword->enabled |
| 1665 | : true; |
| 1666 | } |
| 1667 | |
| 1668 | bool Connection::encryptionEnabled() const |
| 1669 | { |
| 1670 | return d->useEncryption; |
| 1671 | } |
| 1672 | |
| 1673 | void 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 | |
| 1697 | inline 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 | |
| 1706 | QVector<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 |
| 1726 | bool Connection::isQueryingKeys() const |
| 1727 | { |
| 1728 | return d->encryptionData |
| 1729 | && d->encryptionData->currentQueryKeysJob != nullptr; |
| 1730 | } |
| 1731 | |
| 1732 | void Connection::encryptionUpdate(const Room* room, const QList<User*>& invited) |
| 1733 | { |
| 1734 | if (d->encryptionData) |
| 1735 | d->encryptionData->encryptionUpdate(room->users() + invited); |
| 1736 | } |
| 1737 | |
| 1738 | QJsonObject 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 | |
| 1748 | Database* Connection::database() const |
| 1749 | { |
| 1750 | return d->encryptionData ? &d->encryptionData->database : nullptr; |
| 1751 | } |
| 1752 | |
| 1753 | UnorderedMap<QByteArray, QOlmInboundGroupSession> |
| 1754 | Connection::loadRoomMegolmSessions(const Room* room) const |
| 1755 | { |
| 1756 | return database()->loadMegolmSessions(room->id()); |
| 1757 | } |
| 1758 | |
| 1759 | void Connection::saveMegolmSession(const Room* room, |
| 1760 | const QOlmInboundGroupSession& session) const |
| 1761 | { |
| 1762 | database()->saveMegolmSession(room->id(), session); |
| 1763 | } |
| 1764 | |
| 1765 | QStringList Connection::devicesForUser(const QString& userId) const |
| 1766 | { |
| 1767 | return d->encryptionData->deviceKeys.value(userId).keys(); |
| 1768 | } |
| 1769 | |
| 1770 | QString 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 | |
| 1777 | bool Connection::hasOlmSession(const QString& user, |
| 1778 | const QString& deviceId) const |
| 1779 | { |
| 1780 | return d->encryptionData && d->encryptionData->hasOlmSession(user, deviceId); |
| 1781 | } |
| 1782 | |
| 1783 | void 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 | |
| 1791 | Omittable<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 | |
| 1801 | void 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 | |
| 1812 | KeyVerificationSession* 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 | |
| 1824 | void 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 | |
| 1844 | bool 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 | |
| 1866 | bool 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 | |
| 1875 | bool 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 | |
| 1886 | Connection* 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 | |