diff options
| author | Andrew Branson <andrew.branson@jolla.com> | 2026-02-10 10:41:02 +0100 |
|---|---|---|
| committer | Andrew Branson <andrew.branson@jolla.com> | 2026-02-10 17:09:39 +0100 |
| commit | 4351f4627ba9e71775438dd26c9acddd002c7e11 (patch) | |
| tree | 3c72c980c5c81507109087bda67052b7ec8216b6 /buteo-plugins | |
Initial commit
Diffstat (limited to 'buteo-plugins')
21 files changed, 2172 insertions, 0 deletions
diff --git a/buteo-plugins/buteo-common/buteo-common.pri b/buteo-plugins/buteo-common/buteo-common.pri new file mode 100644 index 0000000..192da35 --- /dev/null +++ b/buteo-plugins/buteo-common/buteo-common.pri @@ -0,0 +1,9 @@ +INCLUDEPATH += $$PWD +DEPENDPATH += . + +QT += dbus + +CONFIG += link_pkgconfig +PKGCONFIG += accounts-qt5 buteosyncfw5 socialcache libsignon-qt5 libsailfishkeyprovider + +LIBS += -L$$PWD -lmastodonbuteocommon diff --git a/buteo-plugins/buteo-common/buteo-common.pro b/buteo-plugins/buteo-common/buteo-common.pro new file mode 100644 index 0000000..c1aa569 --- /dev/null +++ b/buteo-plugins/buteo-common/buteo-common.pro @@ -0,0 +1,29 @@ +TEMPLATE = lib + +TARGET = mastodonbuteocommon +TARGET = $$qtLibraryTarget($$TARGET) + +QT -= gui +QT += network dbus +CONFIG += link_pkgconfig +PKGCONFIG += accounts-qt5 buteosyncfw5 socialcache + +INCLUDEPATH += $$PWD + +HEADERS += \ + $$PWD/buteosyncfw_p.h \ + $$PWD/socialdbuteoplugin.h \ + $$PWD/socialnetworksyncadaptor.h \ + $$PWD/socialdnetworkaccessmanager_p.h \ + $$PWD/trace.h + +SOURCES += \ + $$PWD/socialdbuteoplugin.cpp \ + $$PWD/socialnetworksyncadaptor.cpp \ + $$PWD/socialdnetworkaccessmanager_p.cpp \ + $$PWD/trace.cpp + +TARGETPATH = $$[QT_INSTALL_LIBS] +target.path = $$TARGETPATH + +INSTALLS += target diff --git a/buteo-plugins/buteo-common/buteosyncfw_p.h b/buteo-plugins/buteo-common/buteosyncfw_p.h new file mode 100644 index 0000000..5924731 --- /dev/null +++ b/buteo-plugins/buteo-common/buteosyncfw_p.h @@ -0,0 +1,39 @@ +/**************************************************************************** + ** + ** Copyright (C) 2014 Jolla Ltd. + ** Contact: Chris Adams <chris.adams@jollamobile.com> + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#ifndef SOCIALD_BUTEOSYNCFW_P_H +#define SOCIALD_BUTEOSYNCFW_P_H + +#include <SyncCommonDefs.h> +#include <SyncPluginBase.h> +#include <ProfileManager.h> +#include <ClientPlugin.h> +#include <SyncResults.h> +#include <ProfileEngineDefs.h> +#include <SyncProfile.h> +#include <Profile.h> +#include <PluginCbInterface.h> + +#ifndef SOCIALD_TEST_DEFINE +#define PRIVILEGED_DATA_DIR QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + QLatin1String("/.local/share/system/privileged") +#endif + +#endif // SOCIALD_BUTEOSYNCFW_P_H diff --git a/buteo-plugins/buteo-common/socialdbuteoplugin.cpp b/buteo-plugins/buteo-common/socialdbuteoplugin.cpp new file mode 100644 index 0000000..8b27f84 --- /dev/null +++ b/buteo-plugins/buteo-common/socialdbuteoplugin.cpp @@ -0,0 +1,338 @@ +/**************************************************************************** + ** + ** Copyright (C) 2014 Jolla Ltd. + ** Contact: Chris Adams <chris.adams@jolla.com> + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#include "socialdbuteoplugin.h" +#include "socialnetworksyncadaptor.h" +#include "trace.h" + +#include <QCoreApplication> +#include <QTranslator> + +#include <QDBusMessage> +#include <QDBusConnection> +#include <QDBusPendingCall> + +#include "buteosyncfw_p.h" + +#include <Accounts/Manager> +#include <Accounts/Account> +#include <Accounts/Service> + +namespace { + const QString SyncProfileTemplatesKey = QStringLiteral("sync_profile_templates"); + + QString SyncProfileIdKey(const QString &templateProfileName) + { + return QStringLiteral("%1/%2").arg(templateProfileName).arg(Buteo::KEY_PROFILE_ID); + } + + QString createProfile(Buteo::ProfileManager *profileManager, + const QString &templateProfileName, + Accounts::Account *account, + const Accounts::Service &srv, + bool enableProfile, + const QVariantMap &properties) + { + if (!account || !srv.isValid()) { + qWarning() << "Invalid account or service"; + return QString(); + } + if (templateProfileName.isEmpty()) { + qWarning() << "Invalid templateProfileName"; + return QString(); + } + + Accounts::Service prevService = account->selectedService(); + account->selectService(srv); + + Buteo::SyncProfile *templateProfile = profileManager->syncProfile(templateProfileName); + if (!templateProfile) { + account->selectService(prevService); + qWarning() << "Unable to load template profile:" << templateProfileName; + return QString(); + } + + Buteo::SyncProfile *profile = templateProfile->clone(); + if (!profile) { + delete templateProfile; + account->selectService(prevService); + qWarning() << "unable to clone template profile:" << templateProfileName; + return QString(); + } + + QString accountIdStr = QString::number(account->id()); + profile->setName(templateProfileName + "-" + accountIdStr); + profile->setKey(Buteo::KEY_DISPLAY_NAME, templateProfileName + "-" + account->displayName().toHtmlEscaped()); + profile->setKey(Buteo::KEY_ACCOUNT_ID, accountIdStr); + profile->setBoolKey(Buteo::KEY_USE_ACCOUNTS, true); + profile->setEnabled(enableProfile); + + // enable the profile schedule + Buteo::SyncSchedule schedule = profile->syncSchedule(); + schedule.setScheduleEnabled(true); + profile->setSyncSchedule(schedule); + + // set custom properties; note this may override any properties already set + Q_FOREACH (const QString &key, properties.keys()) { + profile->setKey(key, properties[key].toString()); + } + + QString profileId = profileManager->updateProfile(*profile); + if (profileId.isEmpty()) { + qWarning() << "Unable to save sync profile" << templateProfile->name(); + } else { + account->setValue(SyncProfileIdKey(templateProfile->name()), profile->name()); + } + + account->selectService(prevService); + delete profile; + delete templateProfile; + + return profileId; + } +} + +SocialdButeoPlugin::SocialdButeoPlugin(const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface *callbackInterface, + const QString &socialServiceName, + const QString &dataTypeName) + : ClientPlugin(pluginName, profile, callbackInterface) + , m_socialNetworkSyncAdaptor(nullptr) + , m_socialServiceName(socialServiceName) + , m_dataTypeName(dataTypeName) + , m_profileAccountId(0) +{ +} + +SocialdButeoPlugin::~SocialdButeoPlugin() +{ +} + +bool SocialdButeoPlugin::init() +{ + m_profileAccountId = profile().key(Buteo::KEY_ACCOUNT_ID).toInt(); + m_socialNetworkSyncAdaptor = createSocialNetworkSyncAdaptor(); + if (m_socialNetworkSyncAdaptor) { + connect(m_socialNetworkSyncAdaptor, &SocialNetworkSyncAdaptor::statusChanged, + this, &SocialdButeoPlugin::syncStatusChanged); + return true; + } + + return false; +} + +bool SocialdButeoPlugin::uninit() +{ + delete m_socialNetworkSyncAdaptor; + m_socialNetworkSyncAdaptor = nullptr; + return true; +} + +bool SocialdButeoPlugin::startSync() +{ + // if the profile being triggered is the template profile, then we + // need to ensure that the appropriate per-account profiles exist. + if (m_profileAccountId == 0) { + QList<Buteo::SyncProfile*> perAccountProfiles = ensurePerAccountSyncProfilesExist(); + m_socialNetworkSyncAdaptor->setAccountSyncProfile(NULL); + + // we need to trigger sync with each profile separately, + // or (due to scheduling/etc) another plugin instance might + // be created to sync that profile at the same time, and + // we don't handle concurrency. + foreach (Buteo::SyncProfile *perAccountProfile, perAccountProfiles) { + QDBusMessage message = QDBusMessage::createMethodCall( + "com.meego.msyncd", "/synchronizer", "com.meego.msyncd", "startSync"); + message.setArguments(QVariantList() << perAccountProfile->name()); + QDBusConnection::sessionBus().asyncCall(message); + } + } else { + m_socialNetworkSyncAdaptor->setAccountSyncProfile(profile().clone()); + } + + // now perform sync. Note that for the template profile case, this will + // result in a purge operation occurring (checking for removed accounts and + // purging any synced data associated with those accounts). + if (m_socialNetworkSyncAdaptor && m_socialNetworkSyncAdaptor->enabled()) { + if (m_socialNetworkSyncAdaptor->status() == SocialNetworkSyncAdaptor::Inactive) { + qCDebug(lcSocialPlugin) << "performing sync of" << m_dataTypeName << "from" << m_socialServiceName + << "for account" << m_profileAccountId; + m_socialNetworkSyncAdaptor->sync(m_dataTypeName, m_profileAccountId); + return true; + } else { + qCDebug(lcSocialPlugin) << m_socialServiceName << "sync adaptor for" << m_dataTypeName + << "is still busy with last sync of account" << m_profileAccountId; + } + } else { + qCDebug(lcSocialPlugin) << "no enabled" << m_socialServiceName << "sync adaptor for" << m_dataTypeName; + } + return false; +} + +void SocialdButeoPlugin::abortSync(Sync::SyncStatus status) +{ + // note: it seems buteo automatically calls abortSync on network connectivity loss... + qCInfo(lcSocialPlugin) << "aborting sync with status:" << status; + m_socialNetworkSyncAdaptor->abortSync(status); +} + +bool SocialdButeoPlugin::cleanUp() +{ + m_profileAccountId = profile().key(Buteo::KEY_ACCOUNT_ID).toInt(); + if (!m_socialNetworkSyncAdaptor) { + // might have already been initialized by the OOP framework via init(). + m_socialNetworkSyncAdaptor = createSocialNetworkSyncAdaptor(); + } + + if (m_socialNetworkSyncAdaptor && m_profileAccountId > 0) { + m_socialNetworkSyncAdaptor->purgeDataForOldAccount(m_profileAccountId, + SocialNetworkSyncAdaptor::CleanUpPurge); + } + + return true; +} + +Buteo::SyncResults SocialdButeoPlugin::getSyncResults() const +{ + return m_syncResults; +} + +void SocialdButeoPlugin::connectivityStateChanged(Sync::ConnectivityType type, bool state) +{ + // See TransportTracker.cpp:149 for example + // Sync::CONNECTIVITY_INTERNET, true|false + qCInfo(lcSocialPlugin) << "notified of connectivity change:" << type << state; + if (type == Sync::CONNECTIVITY_INTERNET && state == false) { + // we lost connectivity during sync. + abortSync(Sync::SYNC_CONNECTION_ERROR); + } +} + +void SocialdButeoPlugin::syncStatusChanged() +{ + if (m_socialNetworkSyncAdaptor) { + SocialNetworkSyncAdaptor::Status syncStatus = m_socialNetworkSyncAdaptor->status(); + // Busy change comes when sync starts -> let's ignore that. + if (syncStatus == SocialNetworkSyncAdaptor::Inactive) { + updateResults(Buteo::SyncResults(QDateTime::currentDateTime(), + Buteo::SyncResults::SYNC_RESULT_SUCCESS, + Buteo::SyncResults::NO_ERROR)); + emit success(getProfileName(), QString("%1 update succeeded").arg(getProfileName())); + } else if (syncStatus != SocialNetworkSyncAdaptor::Busy) { + updateResults(Buteo::SyncResults(QDateTime::currentDateTime(), + Buteo::SyncResults::SYNC_RESULT_FAILED, + Buteo::SyncResults::ABORTED)); + emit error(getProfileName(), QString("%1 update failed").arg(getProfileName()), + Buteo::SyncResults::ABORTED); + } + } else { + updateResults(Buteo::SyncResults(QDateTime::currentDateTime(), + Buteo::SyncResults::SYNC_RESULT_FAILED, + Buteo::SyncResults::ABORTED)); + emit error(getProfileName(), QString("%1 update failed").arg(getProfileName()), Buteo::SyncResults::ABORTED); + } +} + +void SocialdButeoPlugin::updateResults(const Buteo::SyncResults &results) +{ + m_syncResults = results; + m_syncResults.setScheduled(true); +} + +// This function is called when the non-per-account profile is triggered. +// The implementation does: +// - get all profiles from the ProfileManager +// - get all accounts from the AccountManager +// - build a mapping of profile -> account for the current data type. (should be one-to-one for the datatype). +// - any account which doesn't have a profile, print an error. +// - check the enabled status of the account -> ensure that the enabled status is reflected in the profile. +// It then returns a list of the appropriate (per account for this data-type) sync profiles. +// The caller takes ownership of the list. +QList<Buteo::SyncProfile*> SocialdButeoPlugin::ensurePerAccountSyncProfilesExist() +{ + Accounts::Manager am; + Accounts::AccountIdList accountIds = am.accountList(); + QList<Buteo::SyncProfile*> syncProfiles = m_profileManager.allSyncProfiles(); + QMap<Accounts::Account*, Buteo::SyncProfile*> perAccountProfiles; + + Accounts::Service dataTypeSyncService = am.service(m_socialNetworkSyncAdaptor->syncServiceName()); + if (!dataTypeSyncService.isValid()) { + qWarning() << Q_FUNC_INFO << "Invalid data type sync service name specified:" + << m_socialNetworkSyncAdaptor->syncServiceName(); + return QList<Buteo::SyncProfile*>(); + } + + for (int i = 0; i < accountIds.size(); ++i) { + Accounts::Account *currAccount = Accounts::Account::fromId(&am, accountIds.at(i), this); + if (!currAccount || currAccount->id() == 0 + || m_socialNetworkSyncAdaptor->syncServiceName().split('-').first() != currAccount->providerName()) { + // we only generate per-account sync profiles for accounts which + // are provided by the provider which provides our sync service. + continue; + } + + // for the current account, find the associated sync profile. + bool foundProfile = false; + for (int j = 0; j < syncProfiles.size(); ++j) { + if (syncProfiles[j]->key(Buteo::KEY_ACCOUNT_ID).toInt() == QString::number(currAccount->id()).toInt() + && syncProfiles[j]->clientProfile() != NULL + && syncProfiles[j]->clientProfile()->name() == profile().clientProfile()->name()) { + // we have found the sync profile for this datatype for this account. + foundProfile = true; + perAccountProfiles.insert(currAccount, syncProfiles.takeAt(j)); + break; + } + } + + if (!foundProfile) { + // it should have been generated for the account when the account was added. + qCInfo(lcSocialPlugin) << "no per-account" << profile().name() + << "sync profile exists for account:" << currAccount->id(); + + // create the per-account profile... we shouldn't need to do this... + QString profileName = createProfile(&m_profileManager, profile().name(), currAccount, dataTypeSyncService, true, QVariantMap()); + Buteo::SyncProfile *newProfile = m_profileManager.syncProfile(profileName); + if (!newProfile) { + qCWarning(lcSocialPlugin) << "unable to create per-account" << profile().name() + << "sync profile for account:" << currAccount->id(); + } else { + // enable the sync schedule for the profile. + Buteo::SyncSchedule schedule = newProfile->syncSchedule(); + schedule.setScheduleEnabled(true); + newProfile->setSyncSchedule(schedule); + m_profileManager.updateProfile(*newProfile); + // and return the profile in the map. + perAccountProfiles.insert(currAccount, newProfile); + } + } + } + + // Every account now has the appropriate sync profile. + qDeleteAll(syncProfiles); // these are for the wrong data type, ignore them. + QList<Buteo::SyncProfile *> retn; + foreach (Accounts::Account *acc, perAccountProfiles.keys()) { + retn.append(perAccountProfiles[acc]); + acc->deleteLater(); + } + + return retn; +} diff --git a/buteo-plugins/buteo-common/socialdbuteoplugin.h b/buteo-plugins/buteo-common/socialdbuteoplugin.h new file mode 100644 index 0000000..57de171 --- /dev/null +++ b/buteo-plugins/buteo-common/socialdbuteoplugin.h @@ -0,0 +1,75 @@ +/**************************************************************************** + ** + ** Copyright (C) 2013-2014 Jolla Ltd. + ** Contact: Raine Makelainen <raine.makelainen@jollamobile.com> + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#ifndef SOCIALDBUTEOPLUGIN_H +#define SOCIALDBUTEOPLUGIN_H + +#include <QtCore/qglobal.h> +#include "buteosyncfw_p.h" + +/* + Datatype-specific implementations of this class + allow per-account sync profiles for that data type. +*/ + +class SocialNetworkSyncAdaptor; +class Q_DECL_EXPORT SocialdButeoPlugin : public Buteo::ClientPlugin +{ + Q_OBJECT + +protected: + virtual SocialNetworkSyncAdaptor *createSocialNetworkSyncAdaptor() = 0; + +public: + SocialdButeoPlugin(const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface *cbInterface, + const QString &socialServiceName, + const QString &dataTypeName); + virtual ~SocialdButeoPlugin(); + + bool init() override; + bool uninit() override; + bool startSync() override; + void abortSync(Sync::SyncStatus status = Sync::SYNC_ABORTED) override; + Buteo::SyncResults getSyncResults() const override; + bool cleanUp() override; + +public Q_SLOTS: + void connectivityStateChanged(Sync::ConnectivityType type, bool state) override; + +private Q_SLOTS: + void syncStatusChanged(); + +protected: + QList<Buteo::SyncProfile*> ensurePerAccountSyncProfilesExist(); + +private: + void updateResults(const Buteo::SyncResults &results); + Buteo::SyncResults m_syncResults; + Buteo::ProfileManager m_profileManager; + SocialNetworkSyncAdaptor *m_socialNetworkSyncAdaptor; + QString m_socialServiceName; + QString m_dataTypeName; + int m_profileAccountId; +}; + +#endif // SOCIALDBUTEOPLUGIN_H diff --git a/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.cpp b/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.cpp new file mode 100644 index 0000000..7030ce3 --- /dev/null +++ b/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.cpp @@ -0,0 +1,37 @@ +/**************************************************************************** + ** + ** Copyright (C) 2013-2014 Jolla Ltd. + ** Contact: Chris Adams <chris.adams@jollamobile.com> + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#include "socialdnetworkaccessmanager_p.h" + +/* The default implementation is just a normal QNetworkAccessManager */ + +SocialdNetworkAccessManager::SocialdNetworkAccessManager(QObject *parent) + : QNetworkAccessManager(parent) +{ +} + +QNetworkReply *SocialdNetworkAccessManager::createRequest( + QNetworkAccessManager::Operation op, + const QNetworkRequest &req, + QIODevice *outgoingData) +{ + return QNetworkAccessManager::createRequest(op, req, outgoingData); +} diff --git a/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.h b/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.h new file mode 100644 index 0000000..846489b --- /dev/null +++ b/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.h @@ -0,0 +1,40 @@ +/**************************************************************************** + ** + ** Copyright (C) 2013-2014 Jolla Ltd. + ** Contact: Chris Adams <chris.adams@jollamobile.com> + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#ifndef SOCIALD_QNAMFACTORY_P_H +#define SOCIALD_QNAMFACTORY_P_H + +#include <QNetworkAccessManager> + +class SocialdNetworkAccessManager : public QNetworkAccessManager +{ + Q_OBJECT + +public: + SocialdNetworkAccessManager(QObject *parent = 0); + +protected: + QNetworkReply *createRequest(QNetworkAccessManager::Operation op, + const QNetworkRequest &req, + QIODevice *outgoingData = 0) override; +}; + +#endif diff --git a/buteo-plugins/buteo-common/socialnetworksyncadaptor.cpp b/buteo-plugins/buteo-common/socialnetworksyncadaptor.cpp new file mode 100644 index 0000000..4451d5f --- /dev/null +++ b/buteo-plugins/buteo-common/socialnetworksyncadaptor.cpp @@ -0,0 +1,470 @@ +/**************************************************************************** + ** + ** Copyright (C) 2013-2014 Jolla Ltd. + ** Contact: Chris Adams <chris.adams@jollamobile.com> + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#include "socialnetworksyncadaptor.h" +#include "socialdnetworkaccessmanager_p.h" +#include "trace.h" + +#include <QtCore/QJsonDocument> +#include <QtCore/QTimer> +#include <QtSql/QSqlDatabase> +#include <QtSql/QSqlQuery> +#include <QtSql/QSqlError> +#include <QtSql/QSqlRecord> + +#include <QtNetwork/QNetworkAccessManager> +#include <QtNetwork/QNetworkReply> + +#include "buteosyncfw_p.h" + +// libaccounts-qt5 +#include <Accounts/Manager> +#include <Accounts/Account> +#include <Accounts/Service> + +// libsocialcache +#include <socialcache/socialimagesdatabase.h> +#include <socialcache/socialnetworksyncdatabase.h> + +namespace { + QStringList validDataTypesInitialiser() + { + return QStringList() + << QStringLiteral("Contacts") + << QStringLiteral("Calendars") + << QStringLiteral("Notifications") + << QStringLiteral("Images") + << QStringLiteral("Videos") + << QStringLiteral("Posts") + << QStringLiteral("Messages") + << QStringLiteral("Emails") + << QStringLiteral("Signon") + << QStringLiteral("Backup") + << QStringLiteral("BackupQuery") + << QStringLiteral("BackupRestore"); + } +} + +SocialNetworkSyncAdaptor::SocialNetworkSyncAdaptor(const QString &serviceName, + SocialNetworkSyncAdaptor::DataType dataType, + QNetworkAccessManager *qnam, + QObject *parent) + : QObject(parent) + , m_dataType(dataType) + , m_accountManager(new Accounts::Manager(this)) + , m_networkAccessManager(qnam != 0 ? qnam : new SocialdNetworkAccessManager) + , m_accountSyncProfile(NULL) + , m_syncDb(new SocialNetworkSyncDatabase()) + , m_status(SocialNetworkSyncAdaptor::Invalid) + , m_enabled(false) + , m_syncAborted(false) + , m_serviceName(serviceName) +{ +} + +SocialNetworkSyncAdaptor::~SocialNetworkSyncAdaptor() +{ + delete m_networkAccessManager; + delete m_accountSyncProfile; + delete m_syncDb; +} + +// The SocialNetworkSyncAdaptor takes ownership of the sync profiles. +void SocialNetworkSyncAdaptor::setAccountSyncProfile(Buteo::SyncProfile* perAccountSyncProfile) +{ + delete m_accountSyncProfile; + m_accountSyncProfile = perAccountSyncProfile; +} + +SocialNetworkSyncAdaptor::Status SocialNetworkSyncAdaptor::status() const +{ + return m_status; +} + +bool SocialNetworkSyncAdaptor::enabled() const +{ + return m_enabled; +} + +QString SocialNetworkSyncAdaptor::serviceName() const +{ + return m_serviceName; +} + +bool SocialNetworkSyncAdaptor::syncAborted() const +{ + return m_syncAborted; +} + +void SocialNetworkSyncAdaptor::sync(const QString &dataType, int accountId) +{ + Q_UNUSED(dataType) + Q_UNUSED(accountId) + qCWarning(lcSocialPlugin) << "sync() must be overridden by derived types"; +} + +void SocialNetworkSyncAdaptor::abortSync(Sync::SyncStatus status) +{ + qCInfo(lcSocialPlugin) << "forcing timeout of outstanding replies due to abort:" << status; + m_syncAborted = true; + triggerReplyTimeouts(); +} + +/*! + * \brief SocialNetworkSyncAdaptor::checkAccount + * \param account + * \return true if synchronization of this adaptor's datatype is enabled for the account + * + * The default implementation checks that the account is enabled + * with the accounts&sso service associated with this sync adaptor. + */ +bool SocialNetworkSyncAdaptor::checkAccount(Accounts::Account *account) +{ + bool globallyEnabled = account->enabled(); + Accounts::Service srv(m_accountManager->service(syncServiceName())); + if (!srv.isValid()) { + qCInfo(lcSocialPlugin) << "invalid service" << syncServiceName() << "specified, account" << account->id() + << "will be disabled for" << m_serviceName << dataTypeName(m_dataType) << "sync"; + return false; + } + account->selectService(srv); + bool serviceEnabled = account->enabled(); + account->selectService(Accounts::Service()); + return globallyEnabled && serviceEnabled; +} + +/*! + \internal + Called when the semaphores for all accounts have been decreased + to zero. This is the final function which is called prior to + telling buteo that the sync plugin can be destroyed. + The implementation MUST be synchronous. +*/ +void SocialNetworkSyncAdaptor::finalCleanup() +{ +} + +/*! + \internal + Called when the semaphores decreased to 0, this method is used + to finalize something, like saving all data to a database. + + You can call incrementSemaphore to perform asynchronous tasks + in this method. finalize will then be called again when the + asynchronous task is finished (and when decrementSemaphore is + called), be sure to have a condition check in order not to run + into an infinite loop. + + It is unsafe to call decrementSemaphore in this method, as + the semaphore handling method will find that the semaphore + went to 0 twice and will perform cleanup operations twice. + Please call decrementSemaphore at the end of the asynchronous + task (preferably in a slot), and only call incrementSemaphore + for asynchronous tasks. + */ +void SocialNetworkSyncAdaptor::finalize(int accountId) +{ + Q_UNUSED(accountId) +} + +/*! + \internal + Returns the last sync timestamp for the given service, account and data type. + If data from prior to this timestamp is received in subsequent requests, it does not need to be synced. + This function will return an invalid QDateTime if no synchronisation has occurred. +*/ +QDateTime SocialNetworkSyncAdaptor::lastSyncTimestamp(const QString &serviceName, + const QString &dataType, + int accountId) const +{ + return m_syncDb->lastSyncTimestamp(serviceName, dataType, accountId); +} + +/*! + \internal + Updates the last sync timestamp for the given service, account and data type to the given \a timestamp. +*/ +bool SocialNetworkSyncAdaptor::updateLastSyncTimestamp(const QString &serviceName, + const QString &dataType, + int accountId, + const QDateTime ×tamp) +{ + // Workaround + // TODO: do better, with a queue + m_syncDb->addSyncTimestamp(serviceName, dataType, accountId, timestamp); + m_syncDb->commit(); + m_syncDb->wait(); + return m_syncDb->writeStatus() == AbstractSocialCacheDatabase::Finished; +} + +/*! + \internal + Returns the list of identifiers of accounts which have been synced for + the given \a dataType. +*/ +QList<int> SocialNetworkSyncAdaptor::syncedAccounts(const QString &dataType) +{ + return m_syncDb->syncedAccounts(m_serviceName, dataType); +} + +/*! + * \internal + * Changes status if there is real change and emits statusChanged() signal. + */ +void SocialNetworkSyncAdaptor::setStatus(Status status) +{ + if (m_status != status) { + m_status = status; + emit statusChanged(); + } +} + +/*! + * \internal + * Should be used in constructors to set the initial state + * of enabled and status, without emitting signals + * + */ +void SocialNetworkSyncAdaptor::setInitialActive(bool enabled) +{ + m_enabled = enabled; + if (enabled) { + m_status = Inactive; + } else { + m_status = Invalid; + } +} + +/*! + * \internal + * Should be called by any specific sync adapter when + * they've finished syncing data. The transition from + * busy status to inactive status is what causes the + * Buteo plugin to emit the sync results (and allows + * subsequent syncs to occur). + */ +void SocialNetworkSyncAdaptor::setFinishedInactive() +{ + finalCleanup(); + qCInfo(lcSocialPlugin) << "Finished" << m_serviceName << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) + << "sync at:" << QDateTime::currentDateTime().toString(Qt::ISODate); + setStatus(SocialNetworkSyncAdaptor::Inactive); +} + +void SocialNetworkSyncAdaptor::incrementSemaphore(int accountId) +{ + int semaphoreValue = m_accountSyncSemaphores.value(accountId); + semaphoreValue += 1; + m_accountSyncSemaphores.insert(accountId, semaphoreValue); + qCDebug(lcSocialPlugin) << "incremented busy semaphore for account" << accountId << "to:" << semaphoreValue; +} + +void SocialNetworkSyncAdaptor::decrementSemaphore(int accountId) +{ + if (!m_accountSyncSemaphores.contains(accountId)) { + qCWarning(lcSocialPlugin) << "no such semaphore for account" << accountId; + return; + } + + int semaphoreValue = m_accountSyncSemaphores.value(accountId); + semaphoreValue -= 1; + qCDebug(lcSocialPlugin) << "decremented busy semaphore for account" << accountId << "to:" << semaphoreValue; + if (semaphoreValue < 0) { + qCWarning(lcSocialPlugin) << "busy semaphore is negative for account" << accountId; + return; + } + m_accountSyncSemaphores.insert(accountId, semaphoreValue); + + if (semaphoreValue == 0) { + finalize(accountId); + + // With the newer implementation, in finalize we can raise semaphores, + // so if after calling finalize, the semaphore count is not the same anymore, + // we shouldn't update the sync timestamp + if (m_accountSyncSemaphores.value(accountId) > 0) { + return; + } + + // finished all outstanding sync requests for this account. + // update the sync time in the global sociald database. + updateLastSyncTimestamp(m_serviceName, + SocialNetworkSyncAdaptor::dataTypeName(m_dataType), accountId, + QDateTime::currentDateTime().toTimeSpec(Qt::UTC)); + + // if all outstanding requests for all accounts have finished, + // then update our status to Inactive / ready to handle more sync requests. + bool allAreZero = true; + QList<int> semaphores = m_accountSyncSemaphores.values(); + foreach (int sv, semaphores) { + if (sv != 0) { + allAreZero = false; + break; + } + } + + if (allAreZero) { + setFinishedInactive(); // Finished! + } + } +} + +void SocialNetworkSyncAdaptor::timeoutReply() +{ + QTimer *timer = qobject_cast<QTimer*>(sender()); + QNetworkReply *reply = timer->property("networkReply").value<QNetworkReply*>(); + int accountId = timer->property("accountId").toInt(); + + qCWarning(lcSocialPlugin) << "network request timed out while performing sync with account" << accountId; + + m_networkReplyTimeouts[accountId].remove(reply); + reply->setProperty("isError", QVariant::fromValue<bool>(true)); + reply->finished(); // invoke finished, so that the error handling there decrements the semaphore etc. + reply->disconnect(); +} + +void SocialNetworkSyncAdaptor::setupReplyTimeout(int accountId, QNetworkReply *reply, int msecs) +{ + // this function should be called whenever a new network request is performed. + QTimer *timer = new QTimer(this); + timer->setSingleShot(true); + timer->setInterval(msecs); + timer->setProperty("accountId", accountId); + timer->setProperty("networkReply", QVariant::fromValue<QNetworkReply*>(reply)); + connect(timer, &QTimer::timeout, this, &SocialNetworkSyncAdaptor::timeoutReply); + timer->start(); + m_networkReplyTimeouts[accountId].insert(reply, timer); +} + +void SocialNetworkSyncAdaptor::removeReplyTimeout(int accountId, QNetworkReply *reply) +{ + // this function should be called by the finished() handler for the reply. + QTimer *timer = m_networkReplyTimeouts[accountId].value(reply); + if (!reply) { + return; + } + + delete timer; + m_networkReplyTimeouts[accountId].remove(reply); +} + +void SocialNetworkSyncAdaptor::triggerReplyTimeouts() +{ + // if we've lost network connectivity, we should immediately timeout all replies. + Q_FOREACH (int accountId, m_networkReplyTimeouts.keys()) { + Q_FOREACH (QTimer *timer, m_networkReplyTimeouts[accountId]) { + timer->stop(); + timer->setInterval(1); + timer->start(); + } + } +} + +QJsonObject SocialNetworkSyncAdaptor::parseJsonObjectReplyData(const QByteArray &replyData, bool *ok) +{ + QJsonDocument jsonDocument = QJsonDocument::fromJson(replyData); + *ok = !jsonDocument.isEmpty(); + if (*ok && jsonDocument.isObject()) { + return jsonDocument.object(); + } + *ok = false; + return QJsonObject(); +} + +QJsonArray SocialNetworkSyncAdaptor::parseJsonArrayReplyData(const QByteArray &replyData, bool *ok) +{ + QJsonDocument jsonDocument = QJsonDocument::fromJson(replyData); + *ok = !jsonDocument.isEmpty(); + if (*ok && jsonDocument.isArray()) { + return jsonDocument.array(); + } + *ok = false; + return QJsonArray(); +} + +/* + Valid data types are data types which are known to the API. + Note that just because a data type is valid does not mean + that it will necessarily be supported by a given social network + sync adaptor. +*/ +QStringList SocialNetworkSyncAdaptor::validDataTypes() +{ + static QStringList retn(validDataTypesInitialiser()); + return retn; +} + +/* + String for Enum since the DBus API uses strings +*/ +QString SocialNetworkSyncAdaptor::dataTypeName(SocialNetworkSyncAdaptor::DataType t) +{ + switch (t) { + case SocialNetworkSyncAdaptor::Contacts: return QStringLiteral("Contacts"); + case SocialNetworkSyncAdaptor::Calendars: return QStringLiteral("Calendars"); + case SocialNetworkSyncAdaptor::Notifications: return QStringLiteral("Notifications"); + case SocialNetworkSyncAdaptor::Images: return QStringLiteral("Images"); + case SocialNetworkSyncAdaptor::Videos: return QStringLiteral("Videos"); + case SocialNetworkSyncAdaptor::Posts: return QStringLiteral("Posts"); + case SocialNetworkSyncAdaptor::Messages: return QStringLiteral("Messages"); + case SocialNetworkSyncAdaptor::Emails: return QStringLiteral("Emails"); + case SocialNetworkSyncAdaptor::Signon: return QStringLiteral("Signon"); + case SocialNetworkSyncAdaptor::Backup: return QStringLiteral("Backup"); + case SocialNetworkSyncAdaptor::BackupQuery: return QStringLiteral("BackupQuery"); + case SocialNetworkSyncAdaptor::BackupRestore: return QStringLiteral("BackupRestore"); + default: break; + } + + return QString(); +} + +void SocialNetworkSyncAdaptor::purgeCachedImages(SocialImagesDatabase *database, + int accountId) +{ + database->queryImages(accountId); + database->wait(); + + QList<SocialImage::ConstPtr> images = database->images(); + foreach (SocialImage::ConstPtr image, images) { + qCDebug(lcSocialPlugin) << "Purge cached image " << image->imageFile() << " for account " << image->accountId(); + QFile::remove(image->imageFile()); + } + + database->removeImages(images); + database->commit(); + database->wait(); +} + +void SocialNetworkSyncAdaptor::purgeExpiredImages(SocialImagesDatabase *database, + int accountId) +{ + database->queryExpired(accountId); + database->wait(); + + QList<SocialImage::ConstPtr> images = database->images(); + foreach (SocialImage::ConstPtr image, images) { + qCDebug(lcSocialPlugin) << "Purge expired image " << image->imageFile() << " for account " << image->accountId(); + QFile::remove(image->imageFile()); + } + + database->removeImages(images); + database->commit(); + database->wait(); +} diff --git a/buteo-plugins/buteo-common/socialnetworksyncadaptor.h b/buteo-plugins/buteo-common/socialnetworksyncadaptor.h new file mode 100644 index 0000000..99adeb3 --- /dev/null +++ b/buteo-plugins/buteo-common/socialnetworksyncadaptor.h @@ -0,0 +1,155 @@ +/**************************************************************************** + ** + ** Copyright (C) 2013-2014 Jolla Ltd. + ** Contact: Chris Adams <chris.adams@jollamobile.com> + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#ifndef SOCIALNETWORKSYNCADAPTOR_H +#define SOCIALNETWORKSYNCADAPTOR_H + +#include <QtCore/QObject> +#include <QtCore/QDateTime> +#include <QtCore/QString> +#include <QtCore/QJsonObject> +#include <QtCore/QJsonArray> +#include <QtCore/QMap> +#include <QtCore/QList> + +#include "buteosyncfw_p.h" + +class QSqlDatabase; +class QNetworkAccessManager; +class QTimer; +class QNetworkReply; +class SocialNetworkSyncDatabase; +class SocialImagesDatabase; + +namespace Accounts { + class Account; + class Manager; +} + +class SocialNetworkSyncAdaptor : public QObject +{ + Q_OBJECT + Q_PROPERTY(Status status READ status NOTIFY statusChanged) + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) + +public: + enum Status { + Initializing = 0, + Inactive, + Busy, + Error, + Invalid + }; + + enum PurgeMode { + SyncPurge = 0, + CleanUpPurge + }; + + enum DataType { + Contacts = 1, // "Contacts" + Calendars, // "Calendars" + Notifications, // "Notifications" + Images, // "Images" + Videos, // "Videos" + Posts, // "Posts" + Messages, // "Messages" + Emails, // "Emails" + Signon, // "Signon" -- for refreshing AccessTokens etc. + Backup, // "Backup" + BackupQuery, // "BackupQuery" + BackupRestore // "BackupRestore" + }; + static QStringList validDataTypes(); + static QString dataTypeName(DataType t); + +public: + SocialNetworkSyncAdaptor(const QString &serviceName, SocialNetworkSyncAdaptor::DataType dataType, + QNetworkAccessManager *qnam, QObject *parent); + virtual ~SocialNetworkSyncAdaptor(); + + virtual QString syncServiceName() const = 0; + void setAccountSyncProfile(Buteo::SyncProfile* perAccountSyncProfile); + + Status status() const; + bool enabled() const; + QString serviceName() const; + + virtual void sync(const QString &dataType, int accountId = 0); + virtual void purgeDataForOldAccount(int accountId, PurgeMode mode = SyncPurge) = 0; + virtual void abortSync(Sync::SyncStatus status); + +Q_SIGNALS: + void statusChanged(); + void enabledChanged(); + +protected: + virtual bool checkAccount(Accounts::Account *account); + virtual void finalCleanup(); + virtual void finalize(int accountId); + QDateTime lastSyncTimestamp(const QString &serviceName, const QString &dataType, + int accountId) const; + bool updateLastSyncTimestamp(const QString &serviceName, const QString &dataType, + int accountId, const QDateTime ×tamp); + QList<int> syncedAccounts(const QString &dataType); + void setStatus(Status status); + void setInitialActive(bool enabled); + void setFinishedInactive(); + + // whether the sync has been aborted (perhaps due to network connection loss) + bool syncAborted() const; + + // Semaphore system + void incrementSemaphore(int accountId); + void decrementSemaphore(int accountId); + + // network reply timeouts + void setupReplyTimeout(int accountId, QNetworkReply *reply, int msecs = 60000); + void removeReplyTimeout(int accountId, QNetworkReply *reply); + void triggerReplyTimeouts(); + + // Parsing methods + static QJsonObject parseJsonObjectReplyData(const QByteArray &replyData, bool *ok); + static QJsonArray parseJsonArrayReplyData(const QByteArray &replyData, bool *ok); + + // Cache management + void purgeCachedImages(SocialImagesDatabase *database, int accountId); + void purgeExpiredImages(SocialImagesDatabase *database, int accountId); + + const SocialNetworkSyncAdaptor::DataType m_dataType; + Accounts::Manager * const m_accountManager; + QNetworkAccessManager * const m_networkAccessManager; + Buteo::SyncProfile *m_accountSyncProfile; + +protected Q_SLOTS: + virtual void timeoutReply(); + +private: + SocialNetworkSyncDatabase *m_syncDb; + SocialNetworkSyncAdaptor::Status m_status; + bool m_enabled; + bool m_syncAborted; + QString m_serviceName; + QMap<int, int> m_accountSyncSemaphores; + QMap<int, QMap<QNetworkReply*, QTimer *> > m_networkReplyTimeouts; +}; + +#endif // SOCIALNETWORKSYNCADAPTOR_H diff --git a/buteo-plugins/buteo-common/trace.cpp b/buteo-plugins/buteo-common/trace.cpp new file mode 100644 index 0000000..ffcdf5b --- /dev/null +++ b/buteo-plugins/buteo-common/trace.cpp @@ -0,0 +1,25 @@ +/**************************************************************************** + ** + ** Copyright (C) 2021 Jolla Ltd. + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#include "trace.h" + +Q_LOGGING_CATEGORY(lcSocialPlugin, "buteo.plugin.social", QtWarningMsg) +Q_LOGGING_CATEGORY(lcSocialPluginTrace, "buteo.plugin.social.trace", QtWarningMsg) + diff --git a/buteo-plugins/buteo-common/trace.h b/buteo-plugins/buteo-common/trace.h new file mode 100644 index 0000000..b0aeeeb --- /dev/null +++ b/buteo-plugins/buteo-common/trace.h @@ -0,0 +1,30 @@ +/**************************************************************************** + ** + ** Copyright (C) 2013-2014 Jolla Ltd. + ** Contact: Chris Adams <chris.adams@jollamobile.com> + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#ifndef TRACE_H +#define TRACE_H + +#include <QLoggingCategory> + +Q_DECLARE_LOGGING_CATEGORY(lcSocialPlugin) +Q_DECLARE_LOGGING_CATEGORY(lcSocialPluginTrace) + +#endif // TRACE_H diff --git a/buteo-plugins/buteo-plugins.pro b/buteo-plugins/buteo-plugins.pro new file mode 100644 index 0000000..5a48850 --- /dev/null +++ b/buteo-plugins/buteo-plugins.pro @@ -0,0 +1,6 @@ +TEMPLATE = subdirs +SUBDIRS += \ + buteo-common \ + buteo-sync-plugin-mastodon-posts + +buteo-sync-plugin-mastodon-posts.depends = buteo-common diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro b/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro new file mode 100644 index 0000000..86387b2 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro @@ -0,0 +1,37 @@ +TARGET = mastodon-posts-client + +QT -= gui + +include($$PWD/../buteo-common/buteo-common.pri) +include($$PWD/../../common/common.pri) + +CONFIG += link_pkgconfig +PKGCONFIG += mlite5 nemonotifications-qt5 + +INCLUDEPATH += $$PWD + +SOURCES += \ + $$PWD/mastodondatatypesyncadaptor.cpp \ + $$PWD/mastodonpostsplugin.cpp \ + $$PWD/mastodonpostssyncadaptor.cpp + +HEADERS += \ + $$PWD/mastodondatatypesyncadaptor.h \ + $$PWD/mastodonpostsplugin.h \ + $$PWD/mastodonpostssyncadaptor.h + +OTHER_FILES += \ + $$PWD/mastodon-posts.xml \ + $$PWD/mastodon.Posts.xml + +TEMPLATE = lib +CONFIG += plugin +target.path = $$[QT_INSTALL_LIBS]/buteo-plugins-qt5/oopp + +sync.path = /etc/buteo/profiles/sync +sync.files = mastodon.Posts.xml + +client.path = /etc/buteo/profiles/client +client.files = mastodon-posts.xml + +INSTALLS += target sync client diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml new file mode 100644 index 0000000..7f1fec2 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<profile name="mastodon-posts" type="client" > + <field name="Sync Direction" /> +</profile> diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml new file mode 100644 index 0000000..44183a6 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<profile name="mastodon.Posts" type="sync" > + <key name="category" value="eventfeed" /> + <key name="enabled" value="false" /> + <key name="use_accounts" value="false" /> + <key name="destinationtype" value="online" /> + <key name="hidden" value="true" /> + <key name="displayname" value="Mastodon Posts"/> + + <schedule enabled="false" interval="30" days="1,2,3,4,5,6,7" syncconfiguredtime="" time="" /> + + <profile name="mastodon-posts" type="client" > + <key name="Sync Direction" value="from-remote" /> + </profile> +</profile> diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp new file mode 100644 index 0000000..977b33b --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp @@ -0,0 +1,313 @@ +/**************************************************************************** + ** + ** Copyright (C) 2026 Open Mobile Platform LLC. + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#include "mastodondatatypesyncadaptor.h" +#include "trace.h" + +#include <QtCore/QVariantMap> +#include <QtCore/QUrl> +#include <QtNetwork/QNetworkRequest> + +// libaccounts-qt5 +#include <Accounts/Manager> +#include <Accounts/Account> +#include <Accounts/Service> +#include <Accounts/AccountService> + +// libsignon-qt5 +#include <SignOn/Identity> +#include <SignOn/AuthSession> +#include <SignOn/SessionData> + +MastodonDataTypeSyncAdaptor::MastodonDataTypeSyncAdaptor( + SocialNetworkSyncAdaptor::DataType dataType, + QObject *parent) + : SocialNetworkSyncAdaptor(QStringLiteral("mastodon"), dataType, 0, parent) +{ +} + +MastodonDataTypeSyncAdaptor::~MastodonDataTypeSyncAdaptor() +{ +} + +void MastodonDataTypeSyncAdaptor::sync(const QString &dataTypeString, int accountId) +{ + if (dataTypeString != SocialNetworkSyncAdaptor::dataTypeName(m_dataType)) { + qCWarning(lcSocialPlugin) << "Mastodon" << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) + << "sync adaptor was asked to sync" << dataTypeString; + setStatus(SocialNetworkSyncAdaptor::Error); + return; + } + + setStatus(SocialNetworkSyncAdaptor::Busy); + updateDataForAccount(accountId); + qCDebug(lcSocialPlugin) << "successfully triggered sync with profile:" << m_accountSyncProfile->name(); +} + +void MastodonDataTypeSyncAdaptor::updateDataForAccount(int accountId) +{ + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); + if (!account) { + qCWarning(lcSocialPlugin) << "existing account with id" << accountId << "couldn't be retrieved"; + setStatus(SocialNetworkSyncAdaptor::Error); + return; + } + + incrementSemaphore(accountId); + signIn(account); +} + +QString MastodonDataTypeSyncAdaptor::apiHost(int accountId) const +{ + return m_apiHosts.value(accountId, QStringLiteral("https://mastodon.social")); +} + +void MastodonDataTypeSyncAdaptor::errorHandler(QNetworkReply::NetworkError err) +{ + QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender()); + if (!reply) { + return; + } + + const int accountId = reply->property("accountId").toInt(); + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + qCWarning(lcSocialPlugin) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) + << "request with account" << accountId + << "experienced error:" << err + << "HTTP:" << httpStatus; + + reply->setProperty("isError", QVariant::fromValue<bool>(true)); + + if (httpStatus == 401 || err == QNetworkReply::AuthenticationRequiredError) { + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); + if (account) { + setCredentialsNeedUpdate(account); + } + } +} + +void MastodonDataTypeSyncAdaptor::sslErrorsHandler(const QList<QSslError> &errs) +{ + QString sslerrs; + foreach (const QSslError &e, errs) { + sslerrs += e.errorString() + QLatin1String("; "); + } + if (!sslerrs.isEmpty()) { + sslerrs.chop(2); + } + + qCWarning(lcSocialPlugin) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) + << "request with account" << sender()->property("accountId").toInt() + << "experienced ssl errors:" << sslerrs; + sender()->setProperty("isError", QVariant::fromValue<bool>(true)); +} + +void MastodonDataTypeSyncAdaptor::setCredentialsNeedUpdate(Accounts::Account *account) +{ + qCInfo(lcSocialPlugin) << "sociald:Mastodon: setting CredentialsNeedUpdate to true for account:" << account->id(); + Accounts::Service srv(m_accountManager->service(syncServiceName())); + account->selectService(srv); + account->setValue(QStringLiteral("CredentialsNeedUpdate"), QVariant::fromValue<bool>(true)); + account->setValue(QStringLiteral("CredentialsNeedUpdateFrom"), QVariant::fromValue<QString>(QString::fromLatin1("sociald-mastodon"))); + account->selectService(Accounts::Service()); + account->syncAndBlock(); +} + +QString MastodonDataTypeSyncAdaptor::normalizeApiHost(const QString &rawHost) +{ + QString host = rawHost.trimmed(); + if (host.isEmpty()) { + host = QStringLiteral("https://mastodon.social"); + } + if (!host.startsWith(QLatin1String("https://")) + && !host.startsWith(QLatin1String("http://"))) { + host.prepend(QStringLiteral("https://")); + } + + QUrl url(host); + if (!url.isValid() || url.host().isEmpty()) { + return QStringLiteral("https://mastodon.social"); + } + + QString normalized = QString::fromLatin1(url.toEncoded(QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment)); + if (normalized.endsWith(QLatin1Char('/'))) { + normalized.chop(1); + } + return normalized; +} + +void MastodonDataTypeSyncAdaptor::signIn(Accounts::Account *account) +{ + const int accountId = account->id(); + if (!checkAccount(account)) { + decrementSemaphore(accountId); + return; + } + + Accounts::Service srv(m_accountManager->service(syncServiceName())); + account->selectService(srv); + SignOn::Identity *identity = account->credentialsId() > 0 + ? SignOn::Identity::existingIdentity(account->credentialsId()) + : 0; + if (!identity) { + qCWarning(lcSocialPlugin) << "account" << accountId << "has no valid credentials, cannot sign in"; + decrementSemaphore(accountId); + return; + } + + Accounts::AccountService accSrv(account, srv); + const QString method = accSrv.authData().method(); + const QString mechanism = accSrv.authData().mechanism(); + SignOn::AuthSession *session = identity->createSession(method); + if (!session) { + qCWarning(lcSocialPlugin) << "could not create signon session for account" << accountId; + identity->deleteLater(); + decrementSemaphore(accountId); + return; + } + + QVariantMap signonSessionData = accSrv.authData().parameters(); + QString configuredHost = account->value(QStringLiteral("auth/oauth2/web_server/Host")).toString().trimmed(); + if (configuredHost.isEmpty()) { + configuredHost = normalizeApiHost(account->value(QStringLiteral("api/Host")).toString()); + } + if (configuredHost.startsWith(QLatin1String("https://"))) { + configuredHost.remove(0, 8); + } else if (configuredHost.startsWith(QLatin1String("http://"))) { + configuredHost.remove(0, 7); + } + while (configuredHost.endsWith(QLatin1Char('/'))) { + configuredHost.chop(1); + } + if (configuredHost.isEmpty()) { + configuredHost = QStringLiteral("mastodon.social"); + } + signonSessionData.insert(QStringLiteral("Host"), configuredHost); + + const QString authPath = account->value(QStringLiteral("auth/oauth2/web_server/AuthPath")).toString().trimmed(); + if (!authPath.isEmpty()) { + signonSessionData.insert(QStringLiteral("AuthPath"), authPath); + } + + const QString tokenPath = account->value(QStringLiteral("auth/oauth2/web_server/TokenPath")).toString().trimmed(); + if (!tokenPath.isEmpty()) { + signonSessionData.insert(QStringLiteral("TokenPath"), tokenPath); + } + + const QString responseType = account->value(QStringLiteral("auth/oauth2/web_server/ResponseType")).toString().trimmed(); + if (!responseType.isEmpty()) { + signonSessionData.insert(QStringLiteral("ResponseType"), responseType); + } + + const QString redirectUri = account->value(QStringLiteral("auth/oauth2/web_server/RedirectUri")).toString().trimmed(); + if (!redirectUri.isEmpty()) { + signonSessionData.insert(QStringLiteral("RedirectUri"), redirectUri); + } + + const QVariant scopeValue = account->value(QStringLiteral("auth/oauth2/web_server/Scope")); + if (scopeValue.isValid()) { + signonSessionData.insert(QStringLiteral("Scope"), scopeValue); + } + + const QString clientId = account->value(QStringLiteral("auth/oauth2/web_server/ClientId")).toString().trimmed(); + if (!clientId.isEmpty()) { + signonSessionData.insert(QStringLiteral("ClientId"), clientId); + } + + const QString clientSecret = account->value(QStringLiteral("auth/oauth2/web_server/ClientSecret")).toString().trimmed(); + if (!clientSecret.isEmpty()) { + signonSessionData.insert(QStringLiteral("ClientSecret"), clientSecret); + } + + signonSessionData.insert(QStringLiteral("UiPolicy"), SignOn::NoUserInteractionPolicy); + + connect(session, SIGNAL(response(SignOn::SessionData)), + this, SLOT(signOnResponse(SignOn::SessionData)), + Qt::UniqueConnection); + connect(session, SIGNAL(error(SignOn::Error)), + this, SLOT(signOnError(SignOn::Error)), + Qt::UniqueConnection); + + session->setProperty("account", QVariant::fromValue<Accounts::Account*>(account)); + session->setProperty("identity", QVariant::fromValue<SignOn::Identity*>(identity)); + session->process(SignOn::SessionData(signonSessionData), mechanism); +} + +void MastodonDataTypeSyncAdaptor::signOnError(const SignOn::Error &error) +{ + SignOn::AuthSession *session = qobject_cast<SignOn::AuthSession*>(sender()); + Accounts::Account *account = session->property("account").value<Accounts::Account*>(); + SignOn::Identity *identity = session->property("identity").value<SignOn::Identity*>(); + const int accountId = account->id(); + + qCWarning(lcSocialPlugin) << "credentials for account with id" << accountId + << "couldn't be retrieved:" << error.type() << error.message(); + + if (error.type() == SignOn::Error::UserInteraction) { + setCredentialsNeedUpdate(account); + } + + session->disconnect(this); + identity->destroySession(session); + identity->deleteLater(); + account->deleteLater(); + + setStatus(SocialNetworkSyncAdaptor::Error); + decrementSemaphore(accountId); +} + +void MastodonDataTypeSyncAdaptor::signOnResponse(const SignOn::SessionData &responseData) +{ + QVariantMap data; + foreach (const QString &key, responseData.propertyNames()) { + data.insert(key, responseData.getProperty(key)); + } + + QString accessToken; + SignOn::AuthSession *session = qobject_cast<SignOn::AuthSession*>(sender()); + Accounts::Account *account = session->property("account").value<Accounts::Account*>(); + SignOn::Identity *identity = session->property("identity").value<SignOn::Identity*>(); + const int accountId = account->id(); + + accessToken = data.value(QLatin1String("AccessToken")).toString().trimmed(); + if (accessToken.isEmpty()) { + accessToken = data.value(QLatin1String("access_token")).toString().trimmed(); + } + if (accessToken.isEmpty()) { + qCWarning(lcSocialPlugin) << "signon response for account with id" << accountId + << "contained no access token; keys:" << data.keys(); + } + + m_apiHosts.insert(accountId, normalizeApiHost(account->value(QStringLiteral("api/Host")).toString())); + + session->disconnect(this); + identity->destroySession(session); + identity->deleteLater(); + account->deleteLater(); + + if (!accessToken.isEmpty()) { + beginSync(accountId, accessToken); + } else { + setStatus(SocialNetworkSyncAdaptor::Error); + } + + decrementSemaphore(accountId); +} diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h new file mode 100644 index 0000000..f295cd8 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h @@ -0,0 +1,70 @@ +/**************************************************************************** + ** + ** Copyright (C) 2026 Open Mobile Platform LLC. + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#ifndef MASTODONDATATYPESYNCADAPTOR_H +#define MASTODONDATATYPESYNCADAPTOR_H + +#include "socialnetworksyncadaptor.h" + +#include <QtCore/QMap> +#include <QtNetwork/QNetworkReply> +#include <QtNetwork/QSslError> + +namespace Accounts { + class Account; +} +namespace SignOn { + class Error; + class SessionData; +} + +class MastodonDataTypeSyncAdaptor : public SocialNetworkSyncAdaptor +{ + Q_OBJECT + +public: + MastodonDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::DataType dataType, QObject *parent); + virtual ~MastodonDataTypeSyncAdaptor(); + + void sync(const QString &dataTypeString, int accountId) override; + +protected: + QString apiHost(int accountId) const; + virtual void updateDataForAccount(int accountId); + virtual void beginSync(int accountId, const QString &accessToken) = 0; + +protected Q_SLOTS: + virtual void errorHandler(QNetworkReply::NetworkError err); + virtual void sslErrorsHandler(const QList<QSslError> &errs); + +private Q_SLOTS: + void signOnError(const SignOn::Error &error); + void signOnResponse(const SignOn::SessionData &responseData); + +private: + static QString normalizeApiHost(const QString &rawHost); + void setCredentialsNeedUpdate(Accounts::Account *account); + void signIn(Accounts::Account *account); + +private: + QMap<int, QString> m_apiHosts; +}; + +#endif // MASTODONDATATYPESYNCADAPTOR_H diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp new file mode 100644 index 0000000..d3c8d50 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp @@ -0,0 +1,49 @@ +/**************************************************************************** + ** + ** Copyright (C) 2026 Open Mobile Platform LLC. + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#include "mastodonpostsplugin.h" +#include "mastodonpostssyncadaptor.h" +#include "socialnetworksyncadaptor.h" + +MastodonPostsPlugin::MastodonPostsPlugin(const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface *callbackInterface) + : SocialdButeoPlugin(pluginName, profile, callbackInterface, + QStringLiteral("mastodon"), + SocialNetworkSyncAdaptor::dataTypeName(SocialNetworkSyncAdaptor::Posts)) +{ +} + +MastodonPostsPlugin::~MastodonPostsPlugin() +{ +} + +SocialNetworkSyncAdaptor *MastodonPostsPlugin::createSocialNetworkSyncAdaptor() +{ + return new MastodonPostsSyncAdaptor(this); +} + +Buteo::ClientPlugin* MastodonPostsPluginLoader::createClientPlugin( + const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface* cbInterface) +{ + return new MastodonPostsPlugin(pluginName, profile, cbInterface); +} diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h new file mode 100644 index 0000000..35dbb56 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h @@ -0,0 +1,54 @@ +/**************************************************************************** + ** + ** Copyright (C) 2026 Open Mobile Platform LLC. + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#ifndef MASTODONPOSTSPLUGIN_H +#define MASTODONPOSTSPLUGIN_H + +#include "socialdbuteoplugin.h" + +#include <buteosyncfw5/SyncPluginLoader.h> + +class Q_DECL_EXPORT MastodonPostsPlugin : public SocialdButeoPlugin +{ + Q_OBJECT + +public: + MastodonPostsPlugin(const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface *cbInterface); + ~MastodonPostsPlugin(); + +protected: + SocialNetworkSyncAdaptor *createSocialNetworkSyncAdaptor() override; +}; + +class MastodonPostsPluginLoader : public Buteo::SyncPluginLoader +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.sailfishos.plugins.sync.MastodonPostsPluginLoader") + Q_INTERFACES(Buteo::SyncPluginLoader) + +public: + Buteo::ClientPlugin* createClientPlugin(const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface* cbInterface) override; +}; + +#endif // MASTODONPOSTSPLUGIN_H diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp new file mode 100644 index 0000000..7ccf0a2 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp @@ -0,0 +1,316 @@ +/**************************************************************************** + ** + ** Copyright (C) 2026 Open Mobile Platform LLC. + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#include "mastodonpostssyncadaptor.h" +#include "trace.h" + +#include <QtCore/QJsonArray> +#include <QtCore/QJsonObject> +#include <QtCore/QJsonValue> +#include <QtCore/QRegularExpression> +#include <QtCore/QUrl> +#include <QtCore/QUrlQuery> +#include <QtNetwork/QNetworkRequest> + +namespace { + QString decodeHtmlEntities(QString text) + { + text.replace(QStringLiteral("""), QStringLiteral("\"")); + text.replace(QStringLiteral("'"), QStringLiteral("'")); + text.replace(QStringLiteral("<"), QStringLiteral("<")); + text.replace(QStringLiteral(">"), QStringLiteral(">")); + text.replace(QStringLiteral("&"), QStringLiteral("&")); + text.replace(QStringLiteral(" "), QStringLiteral(" ")); + + static const QRegularExpression decimalEntity(QStringLiteral("&#(\\d+);")); + QRegularExpressionMatch match; + int index = 0; + while ((index = text.indexOf(decimalEntity, index, &match)) != -1) { + const uint value = match.captured(1).toUInt(); + const QString replacement = value > 0 ? QString(QChar(value)) : QString(); + text.replace(index, match.capturedLength(0), replacement); + index += replacement.size(); + } + + static const QRegularExpression hexEntity(QStringLiteral("&#x([0-9a-fA-F]+);")); + index = 0; + while ((index = text.indexOf(hexEntity, index, &match)) != -1) { + bool ok = false; + const uint value = match.captured(1).toUInt(&ok, 16); + const QString replacement = ok && value > 0 ? QString(QChar(value)) : QString(); + text.replace(index, match.capturedLength(0), replacement); + index += replacement.size(); + } + + return text; + } + + QString displayNameForAccount(const QJsonObject &account) + { + const QString displayName = account.value(QStringLiteral("display_name")).toString().trimmed(); + if (!displayName.isEmpty()) { + return displayName; + } + + const QString username = account.value(QStringLiteral("username")).toString().trimmed(); + if (!username.isEmpty()) { + return username; + } + + return account.value(QStringLiteral("acct")).toString().trimmed(); + } +} + +MastodonPostsSyncAdaptor::MastodonPostsSyncAdaptor(QObject *parent) + : MastodonDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::Posts, parent) +{ + setInitialActive(m_db.isValid()); +} + +MastodonPostsSyncAdaptor::~MastodonPostsSyncAdaptor() +{ +} + +QString MastodonPostsSyncAdaptor::syncServiceName() const +{ + return QStringLiteral("mastodon-microblog"); +} + +void MastodonPostsSyncAdaptor::purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode) +{ + m_db.removePosts(oldId); + m_db.commit(); + m_db.wait(); + + purgeCachedImages(&m_imageCacheDb, oldId); +} + +void MastodonPostsSyncAdaptor::beginSync(int accountId, const QString &accessToken) +{ + requestPosts(accountId, accessToken); +} + +void MastodonPostsSyncAdaptor::finalize(int accountId) +{ + if (syncAborted()) { + qCInfo(lcSocialPlugin) << "sync aborted, won't commit database changes"; + } else { + m_db.commit(); + m_db.wait(); + purgeExpiredImages(&m_imageCacheDb, accountId); + } +} + +QString MastodonPostsSyncAdaptor::sanitizeContent(const QString &content) +{ + QString plain = content; + plain.replace(QRegularExpression(QStringLiteral("<\\s*br\\s*/?\\s*>"), QRegularExpression::CaseInsensitiveOption), QStringLiteral("\n")); + plain.replace(QRegularExpression(QStringLiteral("<\\s*/\\s*p\\s*>"), QRegularExpression::CaseInsensitiveOption), QStringLiteral("\n")); + plain.remove(QRegularExpression(QStringLiteral("<[^>]+>"), QRegularExpression::CaseInsensitiveOption)); + + return decodeHtmlEntities(plain).trimmed(); +} + +QDateTime MastodonPostsSyncAdaptor::parseTimestamp(const QString ×tampString) +{ + QDateTime timestamp; + +#if QT_VERSION >= QT_VERSION_CHECK(5, 8, 0) + timestamp = QDateTime::fromString(timestampString, Qt::ISODateWithMs); + if (timestamp.isValid()) { + return timestamp; + } +#endif + + timestamp = QDateTime::fromString(timestampString, Qt::ISODate); + if (timestamp.isValid()) { + return timestamp; + } + + // Qt 5.6 cannot parse ISO-8601 timestamps with fractional seconds. + const int timeSeparator = timestampString.indexOf(QLatin1Char('T')); + const int fractionSeparator = timestampString.indexOf(QLatin1Char('.'), timeSeparator + 1); + if (timeSeparator > -1 && fractionSeparator > -1) { + int timezoneSeparator = timestampString.indexOf(QLatin1Char('Z'), fractionSeparator + 1); + if (timezoneSeparator == -1) { + timezoneSeparator = timestampString.indexOf(QLatin1Char('+'), fractionSeparator + 1); + } + if (timezoneSeparator == -1) { + timezoneSeparator = timestampString.indexOf(QLatin1Char('-'), fractionSeparator + 1); + } + + QString stripped = timestampString; + if (timezoneSeparator > -1) { + stripped.remove(fractionSeparator, timezoneSeparator - fractionSeparator); + } else { + stripped.truncate(fractionSeparator); + } + + timestamp = QDateTime::fromString(stripped, Qt::ISODate); + } + + return timestamp; +} + +void MastodonPostsSyncAdaptor::requestPosts(int accountId, const QString &accessToken) +{ + QUrl url(apiHost(accountId) + QStringLiteral("/api/v1/timelines/home")); + + QUrlQuery query(url); + query.addQueryItem(QStringLiteral("limit"), QStringLiteral("20")); + url.setQuery(query); + + QNetworkRequest request(url); + request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(accessToken).toUtf8()); + + QNetworkReply *reply = m_networkAccessManager->get(request); + if (reply) { + reply->setProperty("accountId", accountId); + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(errorHandler(QNetworkReply::NetworkError))); + connect(reply, SIGNAL(sslErrors(QList<QSslError>)), this, SLOT(sslErrorsHandler(QList<QSslError>))); + connect(reply, SIGNAL(finished()), this, SLOT(finishedPostsHandler())); + + incrementSemaphore(accountId); + setupReplyTimeout(accountId, reply); + } else { + qCWarning(lcSocialPlugin) << "unable to request home timeline posts from Mastodon account with id" << accountId; + } +} + +void MastodonPostsSyncAdaptor::finishedPostsHandler() +{ + QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender()); + if (!reply) { + return; + } + + const bool isError = reply->property("isError").toBool(); + const int accountId = reply->property("accountId").toInt(); + QByteArray replyData = reply->readAll(); + + disconnect(reply); + reply->deleteLater(); + removeReplyTimeout(accountId, reply); + + bool ok = false; + QJsonArray statuses = parseJsonArrayReplyData(replyData, &ok); + if (!isError && ok) { + if (!statuses.size()) { + qCDebug(lcSocialPlugin) << "no feed posts received for account" << accountId; + decrementSemaphore(accountId); + return; + } + + m_db.removePosts(accountId); + + const int sinceSpan = m_accountSyncProfile + ? m_accountSyncProfile->key(Buteo::KEY_SYNC_SINCE_DAYS_PAST, QStringLiteral("7")).toInt() + : 7; + + foreach (const QJsonValue &statusValue, statuses) { + const QJsonObject statusObject = statusValue.toObject(); + if (statusObject.isEmpty()) { + continue; + } + + QJsonObject postObject = statusObject; + QString boostedBy; + if (statusObject.contains(QStringLiteral("reblog")) + && statusObject.value(QStringLiteral("reblog")).isObject() + && !statusObject.value(QStringLiteral("reblog")).isNull()) { + boostedBy = displayNameForAccount(statusObject.value(QStringLiteral("account")).toObject()); + postObject = statusObject.value(QStringLiteral("reblog")).toObject(); + } + + QDateTime eventTimestamp = parseTimestamp(statusObject.value(QStringLiteral("created_at")).toString()); + if (!eventTimestamp.isValid()) { + eventTimestamp = parseTimestamp(postObject.value(QStringLiteral("created_at")).toString()); + } + if (!eventTimestamp.isValid()) { + continue; + } + + if (eventTimestamp.daysTo(QDateTime::currentDateTime()) > sinceSpan) { + continue; + } + + const QJsonObject account = postObject.value(QStringLiteral("account")).toObject(); + const QString displayName = displayNameForAccount(account); + const QString accountName = account.value(QStringLiteral("acct")).toString(); + QString icon = account.value(QStringLiteral("avatar_static")).toString(); + if (icon.isEmpty()) { + icon = account.value(QStringLiteral("avatar")).toString(); + } + + QString identifier = postObject.value(QStringLiteral("id")).toVariant().toString(); + if (identifier.isEmpty()) { + continue; + } + + QString url = postObject.value(QStringLiteral("url")).toString(); + if (url.isEmpty() && !accountName.isEmpty()) { + url = QStringLiteral("%1/@%2/%3").arg(apiHost(accountId), accountName, identifier); + } + + const QString body = sanitizeContent(postObject.value(QStringLiteral("content")).toString()); + + QList<QPair<QString, SocialPostImage::ImageType> > imageList; + const QJsonArray mediaAttachments = postObject.value(QStringLiteral("media_attachments")).toArray(); + foreach (const QJsonValue &attachmentValue, mediaAttachments) { + const QJsonObject attachment = attachmentValue.toObject(); + const QString mediaType = attachment.value(QStringLiteral("type")).toString(); + + QString mediaUrl; + SocialPostImage::ImageType imageType = SocialPostImage::Invalid; + if (mediaType == QLatin1String("image")) { + mediaUrl = attachment.value(QStringLiteral("url")).toString(); + imageType = SocialPostImage::Photo; + } else if (mediaType == QLatin1String("video") || mediaType == QLatin1String("gifv")) { + mediaUrl = attachment.value(QStringLiteral("preview_url")).toString(); + if (mediaUrl.isEmpty()) { + mediaUrl = attachment.value(QStringLiteral("url")).toString(); + } + imageType = SocialPostImage::Video; + } + + if (!mediaUrl.isEmpty() && imageType != SocialPostImage::Invalid) { + imageList.append(qMakePair(mediaUrl, imageType)); + } + } + + m_db.addMastodonPost(identifier, + displayName, + accountName, + body, + eventTimestamp, + icon, + imageList, + url, + boostedBy, + apiHost(accountId), + accountId); + } + } else { + qCWarning(lcSocialPlugin) << "unable to parse event feed data from request with account" << accountId + << ", got:" << QString::fromUtf8(replyData); + } + + decrementSemaphore(accountId); +} diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h new file mode 100644 index 0000000..10f8b1c --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h @@ -0,0 +1,61 @@ +/**************************************************************************** + ** + ** Copyright (C) 2026 Open Mobile Platform LLC. + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#ifndef MASTODONPOSTSSYNCADAPTOR_H +#define MASTODONPOSTSSYNCADAPTOR_H + +#include "mastodondatatypesyncadaptor.h" + +#include <QtCore/QDateTime> +#include <QtNetwork/QNetworkReply> + +#include "mastodonpostsdatabase.h" +#include <socialcache/socialimagesdatabase.h> + +class MastodonPostsSyncAdaptor : public MastodonDataTypeSyncAdaptor +{ + Q_OBJECT + +public: + MastodonPostsSyncAdaptor(QObject *parent); + ~MastodonPostsSyncAdaptor(); + + QString syncServiceName() const override; + +protected: + void purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode mode) override; + void beginSync(int accountId, const QString &accessToken) override; + void finalize(int accountId) override; + +private: + static QString sanitizeContent(const QString &content); + static QDateTime parseTimestamp(const QString ×tampString); + + void requestPosts(int accountId, const QString &accessToken); + +private Q_SLOTS: + void finishedPostsHandler(); + +private: + MastodonPostsDatabase m_db; + SocialImagesDatabase m_imageCacheDb; +}; + +#endif // MASTODONPOSTSSYNCADAPTOR_H |
