From 5eb8f40c602b743fc97bc6f1e418da82ee349671 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Thu, 31 Jul 2025 00:28:00 +0200 Subject: [PATCH] feat: support `notify_file_id` push notifications For this to work we also now need to store the fileId from the sync root folder, otherwise we would never know about new files inside the configured sync root Signed-off-by: Jyrki Gadinger --- src/common/syncjournaldb.cpp | 34 ++++++++++++++++++++++ src/common/syncjournaldb.h | 2 ++ src/gui/folder.cpp | 11 +++++++ src/gui/folder.h | 8 +++++ src/gui/folderman.cpp | 25 ++++++++++++++-- src/gui/folderman.h | 1 + src/libsync/discovery.cpp | 1 + src/libsync/discovery.h | 1 + src/libsync/discoveryphase.cpp | 9 ++++++ src/libsync/discoveryphase.h | 1 + src/libsync/pushnotifications.cpp | 45 ++++++++++++++++++++++++++--- src/libsync/pushnotifications.h | 6 ++++ src/libsync/syncengine.cpp | 11 +++++++ src/libsync/syncengine.h | 4 +++ test/pushnotificationstestutils.cpp | 5 ++++ test/testpushnotifications.cpp | 17 +++++++++++ 16 files changed, 175 insertions(+), 6 deletions(-) diff --git a/src/common/syncjournaldb.cpp b/src/common/syncjournaldb.cpp index dc12518479474..0fa86091f99d9 100644 --- a/src/common/syncjournaldb.cpp +++ b/src/common/syncjournaldb.cpp @@ -1762,6 +1762,40 @@ bool SyncJournalDb::updateLocalMetadata(const QString &filename, return true; } +bool SyncJournalDb::hasFileIds(const QList &fileIds) +{ + QMutexLocker locker(&_mutex); + + if (!checkConnect()) { + return false; + } + + QStringList fileIdStrings = {}; + for (const auto &fileId : fileIds) { + fileIdStrings.append(QString::number(fileId)); + } + + // quick workaround for looking up pure numeric file IDs: simply `round()` that field! + // this will return the file ID as e.g. 12345.0, but that's still good enough to use it + // with the IN operator -- e.g. (12345, 1337, 29001) + SqlQuery query( + QLatin1String{"SELECT 1 FROM metadata WHERE ROUND(fileid) IN (%1) LIMIT 1;"} + .arg(fileIdStrings.join(QLatin1String{", "})).toLocal8Bit(), + _db + ); + + if (!query.exec()) { + qCWarning(lcDb) << "file id query failed:" << query.error(); + return false; + } + + if (query.next().hasData && query.intValue(0) == 1) { + return true; + } + + return false; +} + Optional SyncJournalDb::hasHydratedOrDehydratedFiles(const QByteArray &filename) { QMutexLocker locker(&_mutex); diff --git a/src/common/syncjournaldb.h b/src/common/syncjournaldb.h index 14ba342227779..ebee7a9a2a7a4 100644 --- a/src/common/syncjournaldb.h +++ b/src/common/syncjournaldb.h @@ -76,6 +76,8 @@ class OCSYNC_EXPORT SyncJournalDb : public QObject [[nodiscard]] bool updateLocalMetadata(const QString &filename, qint64 modtime, qint64 size, quint64 inode, const SyncJournalFileLockInfo &lockInfo); + [[nodiscard]] bool hasFileIds(const QList &fileIds); + /// Return value for hasHydratedOrDehydratedFiles() struct HasHydratedDehydrated { diff --git a/src/gui/folder.cpp b/src/gui/folder.cpp index b4a78e8314af2..5d379401c6329 100644 --- a/src/gui/folder.cpp +++ b/src/gui/folder.cpp @@ -92,6 +92,7 @@ Folder::Folder(const FolderDefinition &definition, connect(_accountState.data(), &AccountState::isConnectedChanged, this, &Folder::canSyncChanged); connect(_engine.data(), &SyncEngine::rootEtag, this, &Folder::etagRetrievedFromSyncEngine); + connect(_engine.data(), &SyncEngine::rootFileIdReceived, this, &Folder::rootFileIdReceivedFromSyncEngine); connect(_engine.data(), &SyncEngine::started, this, &Folder::slotSyncStarted, Qt::QueuedConnection); connect(_engine.data(), &SyncEngine::finished, this, &Folder::slotSyncFinished, Qt::QueuedConnection); @@ -397,6 +398,11 @@ void Folder::etagRetrievedFromSyncEngine(const QByteArray &etag, const QDateTime _lastEtag = etag; } +void Folder::rootFileIdReceivedFromSyncEngine(const qint64 fileId) +{ + qCDebug(lcFolder).nospace() << "retrieved root fileId=" << fileId; + _rootFileId = fileId; +} void Folder::showSyncResultPopup() { @@ -1003,6 +1009,11 @@ void Folder::migrateBlackListPath(const QString &legacyPath) } } +bool Folder::hasFileIds(const QList& fileIds) const +{ + return fileIds.contains(_rootFileId) || journalDb()->hasFileIds(fileIds); +} + QString Folder::filePath(const QString& fileName) { const auto folderDir = QDir(_canonicalLocalPath); diff --git a/src/gui/folder.h b/src/gui/folder.h index ae1bea4a754b8..0c7e845c8f19e 100644 --- a/src/gui/folder.h +++ b/src/gui/folder.h @@ -316,6 +316,9 @@ class Folder : public QObject void blacklistPath(const QString &path); void migrateBlackListPath(const QString &legacyPath); + /// whether the current folder contains any of the passed fileIds + [[nodiscard]] bool hasFileIds(const QList& fileIds) const; + signals: void syncStateChange(); void syncStarted(); @@ -429,6 +432,8 @@ private slots: void etagRetrieved(const QByteArray &, const QDateTime &tp); void etagRetrievedFromSyncEngine(const QByteArray &, const QDateTime &time); + void rootFileIdReceivedFromSyncEngine(const qint64 fileId); + void slotEmitFinishedDelayed(); void slotNewBigFolderDiscovered(const QString &, bool isExternal); @@ -585,6 +590,9 @@ private slots: QMetaObject::Connection _officeFileLockReleaseUnlockFailure; QMetaObject::Connection _fileLockSuccess; QMetaObject::Connection _fileLockFailure; + + /// The remote file ID of the current folder. + qint64 _rootFileId = 0; }; } diff --git a/src/gui/folderman.cpp b/src/gui/folderman.cpp index 8c1f1122ae663..e90be494c6884 100644 --- a/src/gui/folderman.cpp +++ b/src/gui/folderman.cpp @@ -2079,7 +2079,7 @@ void FolderMan::slotSetupPushNotifications(const Folder::Map &folderMap) void FolderMan::slotProcessFilesPushNotification(Account *account) { - qCInfo(lcFolderMan) << "Got files push notification for account" << account; + qCDebug(lcFolderMan) << "received notify_file push notification account=" << account->displayName(); for (auto folder : std::as_const(_folderMap)) { // Just run on the folders that belong to this account @@ -2087,7 +2087,27 @@ void FolderMan::slotProcessFilesPushNotification(Account *account) continue; } - qCInfo(lcFolderMan) << "Schedule folder" << folder << "for sync"; + qCInfo(lcFolderMan).nospace() << "scheduling sync account=" << account->displayName() << " folder=" << folder->alias() << " reason=notify_file"; + scheduleFolder(folder); + } +} + +void FolderMan::slotProcessFileIdsPushNotification(Account *account, const QList &fileIds) +{ + qCDebug(lcFolderMan).nospace() << "received notify_file_id push notification account=" << account->displayName() << " fileIds=" << fileIds; + + for (auto folder : std::as_const(_folderMap)) { + // Just run on the folders that belong to this account + if (folder->accountState()->account() != account) { + continue; + } + + if (!folder->hasFileIds(fileIds)) { + qCDebug(lcFolderMan).nospace() << "no matching file ids, ignoring account=" << account->displayName() << " folder=" << folder->alias(); + continue; + } + + qCInfo(lcFolderMan).nospace() << "scheduling sync account=" << account->displayName() << " folder=" << folder->alias() << " reason=notify_file_id"; scheduleFolder(folder); } } @@ -2099,6 +2119,7 @@ void FolderMan::slotConnectToPushNotifications(const AccountPtr &account) if (pushNotificationsFilesReady(account)) { qCInfo(lcFolderMan) << "Push notifications ready"; connect(pushNotifications, &PushNotifications::filesChanged, this, &FolderMan::slotProcessFilesPushNotification, Qt::UniqueConnection); + connect(pushNotifications, &PushNotifications::fileIdsChanged, this, &FolderMan::slotProcessFileIdsPushNotification, Qt::UniqueConnection); } } diff --git a/src/gui/folderman.h b/src/gui/folderman.h index bf3775ad115cb..406310c85d4de 100644 --- a/src/gui/folderman.h +++ b/src/gui/folderman.h @@ -329,6 +329,7 @@ private slots: void slotSetupPushNotifications(const OCC::Folder::Map &); void slotProcessFilesPushNotification(OCC::Account *account); + void slotProcessFileIdsPushNotification(OCC::Account *account, const QList &fileIds); void slotConnectToPushNotifications(const OCC::AccountPtr &account); void slotLeaveShare(const QString &localFile, const QByteArray &folderToken = {}); diff --git a/src/libsync/discovery.cpp b/src/libsync/discovery.cpp index a5e53ce32ba44..059ca56645575 100644 --- a/src/libsync/discovery.cpp +++ b/src/libsync/discovery.cpp @@ -2235,6 +2235,7 @@ DiscoverySingleDirectoryJob *ProcessDirectoryJob::startAsyncServerQuery() } connect(serverJob, &DiscoverySingleDirectoryJob::etag, this, &ProcessDirectoryJob::etag); + connect(serverJob, &DiscoverySingleDirectoryJob::rootFileIdReceived, this, &ProcessDirectoryJob::rootFileIdReceived); connect(serverJob, &DiscoverySingleDirectoryJob::setfolderQuota, this, &ProcessDirectoryJob::setFolderQuota); _discoveryData->_currentlyActiveJobs++; _pendingAsyncJobs++; diff --git a/src/libsync/discovery.h b/src/libsync/discovery.h index 1dc2de2350baa..8635379bf7af5 100644 --- a/src/libsync/discovery.h +++ b/src/libsync/discovery.h @@ -306,6 +306,7 @@ class ProcessDirectoryJob : public QObject // The root etag of this directory was fetched void etag(const QByteArray &, const QDateTime &time); void updatedRootFolderQuota(const int64_t &bytesUsed, const int64_t &bytesAvailable); + void rootFileIdReceived(const qint64 fileId); private slots: void setFolderQuota(const FolderQuota &folderQuota); diff --git a/src/libsync/discoveryphase.cpp b/src/libsync/discoveryphase.cpp index 16e7c8822e0bf..d85906bb1ba93 100644 --- a/src/libsync/discoveryphase.cpp +++ b/src/libsync/discoveryphase.cpp @@ -613,8 +613,17 @@ void DiscoverySingleDirectoryJob::directoryListingIteratedSlot(const QString &fi _localFileId = map.value(QStringLiteral("fileid")).toUtf8(); } if (map.contains("id")) { + // this is from the "oc:id" property, the format is e.g. "00000002oc123xyz987e" _fileId = map.value("id").toUtf8(); } + if (map.contains("fileid")) { + // this is from the "oc:fileid" property, this is the plain ID without any special format (e.g. "2") + bool ok = false; + if (qint64 numericFileId = map.value("fileid").toLongLong(&ok); ok) { + qCDebug(lcDiscovery).nospace() << "received numericFileId=" << numericFileId; + emit rootFileIdReceived(numericFileId); + } + } if (map.contains("is-encrypted") && map.value("is-encrypted") == QStringLiteral("1")) { _encryptionStatusCurrent = SyncFileItem::EncryptionStatus::EncryptedMigratedV2_0; Q_ASSERT(!_fileId.isEmpty()); diff --git a/src/libsync/discoveryphase.h b/src/libsync/discoveryphase.h index 166eaccedb661..d67fc0208f17e 100644 --- a/src/libsync/discoveryphase.h +++ b/src/libsync/discoveryphase.h @@ -186,6 +186,7 @@ class DiscoverySingleDirectoryJob : public QObject void etag(const QByteArray &, const QDateTime &time); void finished(const OCC::HttpResult> &result); void setfolderQuota(const FolderQuota &folderQuota); + void rootFileIdReceived(const qint64 fileId); private slots: void directoryListingIteratedSlot(const QString &, const QMap &); diff --git a/src/libsync/pushnotifications.cpp b/src/libsync/pushnotifications.cpp index c7c259ab0f857..65373231f62f2 100644 --- a/src/libsync/pushnotifications.cpp +++ b/src/libsync/pushnotifications.cpp @@ -7,9 +7,13 @@ #include "creds/abstractcredentials.h" #include "account.h" +#include + namespace { static constexpr int MAX_ALLOWED_FAILED_AUTHENTICATION_ATTEMPTS = 3; static constexpr int PING_INTERVAL = 30 * 1000; + +static constexpr QLatin1String NOTIFY_FILE_ID_PREFIX = QLatin1String{"notify_file_id "}; } namespace OCC { @@ -102,7 +106,9 @@ void PushNotifications::onWebSocketTextMessageReceived(const QString &message) { qCInfo(lcPushNotifications) << "Received push notification:" << message; - if (message == "notify_file") { + if (message.startsWith(NOTIFY_FILE_ID_PREFIX)) { + handleNotifyFileId(message); + } else if (message == "notify_file") { handleNotifyFile(); } else if (message == "notify_activity") { handleNotifyActivity(); @@ -124,7 +130,7 @@ void PushNotifications::onWebSocketError(QAbstractSocket::SocketError error) return; } - qCWarning(lcPushNotifications) << "Websocket error on with account" << _account->url() << error; + qCWarning(lcPushNotifications) << "Websocket error on with account" << _account->displayName() << _account->url() << error; closeWebSocket(); emit connectionLost(); } @@ -153,7 +159,7 @@ bool PushNotifications::tryReconnectToWebSocket() void PushNotifications::onWebSocketSslErrors(const QList &errors) { - qCWarning(lcPushNotifications) << "Websocket ssl errors on with account" << _account->url() << errors; + qCWarning(lcPushNotifications) << "Websocket ssl errors on with account" << _account->displayName() << _account->url() << errors; closeWebSocket(); emit authenticationFailed(); } @@ -164,7 +170,7 @@ void PushNotifications::openWebSocket() const auto capabilities = _account->capabilities(); const auto webSocketUrl = capabilities.pushNotificationsWebSocketUrl(); - qCInfo(lcPushNotifications) << "Open connection to websocket on" << webSocketUrl << "for account" << _account->url(); + qCInfo(lcPushNotifications) << "Open connection to websocket on" << webSocketUrl << "for account" << _account->displayName() << _account->url(); connect(_webSocket, QOverload::of(&QWebSocket::errorOccurred), this, &PushNotifications::onWebSocketError); connect(_webSocket, &QWebSocket::sslErrors, this, &PushNotifications::onWebSocketSslErrors); _webSocket->open(webSocketUrl); @@ -184,6 +190,8 @@ void PushNotifications::handleAuthenticated() { qCInfo(lcPushNotifications) << "Authenticated successful on websocket"; _failedAuthenticationAttemptsCount = 0; + qCDebug(lcPushNotifications) << "Requesting opt-in to 'notify_file_id' notifications"; + _webSocket->sendTextMessage("listen notify_file_id"); _isReady = true; startPingTimer(); emit ready(); @@ -202,6 +210,35 @@ void PushNotifications::handleNotifyFile() emitFilesChanged(); } +void PushNotifications::handleNotifyFileId(const QString &message) +{ + qCDebug(lcPushNotifications) << "File-ID push notification arrived"; + + QList fileIds{}; + QJsonParseError parseError; + const auto fileIdsJson = message.mid(NOTIFY_FILE_ID_PREFIX.length()); + const auto jsonDoc = QJsonDocument::fromJson(fileIdsJson.toUtf8(), &parseError); + + if (parseError.error != QJsonParseError::NoError) { + qCWarning(lcPushNotifications).nospace() << "could not parse received list of file IDs error=" << parseError.error << " errorString=" << parseError.errorString() << " fileIdsJson=" << fileIdsJson; + return; + } + + if (const auto jsonArray = jsonDoc.array(); jsonDoc.isArray()) { + for (const auto& fileid : jsonArray) { + if (const auto fid = fileid.toInteger(); fileid.isDouble()) { + fileIds.push_back(fid); + } + } + } + + if (fileIds.empty()) { + return; + } + + emit fileIdsChanged(_account, fileIds); +} + void PushNotifications::handleInvalidCredentials() { qCInfo(lcPushNotifications) << "Invalid credentials submitted to websocket"; diff --git a/src/libsync/pushnotifications.h b/src/libsync/pushnotifications.h index b18b3f0f6424a..e8b449a0150ba 100644 --- a/src/libsync/pushnotifications.h +++ b/src/libsync/pushnotifications.h @@ -65,6 +65,11 @@ class OWNCLOUDSYNC_EXPORT PushNotifications : public QObject */ void filesChanged(OCC::Account *account); + /** + * Will be emitted if specific files on the server changed + */ + void fileIdsChanged(OCC::Account *account, const QList &fileIds); + /** * Will be emitted if activities have been changed on the server */ @@ -111,6 +116,7 @@ private slots: void handleAuthenticated(); void handleNotifyFile(); + void handleNotifyFileId(const QString &message); void handleInvalidCredentials(); void handleNotifyNotification(); void handleNotifyActivity(); diff --git a/src/libsync/syncengine.cpp b/src/libsync/syncengine.cpp index a9ca164038fb0..2f3bbc43a1be8 100644 --- a/src/libsync/syncengine.cpp +++ b/src/libsync/syncengine.cpp @@ -761,6 +761,7 @@ void SyncEngine::startSync() _discoveryPhase->startJob(discoveryJob); connect(discoveryJob, &ProcessDirectoryJob::etag, this, &SyncEngine::slotRootEtagReceived); connect(discoveryJob, &ProcessDirectoryJob::updatedRootFolderQuota, account().data(), &Account::rootFolderQuotaChanged); + connect(discoveryJob, &ProcessDirectoryJob::rootFileIdReceived, this, &SyncEngine::slotRootFileIdReceived); connect(_discoveryPhase.get(), &DiscoveryPhase::addErrorToGui, this, &SyncEngine::addErrorToGui); } @@ -792,6 +793,16 @@ void SyncEngine::slotRootEtagReceived(const QByteArray &e, const QDateTime &time } } +void SyncEngine::slotRootFileIdReceived(const qint64 fileId) +{ + if (_rootFileIdReceived) { + return; + } + _rootFileId = fileId; + _rootFileIdReceived = true; + emit rootFileIdReceived(fileId); +} + void SyncEngine::slotNewItem(const SyncFileItemPtr &item) { _progressInfo->adjustTotalsForFile(*item); diff --git a/src/libsync/syncengine.h b/src/libsync/syncengine.h index a53838cf4bc29..a7af0a44d7906 100644 --- a/src/libsync/syncengine.h +++ b/src/libsync/syncengine.h @@ -155,6 +155,7 @@ public slots: signals: // During update, before reconcile void rootEtag(const QByteArray &, const QDateTime &); + void rootFileIdReceived(const qint64 fileId); // after the above signals. with the items that actually need propagating void aboutToPropagate(OCC::SyncFileItemVector &); @@ -197,6 +198,7 @@ public slots: private slots: void slotFolderDiscovered(bool local, const QString &folder); void slotRootEtagReceived(const QByteArray &, const QDateTime &time); + void slotRootFileIdReceived(const qint64 fileId); /** When the discovery phase discovers an item */ void slotItemDiscovered(const OCC::SyncFileItemPtr &item); @@ -326,6 +328,8 @@ private slots: QString _localPath; QString _remotePath; QByteArray _remoteRootEtag; + bool _rootFileIdReceived = false; + qint64 _rootFileId = 0; SyncJournalDb *_journal; std::unique_ptr _discoveryPhase; QSharedPointer _propagator; diff --git a/test/pushnotificationstestutils.cpp b/test/pushnotificationstestutils.cpp index 72766c582e05a..148bdda480e93 100644 --- a/test/pushnotificationstestutils.cpp +++ b/test/pushnotificationstestutils.cpp @@ -67,6 +67,11 @@ QWebSocket *FakeWebSocketServer::authenticateAccount(const OCC::AccountPtr accou return nullptr; } + // Wait for notify_file_id opt-in + if (!waitForTextMessages()) { + return nullptr; + } + afterAuthentication(); return socket; diff --git a/test/testpushnotifications.cpp b/test/testpushnotifications.cpp index a3d813eaeff41..2a2fb34d2b545 100644 --- a/test/testpushnotifications.cpp +++ b/test/testpushnotifications.cpp @@ -121,6 +121,23 @@ private slots: QVERIFY(verifyCalledOnceWithAccount(filesChangedSpy, account)); } + void testOnWebSocketTextMessageReceived_notifyFileIdMessage_emitFilesChanged() + { + FakeWebSocketServer fakeServer; + auto account = FakeWebSocketServer::createAccount(); + const auto socket = fakeServer.authenticateAccount(account); + QVERIFY(socket); + QSignalSpy filesChangedSpy(account->pushNotifications(), &OCC::PushNotifications::fileIdsChanged); + + socket->sendTextMessage("notify_file_id [1,2,3,5,8,13,21,34,55,89,144]"); + + // filesChanged signal should be emitted + QVERIFY(filesChangedSpy.wait()); + QVERIFY(verifyCalledOnceWithAccount(filesChangedSpy, account)); + const QList expected = {1,2,3,5,8,13,21,34,55,89,144}; + QCOMPARE(filesChangedSpy.at(0).at(1).toList(), expected); + } + void testOnWebSocketTextMessageReceived_notifyActivityMessage_emitNotification() { FakeWebSocketServer fakeServer;