Explorar el Código

Add push notifications for file changes

Resolves #2802

Signed-off-by: Felix Weilbach <felix.weilbach@nextcloud.com>
Felix Weilbach hace 5 años
padre
commit
78f00acaa2

+ 110 - 21
src/gui/folderman.cpp

@@ -24,6 +24,7 @@
 #include "filesystem.h"
 #include "lockwatcher.h"
 #include "common/asserts.h"
+#include <pushnotifications.h>
 #include <syncengine.h>
 
 #ifdef Q_OS_MAC
@@ -77,6 +78,8 @@ FolderMan::FolderMan(QObject *parent)
 
     connect(_lockWatcher.data(), &LockWatcher::fileUnlocked,
         this, &FolderMan::slotWatchedFileUnlocked);
+
+    connect(this, &FolderMan::folderListChanged, this, &FolderMan::slotSetupPushNotifications);
 }
 
 FolderMan *FolderMan::instance()
@@ -823,32 +826,80 @@ void FolderMan::slotStartScheduledFolderSync()
     }
 }
 
+bool FolderMan::pushNotificationsFilesReady(Account *account)
+{
+    const auto pushNotifications = account->pushNotifications();
+    const auto pushFilesAvailable = account->capabilities().availablePushNotifications() & PushNotificationType::Files;
+
+    return pushFilesAvailable && pushNotifications && pushNotifications->isReady();
+}
+
 void FolderMan::slotEtagPollTimerTimeout()
 {
-    ConfigFile cfg;
-    auto polltime = cfg.remotePollInterval();
+    qCInfo(lcFolderMan) << "Etag poll timer timeout";
 
-    for (Folder *f : qAsConst(_folderMap)) {
-        if (!f) {
-            continue;
-        }
-        if (f->isSyncRunning()) {
-            continue;
-        }
-        if (_scheduledFolders.contains(f)) {
-            continue;
-        }
-        if (_disabledFolders.contains(f)) {
-            continue;
-        }
-        if (f->etagJob() || f->isBusy() || !f->canSync()) {
-            continue;
-        }
-        if (f->msecSinceLastSync() < polltime) {
-            continue;
+    const auto folderMapValues = _folderMap.values();
+
+    qCInfo(lcFolderMan) << "Folders to sync:" << folderMapValues.size();
+
+    QList<Folder *> foldersToRun;
+
+    // Some folders need not to be checked because they use the push notifications
+    std::copy_if(folderMapValues.begin(), folderMapValues.end(), std::back_inserter(foldersToRun), [this](Folder *folder) -> bool {
+        const auto account = folder->accountState()->account();
+        const auto capabilities = account->capabilities();
+        const auto pushNotifications = account->pushNotifications();
+
+        return !pushNotificationsFilesReady(account.data());
+    });
+
+    qCInfo(lcFolderMan) << "Number of folders that don't use push notifications:" << foldersToRun.size();
+
+    runEtagJobsIfPossible(foldersToRun);
+}
+
+void FolderMan::runEtagJobsIfPossible(const QList<Folder *> &folderMap)
+{
+    for (auto folder : folderMap) {
+        runEtagJobIfPossible(folder);
+    }
+}
+
+void FolderMan::runEtagJobIfPossible(Folder *folder)
+{
+    const ConfigFile cfg;
+    const auto polltime = cfg.remotePollInterval();
+
+    qCInfo(lcFolderMan) << "Run etag job on folder" << folder;
+
+    if (!folder) {
+        return;
+    }
+    if (folder->isSyncRunning()) {
+        qCInfo(lcFolderMan) << "Can not run etag job: Sync is running";
+        return;
+    }
+    if (_scheduledFolders.contains(folder)) {
+        qCInfo(lcFolderMan) << "Can not run etag job: Folder is alreday scheduled";
+        return;
+    }
+    if (_disabledFolders.contains(folder)) {
+        qCInfo(lcFolderMan) << "Can not run etag job: Folder is disabled";
+        return;
+    }
+    if (folder->etagJob() || folder->isBusy() || !folder->canSync()) {
+        qCInfo(lcFolderMan) << "Can not run etag job: Folder is busy";
+        return;
+    }
+    // When not using push notifications, make sure polltime is reached
+    if (!pushNotificationsFilesReady(folder->accountState()->account().data())) {
+        if (folder->msecSinceLastSync() < polltime) {
+            qCInfo(lcFolderMan) << "Can not run etag job: Polltime not reached";
+            return;
         }
-        QMetaObject::invokeMethod(f, "slotRunEtagJob", Qt::QueuedConnection);
     }
+
+    QMetaObject::invokeMethod(folder, "slotRunEtagJob", Qt::QueuedConnection);
 }
 
 void FolderMan::slotRemoveFoldersForAccount(AccountState *accountState)
@@ -1631,4 +1682,42 @@ void FolderMan::restartApplication()
     }
 }
 
+void FolderMan::slotSetupPushNotifications(const Folder::Map &folderMap)
+{
+    for (auto folder : folderMap) {
+        const auto account = folder->accountState()->account();
+
+        // See if the account already provides the PushNotifications object and if yes connect to it.
+        // If we can't connect at this point, the signals will be connected in slotPushNotificationsReady()
+        // after the PushNotification object emitted the ready signal
+        slotConnectToPushNotifications(account.data());
+        connect(account.data(), &Account::pushNotificationsReady, this, &FolderMan::slotConnectToPushNotifications, Qt::UniqueConnection);
+    }
+}
+
+void FolderMan::slotProcessFilesPushNotification(Account *account)
+{
+    qCInfo(lcFolderMan) << "Got files push notification for account" << account;
+
+    for (auto folder : _folderMap) {
+        // Just run on the folders that belong to this account
+        if (folder->accountState()->account() != account) {
+            continue;
+        }
+
+        qCInfo(lcFolderMan) << "Schedule folder" << folder << "for sync";
+        scheduleFolder(folder);
+    }
+}
+
+void FolderMan::slotConnectToPushNotifications(Account *account)
+{
+    const auto pushNotifications = account->pushNotifications();
+
+    if (pushNotificationsFilesReady(account)) {
+        qCInfo(lcFolderMan) << "Push notifications ready";
+        connect(pushNotifications, &PushNotifications::filesChanged, this, &FolderMan::slotProcessFilesPushNotification, Qt::UniqueConnection);
+    }
+}
+
 } // namespace OCC

+ 9 - 0
src/gui/folderman.h

@@ -288,6 +288,10 @@ private slots:
      */
     void slotScheduleFolderByTime();
 
+    void slotSetupPushNotifications(const Folder::Map &);
+    void slotProcessFilesPushNotification(Account *account);
+    void slotConnectToPushNotifications(Account *account);
+
 private:
     /** Adds a new folder, does not add it to the account settings and
      *  does not set an account on the new folder.
@@ -313,6 +317,11 @@ private:
 
     void setupFoldersHelper(QSettings &settings, AccountStatePtr account, const QStringList &ignoreKeys, bool backwardsCompatible, bool foldersWithPlaceholders);
 
+    void runEtagJobsIfPossible(const QList<Folder *> &folderMap);
+    void runEtagJobIfPossible(Folder *folder);
+
+    bool pushNotificationsFilesReady(Account *account);
+
     QSet<Folder *> _disabledFolders;
     Folder::Map _folderMap;
     QString _folderConfigPath;

+ 3 - 0
src/libsync/CMakeLists.txt

@@ -19,6 +19,7 @@ ENDIF(${CMAKE_SYSTEM_NAME} MATCHES "FreeBSD|NetBSD|OpenBSD")
 
 set(libsync_SRCS
     account.cpp
+    pushnotifications.cpp
     wordlist.cpp
     bandwidthmanager.cpp
     capabilities.cpp
@@ -116,6 +117,7 @@ IF (NOT APPLE)
     )
 ENDIF(NOT APPLE)
 
+find_package(Qt5 REQUIRED COMPONENTS WebSockets)
 add_library(${synclib_NAME} SHARED ${libsync_SRCS})
 target_link_libraries(${synclib_NAME}
     "${csync_NAME}"
@@ -123,6 +125,7 @@ target_link_libraries(${synclib_NAME}
     OpenSSL::SSL
     ${OS_SPECIFIC_LINK_LIBRARIES}
     Qt5::Core Qt5::Network
+    Qt5::WebSockets
 )
 
 if (NOT TOKEN_AUTH_ONLY)

+ 35 - 0
src/libsync/account.cpp

@@ -20,6 +20,7 @@
 #include "creds/abstractcredentials.h"
 #include "capabilities.h"
 #include "theme.h"
+#include "pushnotifications.h"
 
 #include "common/asserts.h"
 #include "clientsideencryption.h"
@@ -56,6 +57,7 @@ Account::Account(QObject *parent)
     , _davPath(Theme::instance()->webDavPath())
 {
     qRegisterMetaType<AccountPtr>("AccountPtr");
+    qRegisterMetaType<Account *>("Account*");
 }
 
 AccountPtr Account::create()
@@ -201,6 +203,32 @@ void Account::setCredentials(AbstractCredentials *cred)
         this, &Account::slotCredentialsFetched);
     connect(_credentials.data(), &AbstractCredentials::asked,
         this, &Account::slotCredentialsAsked);
+
+    trySetupPushNotifications();
+}
+
+void Account::trySetupPushNotifications()
+{
+    if (_capabilities.availablePushNotifications() != PushNotificationType::None) {
+        qCInfo(lcAccount) << "Try to setup push notifications";
+
+        if (!_pushNotifications) {
+            _pushNotifications = new PushNotifications(this, this);
+
+            connect(_pushNotifications, &PushNotifications::ready, this, [this]() { emit pushNotificationsReady(this); });
+
+            const auto deletePushNotifications = [this]() {
+                qCInfo(lcAccount) << "Delete push notifications object because authentication failed or connection lost";
+                _pushNotifications->deleteLater();
+                _pushNotifications = nullptr;
+            };
+
+            connect(_pushNotifications, &PushNotifications::connectionLost, this, deletePushNotifications);
+            connect(_pushNotifications, &PushNotifications::authenticationFailed, this, deletePushNotifications);
+        }
+        // If push notifications already running it is no problem to call setup again
+        _pushNotifications->setup();
+    }
 }
 
 QUrl Account::davUrl() const
@@ -478,6 +506,8 @@ const Capabilities &Account::capabilities() const
 void Account::setCapabilities(const QVariantMap &caps)
 {
     _capabilities = Capabilities(caps);
+
+    trySetupPushNotifications();
 }
 
 QString Account::serverVersion() const
@@ -661,4 +691,9 @@ void Account::slotDirectEditingRecieved(const QJsonDocument &json)
     }
 }
 
+PushNotifications *Account::pushNotifications() const
+{
+    return _pushNotifications;
+}
+
 } // namespace OCC

+ 9 - 0
src/libsync/account.h

@@ -54,6 +54,7 @@ class Account;
 using AccountPtr = QSharedPointer<Account>;
 class AccessManager;
 class SimpleNetworkJob;
+class PushNotifications;
 
 /**
  * @brief Reimplement this to handle SSL errors from libsync
@@ -250,6 +251,8 @@ public:
     // Check for the directEditing capability
     void fetchDirectEditors(const QUrl &directEditingURL, const QString &directEditingETag);
 
+    PushNotifications *pushNotifications() const;
+
 public slots:
     /// Used when forgetting credentials
     void clearQNAMCache();
@@ -279,6 +282,8 @@ signals:
     /// Used in RemoteWipe
     void appPasswordRetrieved(QString);
 
+    void pushNotificationsReady(Account *account);
+
 protected Q_SLOTS:
     void slotCredentialsFetched();
     void slotCredentialsAsked();
@@ -287,6 +292,7 @@ protected Q_SLOTS:
 private:
     Account(QObject *parent = nullptr);
     void setSharedThis(AccountPtr sharedThis);
+    void trySetupPushNotifications();
 
     QWeakPointer<Account> _sharedThis;
     QString _id;
@@ -331,6 +337,8 @@ private:
     // Direct Editing
     QString _lastDirectEditingETag;
 
+    PushNotifications *_pushNotifications = nullptr;
+
     /* IMPORTANT - remove later - FIXME MS@2019-12-07 -->
      * TODO: For "Log out" & "Remove account": Remove client CA certs and KEY!
      *
@@ -350,5 +358,6 @@ private:
 }
 
 Q_DECLARE_METATYPE(OCC::AccountPtr)
+Q_DECLARE_METATYPE(OCC::Account *)
 
 #endif //SERVERCONNECTION_H

+ 23 - 0
src/libsync/capabilities.cpp

@@ -16,6 +16,7 @@
 
 #include <QVariantMap>
 #include <QLoggingCategory>
+#include <QUrl>
 
 #include <QDebug>
 
@@ -176,6 +177,28 @@ bool Capabilities::chunkingNg() const
     return _capabilities["dav"].toMap()["chunking"].toByteArray() >= "1.0";
 }
 
+PushNotificationTypes Capabilities::availablePushNotifications() const
+{
+    if (!_capabilities.contains("notify_push")) {
+        return PushNotificationType::None;
+    }
+
+    const auto types = _capabilities["notify_push"].toMap()["type"].toStringList();
+    PushNotificationTypes pushNotificationTypes;
+
+    if (types.contains("files")) {
+        pushNotificationTypes.setFlag(PushNotificationType::Files);
+    }
+
+    return pushNotificationTypes;
+}
+
+QUrl Capabilities::pushNotificationsWebSocketUrl() const
+{
+    const auto websocket = _capabilities["notify_push"].toMap()["endpoints"].toMap()["websocket"].toString();
+    return QUrl(websocket);
+}
+
 bool Capabilities::chunkingParallelUploadDisabled() const
 {
     return _capabilities["dav"].toMap()["chunkingParallelUploadDisabled"].toBool();

+ 13 - 0
src/libsync/capabilities.h

@@ -26,6 +26,13 @@ namespace OCC {
 
 class DirectEditor;
 
+enum PushNotificationType {
+    None = 0,
+    Files = 1
+};
+Q_DECLARE_FLAGS(PushNotificationTypes, PushNotificationType)
+Q_DECLARE_OPERATORS_FOR_FLAGS(PushNotificationTypes)
+
 /**
  * @brief The Capabilities class represents the capabilities of an ownCloud
  * server
@@ -48,6 +55,12 @@ public:
     bool shareResharing() const;
     bool chunkingNg() const;
 
+    /// Returns which kind of push notfications are available
+    PushNotificationTypes availablePushNotifications() const;
+
+    /// Websocket url for files push notifications if available
+    QUrl pushNotificationsWebSocketUrl() const;
+
     /// disable parallel upload in chunking
     bool chunkingParallelUploadDisabled() const;
 

+ 1 - 0
src/libsync/creds/abstractcredentials.h

@@ -45,6 +45,7 @@ public:
 
     virtual QString authType() const = 0;
     virtual QString user() const = 0;
+    virtual QString password() const = 0;
     virtual QNetworkAccessManager *createQNAM() const = 0;
 
     /** Whether there are credentials that can be used for a connection attempt. */

+ 6 - 0
src/libsync/creds/dummycredentials.cpp

@@ -27,6 +27,12 @@ QString DummyCredentials::user() const
     return _user;
 }
 
+QString DummyCredentials::password() const
+{
+    Q_UNREACHABLE();
+    return QString();
+}
+
 QNetworkAccessManager *DummyCredentials::createQNAM() const
 {
     return new AccessManager;

+ 1 - 0
src/libsync/creds/dummycredentials.h

@@ -28,6 +28,7 @@ public:
     QString _password;
     QString authType() const override;
     QString user() const override;
+    QString password() const override;
     QNetworkAccessManager *createQNAM() const override;
     bool ready() const override;
     bool stillValid(QNetworkReply *reply) override;

+ 1 - 1
src/libsync/creds/httpcredentials.h

@@ -91,7 +91,7 @@ public:
     void persist() override;
     QString user() const override;
     // the password or token
-    QString password() const;
+    QString password() const override;
     void invalidateToken() override;
     void forgetSensitiveData() override;
     QString fetchUser();

+ 189 - 0
src/libsync/pushnotifications.cpp

@@ -0,0 +1,189 @@
+#include "pushnotifications.h"
+#include "creds/abstractcredentials.h"
+#include "account.h"
+
+namespace {
+static constexpr int MAX_ALLOWED_FAILED_AUTHENTICATION_ATTEMPTS = 3;
+}
+
+namespace OCC {
+
+Q_LOGGING_CATEGORY(lcPushNotifications, "nextcloud.sync.pushnotifications", QtInfoMsg)
+
+PushNotifications::PushNotifications(Account *account, QObject *parent)
+    : QObject(parent)
+    , _account(account)
+{
+}
+
+PushNotifications::~PushNotifications()
+{
+    closeWebSocket();
+}
+
+void PushNotifications::setup()
+{
+    _isReady = false;
+    _failedAuthenticationAttemptsCount = 0;
+    reconnectToWebSocket();
+}
+
+void PushNotifications::reconnectToWebSocket()
+{
+    closeWebSocket();
+    openWebSocket();
+}
+
+void PushNotifications::closeWebSocket()
+{
+    if (_webSocket) {
+        qCInfo(lcPushNotifications) << "Close websocket";
+        _webSocket->close();
+    }
+}
+
+void PushNotifications::onWebSocketConnected()
+{
+    qCInfo(lcPushNotifications) << "Connected to websocket";
+
+    connect(_webSocket, &QWebSocket::textMessageReceived, this, &PushNotifications::onWebSocketTextMessageReceived, Qt::UniqueConnection);
+
+    authenticateOnWebSocket();
+}
+
+void PushNotifications::authenticateOnWebSocket()
+{
+    const auto credentials = _account->credentials();
+    const auto username = credentials->user();
+    const auto password = credentials->password();
+
+    // Authenticate
+    _webSocket->sendTextMessage(username);
+    _webSocket->sendTextMessage(password);
+}
+
+void PushNotifications::onWebSocketDisconnected()
+{
+    qCInfo(lcPushNotifications) << "Disconnected from websocket";
+}
+
+void PushNotifications::onWebSocketTextMessageReceived(const QString &message)
+{
+    qCInfo(lcPushNotifications) << "Received push notification:" << message;
+
+    if (message == "notify_file") {
+        handleNotifyFile();
+    } else if (message == "notify_activity" || message == "notify_notification") {
+        handleNotification();
+    } else if (message == "authenticated") {
+        handleAuthenticated();
+    } else if (message == "err: Invalid credentials") {
+        handleInvalidCredentials();
+    }
+}
+
+void PushNotifications::onWebSocketError(QAbstractSocket::SocketError error)
+{
+    // This error gets thrown in testSetup_maxConnectionAttemptsReached_deletePushNotifications after
+    // the second connection attempt. I have no idea why this happens. Maybe the socket gets not closed correctly?
+    // I think it's fine to ignore this error.
+    if (error == QAbstractSocket::UnfinishedSocketOperationError) {
+        return;
+    }
+
+    qCWarning(lcPushNotifications) << "Websocket error" << error;
+
+    _isReady = false;
+    emit connectionLost();
+}
+
+bool PushNotifications::tryReconnectToWebSocket()
+{
+    ++_failedAuthenticationAttemptsCount;
+    if (_failedAuthenticationAttemptsCount >= MAX_ALLOWED_FAILED_AUTHENTICATION_ATTEMPTS) {
+        qCInfo(lcPushNotifications) << "Max authentication attempts reached";
+        return false;
+    }
+
+    if (!_reconnectTimer) {
+        _reconnectTimer = new QTimer(this);
+    }
+
+    _reconnectTimer->setInterval(_reconnectTimerInterval);
+    _reconnectTimer->setSingleShot(true);
+    connect(_reconnectTimer, &QTimer::timeout, [this]() {
+        reconnectToWebSocket();
+    });
+    _reconnectTimer->start();
+
+    return true;
+}
+
+void PushNotifications::onWebSocketSslErrors(const QList<QSslError> &errors)
+{
+    qCWarning(lcPushNotifications) << "Received websocket ssl errors:" << errors;
+    _isReady = false;
+    emit authenticationFailed();
+}
+
+void PushNotifications::openWebSocket()
+{
+    // Open websocket
+    const auto capabilities = _account->capabilities();
+    const auto webSocketUrl = capabilities.pushNotificationsWebSocketUrl();
+
+    if (!_webSocket) {
+        qCInfo(lcPushNotifications) << "Create websocket";
+        _webSocket = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this);
+    }
+
+    if (_webSocket) {
+        connect(_webSocket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error), this, &PushNotifications::onWebSocketError, Qt::UniqueConnection);
+        connect(_webSocket, &QWebSocket::sslErrors, this, &PushNotifications::onWebSocketSslErrors, Qt::UniqueConnection);
+        connect(_webSocket, &QWebSocket::connected, this, &PushNotifications::onWebSocketConnected, Qt::UniqueConnection);
+        connect(_webSocket, &QWebSocket::disconnected, this, &PushNotifications::onWebSocketDisconnected, Qt::UniqueConnection);
+
+        qCInfo(lcPushNotifications) << "Open connection to websocket on:" << webSocketUrl;
+        _webSocket->open(webSocketUrl);
+    }
+}
+
+void PushNotifications::setReconnectTimerInterval(uint32_t interval)
+{
+    _reconnectTimerInterval = interval;
+}
+
+bool PushNotifications::isReady() const
+{
+    return _isReady;
+}
+
+void PushNotifications::handleAuthenticated()
+{
+    qCInfo(lcPushNotifications) << "Authenticated successful on websocket";
+    _failedAuthenticationAttemptsCount = 0;
+    _isReady = true;
+    emit ready();
+}
+
+void PushNotifications::handleNotifyFile()
+{
+    qCInfo(lcPushNotifications) << "Files push notification arrived";
+    emit filesChanged(_account);
+}
+
+void PushNotifications::handleInvalidCredentials()
+{
+    qCInfo(lcPushNotifications) << "Invalid credentials submitted to websocket";
+    if (!tryReconnectToWebSocket()) {
+        _isReady = false;
+        emit authenticationFailed();
+    }
+}
+
+void PushNotifications::handleNotification()
+{
+    qCInfo(lcPushNotifications) << "Notification or activity push notification arrived";
+    emit notification(_account);
+}
+}

+ 113 - 0
src/libsync/pushnotifications.h

@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) by Felix Weilbach <felix.weilbach@nextcloud.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+
+#pragma once
+
+#include <QWebSocket>
+#include <QTimer>
+
+#include "capabilities.h"
+
+namespace OCC {
+
+class Account;
+class AbstractCredentials;
+
+class OWNCLOUDSYNC_EXPORT PushNotifications : public QObject
+{
+    Q_OBJECT
+
+public:
+    explicit PushNotifications(Account *account, QObject *parent = nullptr);
+
+    ~PushNotifications();
+
+    /**
+     * Setup push notifications
+     *
+     * This method needs to be called before push notifications can be used.
+     */
+    void setup();
+
+    /**
+     * Set the interval for reconnection attempts
+     */
+    void setReconnectTimerInterval(uint32_t interval);
+
+    /**
+     * Indicates if push notifications ready to use
+     *
+     * Ready to use means connected and authenticated.
+     */
+    bool isReady() const;
+
+signals:
+    /**
+     * Will be emitted after a successful connection and authentication
+     */
+    void ready();
+
+    /**
+     * Will be emitted if files on the server changed
+     */
+    void filesChanged(Account *account);
+
+    /**
+     * Will be emitted if there is a new notification or activity on the server
+     */
+    void notification(Account *account);
+
+    /**
+     * Will be emitted if push notifications are unable to authenticate
+     *
+     * It's save to call #PushNotifications::setup() after this signal has been emitted.
+     */
+    void authenticationFailed();
+
+    /**
+     * Will be emitted if push notifications are unable to connect or the connection timed out
+     *
+     * It's save to call #PushNotifications::setup() after this signal has been emitted.
+     */
+    void connectionLost();
+
+private slots:
+    void onWebSocketConnected();
+    void onWebSocketDisconnected();
+    void onWebSocketTextMessageReceived(const QString &message);
+    void onWebSocketError(QAbstractSocket::SocketError error);
+    void onWebSocketSslErrors(const QList<QSslError> &errors);
+
+private:
+    void openWebSocket();
+    void reconnectToWebSocket();
+    void closeWebSocket();
+    void authenticateOnWebSocket();
+    bool tryReconnectToWebSocket();
+    void initReconnectTimer();
+
+    void handleAuthenticated();
+    void handleNotifyFile();
+    void handleInvalidCredentials();
+    void handleNotification();
+
+    Account *_account = nullptr;
+    QWebSocket *_webSocket = nullptr;
+    uint8_t _failedAuthenticationAttemptsCount = 0;
+    QTimer *_reconnectTimer = nullptr;
+    uint32_t _reconnectTimerInterval = 20 * 1000;
+    bool _isReady = false;
+};
+
+}

+ 2 - 0
test/CMakeLists.txt

@@ -69,6 +69,8 @@ nextcloud_add_test(SelectiveSync "")
 nextcloud_add_test(DatabaseError "")
 nextcloud_add_test(LockedFiles "../src/gui/lockwatcher.cpp")
 nextcloud_add_test(FolderWatcher "${FolderWatcher_SRC}")
+nextcloud_add_test(Capabilities "")
+nextcloud_add_test(PushNotifications "pushnotificationstestutils.cpp")
 
 if( UNIX AND NOT APPLE )
     nextcloud_add_test(InotifyWatcher "${FolderWatcher_SRC}")

+ 135 - 0
test/pushnotificationstestutils.cpp

@@ -0,0 +1,135 @@
+#include <QLoggingCategory>
+#include <QSignalSpy>
+#include <QTest>
+
+#include "pushnotificationstestutils.h"
+
+Q_LOGGING_CATEGORY(lcFakeWebSocketServer, "nextcloud.test.fakewebserver", QtInfoMsg)
+
+FakeWebSocketServer::FakeWebSocketServer(quint16 port, QObject *parent)
+    : QObject(parent)
+    , _webSocketServer(new QWebSocketServer(QStringLiteral("Fake Server"), QWebSocketServer::NonSecureMode, this))
+{
+    if (_webSocketServer->listen(QHostAddress::Any, port)) {
+        connect(_webSocketServer, &QWebSocketServer::newConnection, this, &FakeWebSocketServer::onNewConnection);
+        connect(_webSocketServer, &QWebSocketServer::closed, this, &FakeWebSocketServer::closed);
+        qCInfo(lcFakeWebSocketServer) << "Open fake websocket server on port:" << port;
+        return;
+    }
+    Q_UNREACHABLE();
+}
+
+FakeWebSocketServer::~FakeWebSocketServer()
+{
+    close();
+}
+
+void FakeWebSocketServer::close()
+{
+    if (_webSocketServer->isListening()) {
+        qCInfo(lcFakeWebSocketServer) << "Close fake websocket server";
+
+        _webSocketServer->close();
+        qDeleteAll(_clients.begin(), _clients.end());
+    }
+}
+
+void FakeWebSocketServer::processTextMessageInternal(const QString &message)
+{
+    auto client = qobject_cast<QWebSocket *>(sender());
+    emit processTextMessage(client, message);
+}
+
+void FakeWebSocketServer::onNewConnection()
+{
+    qCInfo(lcFakeWebSocketServer) << "New connection on fake websocket server";
+
+    auto socket = _webSocketServer->nextPendingConnection();
+
+    connect(socket, &QWebSocket::textMessageReceived, this, &FakeWebSocketServer::processTextMessageInternal);
+    connect(socket, &QWebSocket::disconnected, this, &FakeWebSocketServer::socketDisconnected);
+
+    _clients << socket;
+}
+
+void FakeWebSocketServer::socketDisconnected()
+{
+    qCInfo(lcFakeWebSocketServer) << "Socket disconnected";
+
+    auto client = qobject_cast<QWebSocket *>(sender());
+
+    if (client) {
+        _clients.removeAll(client);
+        client->deleteLater();
+    }
+}
+
+OCC::AccountPtr FakeWebSocketServer::createAccount()
+{
+    auto account = OCC::Account::create();
+
+    QStringList typeList;
+    typeList.append("files");
+
+    QString websocketUrl("ws://localhost:12345");
+
+    QVariantMap endpointsMap;
+    endpointsMap["websocket"] = websocketUrl;
+
+    QVariantMap notifyPushMap;
+    notifyPushMap["type"] = typeList;
+    notifyPushMap["endpoints"] = endpointsMap;
+
+    QVariantMap capabilitiesMap;
+    capabilitiesMap["notify_push"] = notifyPushMap;
+
+    account->setCapabilities(capabilitiesMap);
+
+    return account;
+}
+
+CredentialsStub::CredentialsStub(const QString &user, const QString &password)
+    : _user(user)
+    , _password(password)
+{
+}
+
+QString CredentialsStub::authType() const
+{
+    return "";
+}
+
+QString CredentialsStub::user() const
+{
+    return _user;
+}
+
+QString CredentialsStub::password() const
+{
+    return _password;
+}
+
+QNetworkAccessManager *CredentialsStub::createQNAM() const
+{
+    return nullptr;
+}
+
+bool CredentialsStub::ready() const
+{
+    return false;
+}
+
+void CredentialsStub::fetchFromKeychain() { }
+
+void CredentialsStub::askFromUser() { }
+
+bool CredentialsStub::stillValid(QNetworkReply * /*reply*/)
+{
+    return false;
+}
+
+void CredentialsStub::persist() { }
+
+void CredentialsStub::invalidateToken() { }
+
+void CredentialsStub::forgetSensitiveData() { }

+ 73 - 0
test/pushnotificationstestutils.h

@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) by Felix Weilbach <felix.weilbach@nextcloud.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+
+#pragma once
+
+#include <functional>
+
+#include <QWebSocketServer>
+#include <QWebSocket>
+
+#include "creds/abstractcredentials.h"
+#include "account.h"
+
+class FakeWebSocketServer : public QObject
+{
+    Q_OBJECT
+public:
+    explicit FakeWebSocketServer(quint16 port = 12345, QObject *parent = nullptr);
+
+    ~FakeWebSocketServer();
+
+    void close();
+
+    static OCC::AccountPtr createAccount();
+
+signals:
+    void closed();
+    void processTextMessage(QWebSocket *sender, const QString &message);
+
+private slots:
+    void processTextMessageInternal(const QString &message);
+    void onNewConnection();
+    void socketDisconnected();
+
+private:
+    QWebSocketServer *_webSocketServer;
+    QList<QWebSocket *> _clients;
+};
+
+class CredentialsStub : public OCC::AbstractCredentials
+{
+    Q_OBJECT
+
+public:
+    CredentialsStub(const QString &user, const QString &password);
+    virtual QString authType() const;
+    virtual QString user() const;
+    virtual QString password() const;
+    virtual QNetworkAccessManager *createQNAM() const;
+    virtual bool ready() const;
+    virtual void fetchFromKeychain();
+    virtual void askFromUser();
+
+    virtual bool stillValid(QNetworkReply *reply);
+    virtual void persist();
+    virtual void invalidateToken();
+    virtual void forgetSensitiveData();
+
+private:
+    QString _user;
+    QString _password;
+};

+ 1 - 0
test/syncenginetestutils.h

@@ -413,6 +413,7 @@ public:
     FakeCredentials(QNetworkAccessManager *qnam) : _qnam{qnam} { }
     virtual QString authType() const { return "test"; }
     virtual QString user() const { return "admin"; }
+    virtual QString password() const { return "password"; }
     virtual QNetworkAccessManager *createQNAM() const { return _qnam; }
     virtual bool ready() const { return true; }
     virtual void fetchFromKeychain() { }

+ 72 - 0
test/testcapabilities.cpp

@@ -0,0 +1,72 @@
+#include <QTest>
+
+#include "capabilities.h"
+
+class TestCapabilities : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void testPushNotificationsAvailable_pushNotificationsForFilesAvailable_returnTrue()
+    {
+        QStringList typeList;
+        typeList.append("files");
+
+        QVariantMap notifyPushMap;
+        notifyPushMap["type"] = typeList;
+
+        QVariantMap capabilitiesMap;
+        capabilitiesMap["notify_push"] = notifyPushMap;
+
+        const auto &capabilities = OCC::Capabilities(capabilitiesMap);
+        const auto filesPushNotificationsAvailable = capabilities.availablePushNotifications().testFlag(OCC::PushNotificationType::Files);
+
+        QCOMPARE(filesPushNotificationsAvailable, true);
+    }
+
+    void testPushNotificationsAvailable_pushNotificationsForFilesNotAvailable_returnFalse()
+    {
+        QStringList typeList;
+        typeList.append("nofiles");
+
+        QVariantMap notifyPushMap;
+        notifyPushMap["type"] = typeList;
+
+        QVariantMap capabilitiesMap;
+        capabilitiesMap["notify_push"] = notifyPushMap;
+
+        const auto &capabilities = OCC::Capabilities(capabilitiesMap);
+        const auto filesPushNotificationsAvailable = capabilities.availablePushNotifications().testFlag(OCC::PushNotificationType::Files);
+
+        QCOMPARE(filesPushNotificationsAvailable, false);
+    }
+
+    void testPushNotificationsAvailable_pushNotificationsNotAvailable_returnFalse()
+    {
+        const auto &capabilities = OCC::Capabilities(QVariantMap());
+        const auto filesPushNotificationsAvailable = capabilities.availablePushNotifications().testFlag(OCC::PushNotificationType::Files);
+
+        QCOMPARE(filesPushNotificationsAvailable, false);
+    }
+
+    void testPushNotificationsWebSocketUrl_urlAvailable_returnUrl()
+    {
+        QString websocketUrl("testurl");
+
+        QVariantMap endpointsMap;
+        endpointsMap["websocket"] = websocketUrl;
+
+        QVariantMap notifyPushMap;
+        notifyPushMap["endpoints"] = endpointsMap;
+
+        QVariantMap capabilitiesMap;
+        capabilitiesMap["notify_push"] = notifyPushMap;
+
+        const auto &capabilities = OCC::Capabilities(capabilitiesMap);
+
+        QCOMPARE(capabilities.pushNotificationsWebSocketUrl(), websocketUrl);
+    }
+};
+
+QTEST_GUILESS_MAIN(TestCapabilities)
+#include "testcapabilities.moc"

+ 279 - 0
test/testpushnotifications.cpp

@@ -0,0 +1,279 @@
+#include <QTest>
+#include <QVector>
+#include <QWebSocketServer>
+#include <QSignalSpy>
+
+#include "pushnotifications.h"
+#include "pushnotificationstestutils.h"
+
+class TestPushNotifications : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void testSetup_correctCredentials_authenticateAndEmitReady()
+    {
+        FakeWebSocketServer fakeServer;
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+        const QString user = "user";
+        const QString password = "password";
+        auto account = FakeWebSocketServer::createAccount();
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+        QSignalSpy readySpy(account->pushNotifications(), &OCC::PushNotifications::ready);
+        QVERIFY(readySpy.isValid());
+
+        // Wait for authentication
+        QVERIFY(processTextMessageSpy.wait());
+
+        // Right authentication data should be sent
+        QCOMPARE(processTextMessageSpy.count(), 2);
+
+        const auto socket = processTextMessageSpy.at(0).at(0).value<QWebSocket *>();
+        const auto userSent = processTextMessageSpy.at(0).at(1).toString();
+        const auto passwordSent = processTextMessageSpy.at(1).at(1).toString();
+
+        QCOMPARE(userSent, user);
+        QCOMPARE(passwordSent, password);
+
+        // Sent authenticated
+        socket->sendTextMessage("authenticated");
+
+        // Wait for ready signal
+        readySpy.wait();
+        QCOMPARE(readySpy.count(), 1);
+        QCOMPARE(account->pushNotifications()->isReady(), true);
+    }
+
+    void testOnWebSocketTextMessageReceived_notifyFileMessage_emitFilesChanged()
+    {
+        const QString user = "user";
+        const QString password = "password";
+        FakeWebSocketServer fakeServer;
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+
+        auto account = FakeWebSocketServer::createAccount();
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+        QSignalSpy filesChangedSpy(account->pushNotifications(), &OCC::PushNotifications::filesChanged);
+        QVERIFY(filesChangedSpy.isValid());
+
+        // Wait for authentication and then send notify_file push notification
+        QVERIFY(processTextMessageSpy.wait());
+        QCOMPARE(processTextMessageSpy.count(), 2);
+        const auto socket = processTextMessageSpy.at(0).at(0).value<QWebSocket *>();
+        socket->sendTextMessage("notify_file");
+
+        // filesChanged signal should be emitted
+        QVERIFY(filesChangedSpy.wait());
+        QCOMPARE(filesChangedSpy.count(), 1);
+        auto accountFilesChanged = filesChangedSpy.at(0).at(0).value<OCC::Account *>();
+        QCOMPARE(accountFilesChanged, account.data());
+    }
+
+    void testOnWebSocketTextMessageReceived_notifyActivityMessage_emitNotification()
+    {
+        const QString user = "user";
+        const QString password = "password";
+        FakeWebSocketServer fakeServer;
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+
+        auto account = FakeWebSocketServer::createAccount();
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+        QSignalSpy notificationSpy(account->pushNotifications(), &OCC::PushNotifications::notification);
+        QVERIFY(notificationSpy.isValid());
+
+        // Wait for authentication and then send notify_file push notification
+        QVERIFY(processTextMessageSpy.wait());
+        QCOMPARE(processTextMessageSpy.count(), 2);
+        const auto socket = processTextMessageSpy.at(0).at(0).value<QWebSocket *>();
+        socket->sendTextMessage("notify_activity");
+
+        // notification signal should be emitted
+        QVERIFY(notificationSpy.wait());
+        QCOMPARE(notificationSpy.count(), 1);
+        auto accountFilesChanged = notificationSpy.at(0).at(0).value<OCC::Account *>();
+        QCOMPARE(accountFilesChanged, account.data());
+    }
+
+    void testOnWebSocketTextMessageReceived_notifyNotificationMessage_emitNotification()
+    {
+        const QString user = "user";
+        const QString password = "password";
+        FakeWebSocketServer fakeServer;
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+
+        auto account = FakeWebSocketServer::createAccount();
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+        QSignalSpy notificationSpy(account->pushNotifications(), &OCC::PushNotifications::notification);
+        QVERIFY(notificationSpy.isValid());
+
+        // Wait for authentication and then send notify_file push notification
+        QVERIFY(processTextMessageSpy.wait());
+        QCOMPARE(processTextMessageSpy.count(), 2);
+        const auto socket = processTextMessageSpy.at(0).at(0).value<QWebSocket *>();
+        socket->sendTextMessage("notify_notification");
+
+        // notification signal should be emitted
+        QVERIFY(notificationSpy.wait());
+        QCOMPARE(notificationSpy.count(), 1);
+        auto accountFilesChanged = notificationSpy.at(0).at(0).value<OCC::Account *>();
+        QCOMPARE(accountFilesChanged, account.data());
+    }
+
+    void testOnWebSocketTextMessageReceived_invalidCredentialsMessage_reconnectWebSocket()
+    {
+        const QString user = "user";
+        const QString password = "password";
+        FakeWebSocketServer fakeServer;
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+
+        auto account = FakeWebSocketServer::createAccount();
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+        // Need to set reconnect timer interval to zero for tests
+        account->pushNotifications()->setReconnectTimerInterval(0);
+
+        // Wait for authentication attempt and then sent invalid credentials
+        QVERIFY(processTextMessageSpy.wait());
+        QCOMPARE(processTextMessageSpy.count(), 2);
+        const auto socket = processTextMessageSpy.at(0).at(0).value<QWebSocket *>();
+        const auto firstPasswordSent = processTextMessageSpy.at(1).at(1).toString();
+        QCOMPARE(firstPasswordSent, password);
+        processTextMessageSpy.clear();
+        socket->sendTextMessage("err: Invalid credentials");
+
+        // Wait for a new authentication attempt
+        QVERIFY(processTextMessageSpy.wait());
+        QCOMPARE(processTextMessageSpy.count(), 2);
+        const auto secondPasswordSent = processTextMessageSpy.at(1).at(1).toString();
+        QCOMPARE(secondPasswordSent, password);
+    }
+
+    void testOnWebSocketError_connectionLost_emitConnectionLost()
+    {
+        const QString user = "user";
+        const QString password = "password";
+        FakeWebSocketServer fakeServer;
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+
+        auto account = FakeWebSocketServer::createAccount();
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+        // Need to set reconnect timer interval to zero for tests
+        account->pushNotifications()->setReconnectTimerInterval(0);
+
+        QSignalSpy connectionLostSpy(account->pushNotifications(), &OCC::PushNotifications::connectionLost);
+        QVERIFY(connectionLostSpy.isValid());
+
+        // Wait for authentication and then sent a network error
+        processTextMessageSpy.wait();
+        QCOMPARE(processTextMessageSpy.count(), 2);
+        auto socket = processTextMessageSpy.at(0).at(0).value<QWebSocket *>();
+        socket->abort();
+
+        QVERIFY(connectionLostSpy.wait());
+        // Account handled connectionLost signal and deleted PushNotifications
+        QCOMPARE(account->pushNotifications(), nullptr);
+    }
+
+    void testSetup_maxConnectionAttemptsReached_deletePushNotifications()
+    {
+        const QString user = "user";
+        const QString password = "password";
+        FakeWebSocketServer fakeServer;
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+
+        auto account = FakeWebSocketServer::createAccount();
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+        account->pushNotifications()->setReconnectTimerInterval(0);
+        QSignalSpy authenticationFailedSpy(account->pushNotifications(), &OCC::PushNotifications::authenticationFailed);
+        QVERIFY(authenticationFailedSpy.isValid());
+
+        // Let three authentication attempts fail
+        QVERIFY(processTextMessageSpy.wait());
+        QCOMPARE(processTextMessageSpy.count(), 2);
+        auto socket = processTextMessageSpy.at(0).at(0).value<QWebSocket *>();
+        socket->sendTextMessage("err: Invalid credentials");
+
+        QVERIFY(processTextMessageSpy.wait());
+        QCOMPARE(processTextMessageSpy.count(), 4);
+        socket = processTextMessageSpy.at(2).at(0).value<QWebSocket *>();
+        socket->sendTextMessage("err: Invalid credentials");
+
+        QVERIFY(processTextMessageSpy.wait());
+        QCOMPARE(processTextMessageSpy.count(), 6);
+        socket = processTextMessageSpy.at(4).at(0).value<QWebSocket *>();
+        socket->sendTextMessage("err: Invalid credentials");
+
+        // Now the authenticationFailed Signal should be emitted
+        QVERIFY(authenticationFailedSpy.wait());
+        QCOMPARE(authenticationFailedSpy.count(), 1);
+
+        // Account deleted the push notifications
+        QCOMPARE(account->pushNotifications(), nullptr);
+    }
+
+    void testOnWebSocketSslError_sslError_deletePushNotifications()
+    {
+        const QString user = "user";
+        const QString password = "password";
+        FakeWebSocketServer fakeServer;
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+
+        auto account = FakeWebSocketServer::createAccount();
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+
+        processTextMessageSpy.wait();
+
+        // FIXME: This a little bit ugly but I had no better idea how to trigger a error on the websocket client.
+        // The websocket that is retrived through the server is not connected to the ssl error signal.
+        auto pushNotificationsWebSocketChildren = account->pushNotifications()->findChildren<QWebSocket *>();
+        QVERIFY(pushNotificationsWebSocketChildren.size() == 1);
+        emit pushNotificationsWebSocketChildren[0]->sslErrors(QList<QSslError>());
+
+        // Account handled connectionLost signal and deleted PushNotifications
+        QCOMPARE(account->pushNotifications(), nullptr);
+    }
+
+    void testAccountSetCredentials_correctCredentials_emitPushNotificationsReady()
+    {
+        FakeWebSocketServer fakeServer;
+        auto account = FakeWebSocketServer::createAccount();
+        QSignalSpy processTextMessageSpy(&fakeServer, &FakeWebSocketServer::processTextMessage);
+        QVERIFY(processTextMessageSpy.isValid());
+        const QString user = "user";
+        const QString password = "password";
+        auto credentials = new CredentialsStub(user, password);
+        account->setCredentials(credentials);
+
+        QSignalSpy pushNotificationsReady(account.data(), &OCC::Account::pushNotificationsReady);
+        QVERIFY(pushNotificationsReady.isValid());
+
+        // Wait for authentication
+        QVERIFY(processTextMessageSpy.wait());
+        auto socket = processTextMessageSpy.at(0).at(0).value<QWebSocket *>();
+        // Don't care about which message was sent
+        socket->sendTextMessage("authenticated");
+
+        // Wait for push notifactions ready signal
+        QVERIFY(pushNotificationsReady.wait());
+        auto accountSent = pushNotificationsReady.at(0).at(0).value<OCC::Account *>();
+        QCOMPARE(accountSent, account.data());
+    }
+};
+
+QTEST_GUILESS_MAIN(TestPushNotifications)
+#include "testpushnotifications.moc"