From 4351f4627ba9e71775438dd26c9acddd002c7e11 Mon Sep 17 00:00:00 2001 From: Andrew Branson Date: Tue, 10 Feb 2026 10:41:02 +0100 Subject: Initial commit --- .gitignore | 8 + README.md | 71 ++++ buteo-plugins/buteo-common/buteo-common.pri | 9 + buteo-plugins/buteo-common/buteo-common.pro | 29 ++ buteo-plugins/buteo-common/buteosyncfw_p.h | 39 ++ buteo-plugins/buteo-common/socialdbuteoplugin.cpp | 338 +++++++++++++++ buteo-plugins/buteo-common/socialdbuteoplugin.h | 75 ++++ .../buteo-common/socialdnetworkaccessmanager_p.cpp | 37 ++ .../buteo-common/socialdnetworkaccessmanager_p.h | 40 ++ .../buteo-common/socialnetworksyncadaptor.cpp | 470 +++++++++++++++++++++ .../buteo-common/socialnetworksyncadaptor.h | 155 +++++++ buteo-plugins/buteo-common/trace.cpp | 25 ++ buteo-plugins/buteo-common/trace.h | 30 ++ buteo-plugins/buteo-plugins.pro | 6 + .../buteo-sync-plugin-mastodon-posts.pro | 37 ++ .../mastodon-posts.xml | 4 + .../mastodon.Posts.xml | 15 + .../mastodondatatypesyncadaptor.cpp | 313 ++++++++++++++ .../mastodondatatypesyncadaptor.h | 70 +++ .../mastodonpostsplugin.cpp | 49 +++ .../mastodonpostsplugin.h | 54 +++ .../mastodonpostssyncadaptor.cpp | 316 ++++++++++++++ .../mastodonpostssyncadaptor.h | 61 +++ common/common.pri | 4 + common/common.pro | 21 + common/mastodonpostsdatabase.cpp | 87 ++++ common/mastodonpostsdatabase.h | 46 ++ .../MastodonFeedItem.qml | 143 +++++++ .../abstractsocialcachemodel.cpp | 190 +++++++++ .../abstractsocialcachemodel.h | 72 ++++ .../abstractsocialcachemodel_p.h | 53 +++ .../eventsview-plugin-mastodon.pro | 61 +++ .../lipstick-jolla-home-mastodon.ts | 24 ++ .../lipstick-jolla-home-mastodon_eng_en.qm | Bin 0 -> 299 bytes .../mastodon-delegate.qml | 167 ++++++++ .../mastodonpostsmodel.cpp | 132 ++++++ .../mastodonpostsmodel.h | 67 +++ .../eventsview-plugin-mastodon/plugin.cpp | 19 + .../eventsview-plugin-mastodon/postimagehelper_p.h | 45 ++ .../eventsview-plugin-mastodon/qmldir | 2 + .../synchronizelists_p.h | 224 ++++++++++ eventsview-plugins/eventsview-plugins.pro | 2 + icons/icons.pro | 5 + icons/svgs/icons/icon-l-mastodon.svg | 10 + rpm/sailfish-account-mastodon.spec | 133 ++++++ sailfish-account-mastodon.pro | 14 + settings/accounts/accounts.pro | 27 ++ settings/accounts/providers/mastodon.provider | 32 ++ .../accounts/services/mastodon-microblog.service | 29 ++ .../accounts/services/mastodon-sharing.service | 28 ++ settings/accounts/ui/MastodonSettingsDisplay.qml | 92 ++++ settings/accounts/ui/mastodon-settings.qml | 66 +++ settings/accounts/ui/mastodon-update.qml | 99 +++++ settings/accounts/ui/mastodon.qml | 337 +++++++++++++++ settings/settings.pro | 2 + .../mastodonshareplugin/MastodonShareImage.qml | 10 + .../mastodonshareplugin/mastodonplugininfo.cpp | 52 +++ .../mastodonshareplugin/mastodonplugininfo.h | 29 ++ .../mastodonshareplugin/mastodonshareplugin.cpp | 23 + .../mastodonshareplugin/mastodonshareplugin.h | 22 + .../mastodonshareplugin/mastodonshareplugin.pro | 25 ++ .../mastodonshareservicestatus.cpp | 367 ++++++++++++++++ .../mastodonshareservicestatus.h | 75 ++++ .../mastodontransferplugin/mastodonapi.cpp | 244 +++++++++++ .../mastodontransferplugin/mastodonapi.h | 56 +++ .../mastodontransferplugin.cpp | 25 ++ .../mastodontransferplugin.h | 27 ++ .../mastodontransferplugin.pro | 24 ++ .../mastodontransferplugin/mastodonuploader.cpp | 200 +++++++++ .../mastodontransferplugin/mastodonuploader.h | 51 +++ transferengine-plugins/transferengine-plugins.pro | 2 + 71 files changed, 5716 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 buteo-plugins/buteo-common/buteo-common.pri create mode 100644 buteo-plugins/buteo-common/buteo-common.pro create mode 100644 buteo-plugins/buteo-common/buteosyncfw_p.h create mode 100644 buteo-plugins/buteo-common/socialdbuteoplugin.cpp create mode 100644 buteo-plugins/buteo-common/socialdbuteoplugin.h create mode 100644 buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.cpp create mode 100644 buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.h create mode 100644 buteo-plugins/buteo-common/socialnetworksyncadaptor.cpp create mode 100644 buteo-plugins/buteo-common/socialnetworksyncadaptor.h create mode 100644 buteo-plugins/buteo-common/trace.cpp create mode 100644 buteo-plugins/buteo-common/trace.h create mode 100644 buteo-plugins/buteo-plugins.pro create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h create mode 100644 common/common.pri create mode 100644 common/common.pro create mode 100644 common/mastodonpostsdatabase.cpp create mode 100644 common/mastodonpostsdatabase.h create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.cpp create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.h create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel_p.h create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon.ts create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon_eng_en.qm create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/postimagehelper_p.h create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/qmldir create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/synchronizelists_p.h create mode 100644 eventsview-plugins/eventsview-plugins.pro create mode 100644 icons/icons.pro create mode 100644 icons/svgs/icons/icon-l-mastodon.svg create mode 100644 rpm/sailfish-account-mastodon.spec create mode 100644 sailfish-account-mastodon.pro create mode 100644 settings/accounts/accounts.pro create mode 100644 settings/accounts/providers/mastodon.provider create mode 100644 settings/accounts/services/mastodon-microblog.service create mode 100644 settings/accounts/services/mastodon-sharing.service create mode 100644 settings/accounts/ui/MastodonSettingsDisplay.qml create mode 100644 settings/accounts/ui/mastodon-settings.qml create mode 100644 settings/accounts/ui/mastodon-update.qml create mode 100644 settings/accounts/ui/mastodon.qml create mode 100644 settings/settings.pro create mode 100644 transferengine-plugins/mastodonshareplugin/MastodonShareImage.qml create mode 100644 transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp create mode 100644 transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h create mode 100644 transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp create mode 100644 transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h create mode 100644 transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro create mode 100644 transferengine-plugins/mastodonshareservicestatus.cpp create mode 100644 transferengine-plugins/mastodonshareservicestatus.h create mode 100644 transferengine-plugins/mastodontransferplugin/mastodonapi.cpp create mode 100644 transferengine-plugins/mastodontransferplugin/mastodonapi.h create mode 100644 transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp create mode 100644 transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h create mode 100644 transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro create mode 100644 transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp create mode 100644 transferengine-plugins/mastodontransferplugin/mastodonuploader.h create mode 100644 transferengine-plugins/transferengine-plugins.pro diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f4965a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +documentation.list +Makefile +*.o +*.so +*.so.* +moc_*.cpp +*.moc +*.png \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ca09b7 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# sailfish-account-mastodon + +Sailfish OS account integration for Mastodon. + +## What This Repository Contains + +This repository is the initial public form of the Mastodon plugin and contains all of its account integration components. + +### `common/` +- Shared C++ library code used by multiple plugins. +- Includes Mastodon posts database support built on `socialcache`. + +### `settings/` +- Sailfish Accounts provider and services definitions. +- Account UI QML files for account creation/settings/credentials update. +- Uses OAuth2 (`web_server`) account flow with per-instance Mastodon app registration. +- Current services: + - `mastodon-microblog` (sync posts) + - `mastodon-sharing` (Transfer Engine sharing) + +### `buteo-plugins/` +- Social sync plugins for Buteo. +- Includes shared Buteo social plugin framework code and Mastodon posts sync plugin/profile files. +- Installs Buteo client and sync profile XML files. + +### `eventsview-plugins/` +- Events view extension QML/C++ plugin for Mastodon posts. +- Includes delegate/feed item QML and `MastodonPostsModel`. + +### `transferengine-plugins/` +- Transfer Engine integration for Mastodon image sharing. +- `mastodonshareplugin/`: sharing method discovery and share UI metadata. +- `mastodontransferplugin/`: upload implementation (upload media + create status). +- Shared account credential/status helper: `mastodonshareservicestatus.*`. + +### `icons/` +- Mastodon SVG icon assets and Sailfish icon conversion setup (`sailfish-svg2png`). +- Provides themed service icons used by provider/settings/transfer UI. + +### `rpm/` +- RPM spec for packaging all components. +- Defines subpackages: + - `sailfish-account-mastodon` + - `buteo-sync-plugin-mastodon-posts` + - `eventsview-extensions-mastodon` + - `transferengine-plugin-mastodon` + - `sailfish-account-mastodon-features-all` + +### Root project file +- `sailfish-account-mastodon.pro` ties all subprojects together. + +## Build Requirements + +This project is designed for the Sailfish OS build environment. + +A full build is not possible without Sailfish SDK access (including target sysroot/tooling and Sailfish-specific development packages). + +In particular, dependencies like these must come from the Sailfish SDK target environment: +- `buteosyncfw5` +- `socialcache` +- `sailfishaccounts` +- `nemotransferengine-qt5` +- other Sailfish/Qt account stack packages listed in `rpm/sailfish-account-mastodon.spec` + +## Typical Build Flow (Inside Sailfish SDK) + +1. Enter Sailfish SDK shell/target. +2. Run qmake build from repository root. +3. Build RPM package(s) from `rpm/sailfish-account-mastodon.spec`. + +If you are outside Sailfish SDK, use static checks and code review only. 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 + ** + ** 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 +#include +#include +#include +#include +#include +#include +#include +#include + +#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 + ** + ** 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 +#include + +#include +#include +#include + +#include "buteosyncfw_p.h" + +#include +#include +#include + +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 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 SocialdButeoPlugin::ensurePerAccountSyncProfilesExist() +{ + Accounts::Manager am; + Accounts::AccountIdList accountIds = am.accountList(); + QList syncProfiles = m_profileManager.allSyncProfiles(); + QMap 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(); + } + + 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 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 + ** + ** 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 +#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 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 + ** + ** 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 + ** + ** 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 + +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 + ** + ** 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 +#include +#include +#include +#include +#include + +#include +#include + +#include "buteosyncfw_p.h" + +// libaccounts-qt5 +#include +#include +#include + +// libsocialcache +#include +#include + +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 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 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(sender()); + QNetworkReply *reply = timer->property("networkReply").value(); + 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(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(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 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 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 + ** + ** 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 +#include +#include +#include +#include +#include +#include + +#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 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 m_accountSyncSemaphores; + QMap > 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 + ** + ** 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 + +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 @@ + + + + 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 @@ + + + + + + + + + + + + + + + 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 +#include +#include + +// libaccounts-qt5 +#include +#include +#include +#include + +// libsignon-qt5 +#include +#include +#include + +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(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(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 &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(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(true)); + account->setValue(QStringLiteral("CredentialsNeedUpdateFrom"), QVariant::fromValue(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(account)); + session->setProperty("identity", QVariant::fromValue(identity)); + session->process(SignOn::SessionData(signonSessionData), mechanism); +} + +void MastodonDataTypeSyncAdaptor::signOnError(const SignOn::Error &error) +{ + SignOn::AuthSession *session = qobject_cast(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + 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(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + 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 +#include +#include + +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 &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 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 + +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 +#include +#include +#include +#include +#include +#include + +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)), this, SLOT(sslErrorsHandler(QList))); + 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(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 > 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 +#include + +#include "mastodonpostsdatabase.h" +#include + +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 diff --git a/common/common.pri b/common/common.pri new file mode 100644 index 0000000..e1d299f --- /dev/null +++ b/common/common.pri @@ -0,0 +1,4 @@ +INCLUDEPATH += $$PWD +DEPENDPATH += . + +LIBS += -L$$PWD -lmastodoncommon diff --git a/common/common.pro b/common/common.pro new file mode 100644 index 0000000..eec5b5f --- /dev/null +++ b/common/common.pro @@ -0,0 +1,21 @@ +TEMPLATE = lib + +QT -= gui +QT += sql + +CONFIG += link_pkgconfig +PKGCONFIG += socialcache + +TARGET = mastodoncommon +TARGET = $$qtLibraryTarget($$TARGET) + +HEADERS += \ + $$PWD/mastodonpostsdatabase.h + +SOURCES += \ + $$PWD/mastodonpostsdatabase.cpp + +TARGETPATH = $$[QT_INSTALL_LIBS] +target.path = $$TARGETPATH + +INSTALLS += target diff --git a/common/mastodonpostsdatabase.cpp b/common/mastodonpostsdatabase.cpp new file mode 100644 index 0000000..fa6ddd3 --- /dev/null +++ b/common/mastodonpostsdatabase.cpp @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 Open Mobile Platform LLC. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "mastodonpostsdatabase.h" + +static const char *DB_NAME = "mastodon.db"; +static const char *ACCOUNT_NAME_KEY = "account_name"; +static const char *URL_KEY = "url"; +static const char *BOOSTED_BY_KEY = "boosted_by"; +static const char *INSTANCE_URL_KEY = "instance_url"; + +MastodonPostsDatabase::MastodonPostsDatabase() + : AbstractSocialPostCacheDatabase(QStringLiteral("mastodon"), QLatin1String(DB_NAME)) +{ +} + +MastodonPostsDatabase::~MastodonPostsDatabase() +{ +} + +void MastodonPostsDatabase::addMastodonPost( + const QString &identifier, + const QString &name, + const QString &accountName, + const QString &body, + const QDateTime ×tamp, + const QString &icon, + const QList > &images, + const QString &url, + const QString &boostedBy, + const QString &instanceUrl, + int account) +{ + QVariantMap extra; + extra.insert(ACCOUNT_NAME_KEY, accountName); + extra.insert(URL_KEY, url); + extra.insert(BOOSTED_BY_KEY, boostedBy); + extra.insert(INSTANCE_URL_KEY, instanceUrl); + addPost(identifier, name, body, timestamp, icon, images, extra, account); +} + +QString MastodonPostsDatabase::accountName(const SocialPost::ConstPtr &post) +{ + if (post.isNull()) { + return QString(); + } + return post->extra().value(ACCOUNT_NAME_KEY).toString(); +} + +QString MastodonPostsDatabase::url(const SocialPost::ConstPtr &post) +{ + if (post.isNull()) { + return QString(); + } + return post->extra().value(URL_KEY).toString(); +} + +QString MastodonPostsDatabase::boostedBy(const SocialPost::ConstPtr &post) +{ + if (post.isNull()) { + return QString(); + } + return post->extra().value(BOOSTED_BY_KEY).toString(); +} + +QString MastodonPostsDatabase::instanceUrl(const SocialPost::ConstPtr &post) +{ + if (post.isNull()) { + return QString(); + } + return post->extra().value(INSTANCE_URL_KEY).toString(); +} diff --git a/common/mastodonpostsdatabase.h b/common/mastodonpostsdatabase.h new file mode 100644 index 0000000..d5fdea7 --- /dev/null +++ b/common/mastodonpostsdatabase.h @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 Open Mobile Platform LLC. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef MASTODONPOSTSDATABASE_H +#define MASTODONPOSTSDATABASE_H + +#include + +class MastodonPostsDatabase: public AbstractSocialPostCacheDatabase +{ + Q_OBJECT +public: + MastodonPostsDatabase(); + ~MastodonPostsDatabase(); + + void addMastodonPost(const QString &identifier, const QString &name, + const QString &accountName, const QString &body, + const QDateTime ×tamp, + const QString &icon, + const QList > &images, + const QString &url, const QString &boostedBy, + const QString &instanceUrl, + int account); + + static QString accountName(const SocialPost::ConstPtr &post); + static QString url(const SocialPost::ConstPtr &post); + static QString boostedBy(const SocialPost::ConstPtr &post); + static QString instanceUrl(const SocialPost::ConstPtr &post); +}; + +#endif // MASTODONPOSTSDATABASE_H diff --git a/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml b/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml new file mode 100644 index 0000000..231b814 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml @@ -0,0 +1,143 @@ +/**************************************************************************** + ** + ** Copyright (C) 2026 Open Mobile Platform LLC. + ** + ****************************************************************************/ + +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.TextLinking 1.0 +import "shared" + +SocialMediaFeedItem { + id: item + + property variant imageList + property int likeCount + property int commentCount + property int boostCount + + property string _booster: item.stringValue("boostedBy", "rebloggedBy", "retweeter") + property string _displayName: item.stringValue("name", "displayName", "display_name") + property string _accountName: item.stringValue("accountName", "acct", "screenName", "username") + property string _bodyText: item.stringValue("body", "content", "text") + + timestamp: item.stringValue("timestamp", "createdAt", "created_at") + + avatar.y: item._booster.length > 0 + ? topMargin + boosterIcon.height + Theme.paddingSmall + : topMargin + contentHeight: Math.max(content.y + content.height, avatar.y + avatar.height) + bottomMargin + topMargin: item._booster.length > 0 ? Theme.paddingMedium : Theme.paddingLarge + userRemovable: false + + Image { + id: boosterIcon + + anchors { + right: avatar.right + top: parent.top + topMargin: item.topMargin + } + visible: item._booster.length > 0 + source: "image://theme/icon-s-repost" + (item.highlighted ? "?" + Theme.highlightColor : "") + } + + Text { + anchors { + left: content.left + right: content.right + verticalCenter: boosterIcon.verticalCenter + } + elide: Text.ElideRight + font.pixelSize: Theme.fontSizeExtraSmall + color: item.highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor + textFormat: Text.PlainText + visible: text.length > 0 + + text: item._booster.length > 0 + ? //: Shown above a post that is boosted by another user. %1 = name of user who boosted + //% "%1 boosted" + qsTrId("lipstick-jolla-home-la-boosted_by").arg(item._booster) + : "" + } + + Column { + id: content + + anchors { + left: avatar.right + leftMargin: Theme.paddingMedium + top: avatar.top + } + width: parent.width - x + + Label { + width: parent.width + truncationMode: TruncationMode.Fade + text: item._displayName + color: item.highlighted ? Theme.highlightColor : Theme.primaryColor + textFormat: Text.PlainText + } + + Label { + width: parent.width + truncationMode: TruncationMode.Fade + text: item._accountName.length > 0 && item._accountName.charAt(0) !== "@" + ? "@" + item._accountName + : item._accountName + font.pixelSize: Theme.fontSizeSmall + color: item.highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor + textFormat: Text.PlainText + } + + LinkedText { + width: parent.width + elide: Text.ElideRight + wrapMode: Text.Wrap + font.pixelSize: Theme.fontSizeSmall + shortenUrl: true + color: item.highlighted ? Theme.highlightColor : Theme.primaryColor + linkColor: Theme.highlightColor + plainText: item._bodyText + } + + Text { + width: parent.width + height: previewRow.visible ? implicitHeight + Theme.paddingMedium : implicitHeight // add padding below + maximumLineCount: 1 + elide: Text.ElideRight + wrapMode: Text.Wrap + color: item.highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + text: item.formattedTime + textFormat: Text.PlainText + } + + SocialMediaPreviewRow { + id: previewRow + + width: parent.width + Theme.horizontalPageMargin // extend to right edge of notification area + imageList: item.imageList + downloader: item.downloader + accountId: item.accountId + connectedToNetwork: item.connectedToNetwork + highlighted: item.highlighted + eventsColumnMaxWidth: item.eventsColumnMaxWidth - item.avatar.width + } + } + + function stringValue() { + for (var i = 0; i < arguments.length; ++i) { + var value = model[arguments[i]] + if (typeof value === "undefined" || value === null) { + continue + } + value = String(value) + if (value.length > 0) { + return value + } + } + return "" + } +} diff --git a/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.cpp b/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.cpp new file mode 100644 index 0000000..6d33d48 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.cpp @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2013 Jolla Ltd. + * Contact: Lucien Xu + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "abstractsocialcachemodel.h" +#include "abstractsocialcachemodel_p.h" + +#include + +#include +#include + +template <> bool compareIdentity( + const SocialCacheModelRow &item, const SocialCacheModelRow &reference) +{ + return item.value(0) == reference.value(0); +} + +template <> +int updateRange( + AbstractSocialCacheModelPrivate *d, + int index, + int count, + const SocialCacheModelData &source, + int sourceIndex) +{ + d->updateRange(index, count, source, sourceIndex); + + return count; +} + +AbstractSocialCacheModelPrivate::AbstractSocialCacheModelPrivate(AbstractSocialCacheModel *q) + : q_ptr(q) +{ +} + +AbstractSocialCacheModelPrivate::~AbstractSocialCacheModelPrivate() +{ +} + +void AbstractSocialCacheModelPrivate::clearData() +{ + Q_Q(AbstractSocialCacheModel); + if (m_data.count() > 0) { + q->beginRemoveRows(QModelIndex(), 0, m_data.count() - 1); + m_data.clear(); + q->endRemoveRows(); + emit q->countChanged(); + } +} + +void AbstractSocialCacheModelPrivate::updateData(const SocialCacheModelData &data) +{ + Q_Q(AbstractSocialCacheModel); + q->updateData(data); +} + +void AbstractSocialCacheModelPrivate::updateRow(int row, const SocialCacheModelRow &data) +{ + Q_Q(AbstractSocialCacheModel); + q->updateRow(row, data); +} + +void AbstractSocialCacheModelPrivate::insertRange( + int index, int count, const SocialCacheModelData &source, int sourceIndex) +{ + Q_Q(AbstractSocialCacheModel); + + if (count > 0 && index >= 0) { + q->beginInsertRows(QModelIndex(), index, index + count - 1); + m_data = m_data.mid(0, index) + source.mid(sourceIndex, count) + m_data.mid(index); + q->endInsertRows(); + emit q->countChanged(); + } +} + +void AbstractSocialCacheModelPrivate::removeRange(int index, int count) +{ + Q_Q(AbstractSocialCacheModel); + + if (count > 0 && index >= 0) { + q->beginRemoveRows(QModelIndex(), index, index + count - 1); + m_data = m_data.mid(0, index) + m_data.mid(index + count); + q->endRemoveRows(); + emit q->countChanged(); + } +} + +void AbstractSocialCacheModelPrivate::updateRange( + int index, int count, const SocialCacheModelData &source, int sourceIndex) +{ + Q_Q(AbstractSocialCacheModel); + + for (int i = 0; i < count; ++i) { + m_data[index + i] = source[sourceIndex + i]; + } + + emit q->dataChanged(q->createIndex(index, 0), q->createIndex(index + count - 1, 0)); +} + +AbstractSocialCacheModel::AbstractSocialCacheModel(AbstractSocialCacheModelPrivate &dd, + QObject *parent) + : QAbstractListModel(parent), d_ptr(&dd) +{ +} + +AbstractSocialCacheModel::~AbstractSocialCacheModel() +{ +} + +int AbstractSocialCacheModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + Q_D(const AbstractSocialCacheModel); + return d->m_data.count(); +} + +QVariant AbstractSocialCacheModel::data(const QModelIndex &index, int role) const +{ + int row = index.row(); + return getField(row, role); +} + +QVariant AbstractSocialCacheModel::getField(int row, int role) const +{ + Q_D(const AbstractSocialCacheModel); + if (row < 0 || row >= d->m_data.count()) { + return QVariant(); + } + + return d->m_data.at(row).value(role); +} + +QString AbstractSocialCacheModel::nodeIdentifier() const +{ + Q_D(const AbstractSocialCacheModel); + return d->nodeIdentifier; +} + +void AbstractSocialCacheModel::setNodeIdentifier(const QString &nodeIdentifier) +{ + Q_D(AbstractSocialCacheModel); + if (d->nodeIdentifier != nodeIdentifier) { + d->nodeIdentifier = nodeIdentifier; + emit nodeIdentifierChanged(); + d->nodeIdentifierChanged(); + } +} + +int AbstractSocialCacheModel::count() const +{ + return rowCount(); +} + +void AbstractSocialCacheModel::updateData(const SocialCacheModelData &data) +{ + Q_D(AbstractSocialCacheModel); + + const int count = d->m_data.count(); + synchronizeList(d, d->m_data, data); + + if (d->m_data.count() != count) { + emit countChanged(); + } + emit modelUpdated(); +} + +void AbstractSocialCacheModel::updateRow(int row, const SocialCacheModelRow &data) +{ + Q_D(AbstractSocialCacheModel); + foreach (int key, data.keys()) { + d->m_data[row].insert(key, data.value(key)); + } + emit dataChanged(index(row), index(row)); +} diff --git a/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.h b/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.h new file mode 100644 index 0000000..1e6394f --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.h @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2013 Jolla Ltd. + * Contact: Lucien Xu + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef ABSTRACTSOCIALCACHEMODEL_H +#define ABSTRACTSOCIALCACHEMODEL_H + +#include + +typedef QMap SocialCacheModelRow; +typedef QList SocialCacheModelData; + +class AbstractSocialCacheModelPrivate; + +class AbstractSocialCacheModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(QString nodeIdentifier READ nodeIdentifier WRITE setNodeIdentifier NOTIFY nodeIdentifierChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + virtual ~AbstractSocialCacheModel(); + + int rowCount(const QModelIndex &parent = QModelIndex()) const; + QVariant data(const QModelIndex &index, int role) const; + Q_INVOKABLE QVariant getField(int row, int role) const; + + // properties + QString nodeIdentifier() const; + void setNodeIdentifier(const QString &nodeIdentifier); + int count() const; + + +public Q_SLOTS: + virtual void refresh() = 0; + +Q_SIGNALS: + void nodeIdentifierChanged(); + void countChanged(); + void modelUpdated(); + +protected: + // Methods used to update the model in the C++ side + void updateData(const SocialCacheModelData &data); + void updateRow(int row, const SocialCacheModelRow &data); + + explicit AbstractSocialCacheModel(AbstractSocialCacheModelPrivate &dd, QObject *parent = 0); + QScopedPointer d_ptr; + +private: + Q_DECLARE_PRIVATE(AbstractSocialCacheModel) +}; + +Q_DECLARE_METATYPE(SocialCacheModelRow) +Q_DECLARE_METATYPE(SocialCacheModelData) + +#endif // ABSTRACTSOCIALCACHEMODEL_H diff --git a/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel_p.h b/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel_p.h new file mode 100644 index 0000000..6c92655 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel_p.h @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2013 Jolla Ltd. + * Contact: Lucien Xu + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef ABSTRACTSOCIALCACHEMODEL_P_H +#define ABSTRACTSOCIALCACHEMODEL_P_H + +#include "abstractsocialcachemodel.h" + +#include + +class AbstractSocialCacheModelPrivate +{ +public: + virtual ~AbstractSocialCacheModelPrivate(); + QString nodeIdentifier; + + void insertRange(int index, int count, const SocialCacheModelData &source, int sourceIndex); + void updateRange(int index, int count, const SocialCacheModelData &source, int sourceIndex); + void removeRange(int index, int count); + + void clearData(); + void updateData(const SocialCacheModelData &data); + void updateRow(int row, const SocialCacheModelRow &data); + + QList > m_data; + +protected: + explicit AbstractSocialCacheModelPrivate(AbstractSocialCacheModel *q); + + virtual void nodeIdentifierChanged() {} + + AbstractSocialCacheModel * const q_ptr; +private: + Q_DECLARE_PUBLIC(AbstractSocialCacheModel) +}; + +#endif // ABSTRACTSOCIALCACHEMODEL_P_H diff --git a/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro b/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro new file mode 100644 index 0000000..229f38a --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro @@ -0,0 +1,61 @@ +TEMPLATE = lib +TARGET = jollaeventsviewmastodonplugin +TARGET = $$qtLibraryTarget($$TARGET) + +MODULENAME = com/jolla/eventsview/mastodon +TARGETPATH = $$[QT_INSTALL_QML]/$$MODULENAME + +QT += qml +CONFIG += plugin link_pkgconfig +PKGCONFIG += socialcache + +include($$PWD/../../common/common.pri) + +TS_FILE = $$OUT_PWD/lipstick-jolla-home-mastodon.ts +EE_QM = $$OUT_PWD/lipstick-jolla-home-mastodon_eng_en.qm + +ts.commands += lupdate $$PWD -ts $$TS_FILE +ts.CONFIG += no_check_exist no_link +ts.output = $$TS_FILE +ts.input = . + +ts_install.files = $$TS_FILE +ts_install.path = /usr/share/translations/source +ts_install.CONFIG += no_check_exist + +# should add -markuntranslated "-" when proper translations are in place (or for testing) +engineering_english.commands += lrelease -idbased $$TS_FILE -qm $$EE_QM +engineering_english.CONFIG += no_check_exist no_link +engineering_english.depends = ts +engineering_english.input = $$TS_FILE +engineering_english.output = $$EE_QM + +engineering_english_install.path = /usr/share/translations +engineering_english_install.files = $$EE_QM +engineering_english_install.CONFIG += no_check_exist + +QMAKE_EXTRA_TARGETS += ts engineering_english +PRE_TARGETDEPS += ts engineering_english + +INSTALLS += ts_install engineering_english_install + +HEADERS += \ + abstractsocialcachemodel.h \ + abstractsocialcachemodel_p.h \ + mastodonpostsmodel.h + +SOURCES += \ + abstractsocialcachemodel.cpp \ + mastodonpostsmodel.cpp \ + plugin.cpp + +qml.files = mastodon-delegate.qml MastodonFeedItem.qml +qml.path = /usr/share/lipstick/eventfeed/ + +import.files = qmldir +import.path = $$TARGETPATH +target.path = $$TARGETPATH + +OTHER_FILES += $$qml.files $$import.files + +INSTALLS += target import qml diff --git a/eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon.ts b/eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon.ts new file mode 100644 index 0000000..60f39fa --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon.ts @@ -0,0 +1,24 @@ + + + + + + + + Posts + Mastodon posts + + + + + Show more in Mastodon + + + + + %1 boosted + Shown above a post that is boosted by another user. %1 = name of user who boosted + + + + diff --git a/eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon_eng_en.qm b/eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon_eng_en.qm new file mode 100644 index 0000000..30da83b Binary files /dev/null and b/eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon_eng_en.qm differ diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml b/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml new file mode 100644 index 0000000..ed79fdb --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml @@ -0,0 +1,167 @@ +/**************************************************************************** + ** + ** Copyright (C) 2026 Open Mobile Platform LLC. + ** + ****************************************************************************/ + +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import org.nemomobile.socialcache 1.0 +import com.jolla.eventsview.mastodon 1.0 +import QtQml.Models 2.1 +import "shared" + +SocialMediaAccountDelegate { + id: delegateItem + + //: Mastodon posts + //% "Posts" + headerText: qsTrId("lipstick-jolla-home-la-mastodon_posts") + headerIcon: "image://theme/graphic-service-mastodon" + showRemainingCount: false + + services: ["Posts", "Notifications"] + socialNetwork: 9 + dataType: SocialSync.Posts + providerName: "mastodon" + + model: MastodonPostsModel { + onCountChanged: { + if (count > 0) { + if (!updateTimer.running) { + shortUpdateTimer.start() + } + } else { + shortUpdateTimer.stop() + } + } + } + + delegate: MastodonFeedItem { + downloader: delegateItem.downloader + imageList: delegateItem.variantRole(model, ["images", "mediaAttachments", "media"]) + avatarSource: delegateItem.convertUrl(delegateItem.stringRole(model, ["icon", "avatar", "avatarUrl"])) + fallbackAvatarSource: delegateItem.stringRole(model, ["icon", "avatar", "avatarUrl"]) + accountId: model.accounts[0] + + onTriggered: Qt.openUrlExternally(delegateItem.statusUrl(model)) + + Component.onCompleted: { + refreshTimeCount = Qt.binding(function() { return delegateItem.refreshTimeCount }) + connectedToNetwork = Qt.binding(function() { return delegateItem.connectedToNetwork }) + eventsColumnMaxWidth = Qt.binding(function() { return delegateItem.eventsColumnMaxWidth }) + } + } + //% "Show more in Mastodon" + expandedLabel: qsTrId("lipstick-jolla-home-la-show-more-in-mastodon") + + onHeaderClicked: Qt.openUrlExternally("https://mastodon.social/explore") + onExpandedClicked: Qt.openUrlExternally("https://mastodon.social/explore") + + onViewVisibleChanged: { + if (viewVisible) { + delegateItem.resetHasSyncableAccounts() + delegateItem.model.refresh() + if (delegateItem.hasSyncableAccounts && !updateTimer.running) { + shortUpdateTimer.start() + } + } else { + shortUpdateTimer.stop() + } + } + + onConnectedToNetworkChanged: { + if (viewVisible) { + if (!updateTimer.running) { + shortUpdateTimer.start() + } + } + } + + // The Mastodon feed is updated 3 seconds after the feed view becomes visible, + // unless it has been updated during last 60 seconds. After that it will be updated + // periodically in every 60 seconds as long as the feed view is visible. + + Timer { + id: shortUpdateTimer + + interval: 3000 + onTriggered: { + delegateItem.sync() + updateTimer.start() + } + } + + Timer { + id: updateTimer + + interval: 60000 + repeat: true + onTriggered: { + if (delegateItem.viewVisible) { + delegateItem.sync() + } else { + stop() + } + } + } + + function variantRole(modelData, roleNames) { + for (var i = 0; i < roleNames.length; ++i) { + var value = modelData[roleNames[i]] + if (typeof value !== "undefined" && value !== null) { + return value + } + } + return undefined + } + + function stringRole(modelData, roleNames) { + for (var i = 0; i < roleNames.length; ++i) { + var value = modelData[roleNames[i]] + if (typeof value === "undefined" || value === null) { + continue + } + value = String(value) + if (value.length > 0) { + return value + } + } + return "" + } + + function statusUrl(modelData) { + var directUrl = stringRole(modelData, ["url", "link", "uri"]) + if (directUrl.length > 0) { + return directUrl + } + + var instanceUrl = stringRole(modelData, ["instanceUrl", "serverUrl", "baseUrl"]) + if (instanceUrl.length === 0) { + instanceUrl = "https://mastodon.social" + } + while (instanceUrl.length > 0 && instanceUrl.charAt(instanceUrl.length - 1) === "/") { + instanceUrl = instanceUrl.slice(0, instanceUrl.length - 1) + } + + var accountName = stringRole(modelData, ["accountName", "acct", "screenName", "username"]) + var statusId = stringRole(modelData, ["mastodonId", "statusId", "id", "twitterId"]) + if (accountName.length > 0 && statusId.length > 0) { + while (accountName.length > 0 && accountName.charAt(0) === "@") { + accountName = accountName.substring(1) + } + return instanceUrl + "/@" + accountName + "/" + statusId + } + + return instanceUrl + "/explore" + } + + function convertUrl(source) { + if (source.indexOf("_normal.") !== -1) { + return source.replace("_normal.", "_bigger.") + } else if (source.indexOf("_mini.") !== -1) { + return source.replace("_mini.", "_bigger.") + } + return source + } +} diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp new file mode 100644 index 0000000..3e54b8b --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2026 Open Mobile Platform LLC. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "mastodonpostsmodel.h" +#include "abstractsocialcachemodel_p.h" +#include "mastodonpostsdatabase.h" +#include "postimagehelper_p.h" + +class MastodonPostsModelPrivate: public AbstractSocialCacheModelPrivate +{ +public: + explicit MastodonPostsModelPrivate(MastodonPostsModel *q); + + MastodonPostsDatabase database; + +private: + Q_DECLARE_PUBLIC(MastodonPostsModel) +}; + +MastodonPostsModelPrivate::MastodonPostsModelPrivate(MastodonPostsModel *q) + : AbstractSocialCacheModelPrivate(q) +{ +} + +MastodonPostsModel::MastodonPostsModel(QObject *parent) + : AbstractSocialCacheModel(*(new MastodonPostsModelPrivate(this)), parent) +{ + Q_D(MastodonPostsModel); + + connect(&d->database, &AbstractSocialPostCacheDatabase::postsChanged, + this, &MastodonPostsModel::postsChanged); + connect(&d->database, SIGNAL(accountIdFilterChanged()), + this, SIGNAL(accountIdFilterChanged())); +} + +QHash MastodonPostsModel::roleNames() const +{ + QHash roleNames; + roleNames.insert(MastodonId, "mastodonId"); + roleNames.insert(Name, "name"); + roleNames.insert(AccountName, "accountName"); + roleNames.insert(Acct, "acct"); + roleNames.insert(Body, "body"); + roleNames.insert(Timestamp, "timestamp"); + roleNames.insert(Icon, "icon"); + roleNames.insert(Images, "images"); + roleNames.insert(Url, "url"); + roleNames.insert(Link, "link"); + roleNames.insert(BoostedBy, "boostedBy"); + roleNames.insert(RebloggedBy, "rebloggedBy"); + roleNames.insert(InstanceUrl, "instanceUrl"); + roleNames.insert(Accounts, "accounts"); + return roleNames; +} + +QVariantList MastodonPostsModel::accountIdFilter() const +{ + Q_D(const MastodonPostsModel); + + return d->database.accountIdFilter(); +} + +void MastodonPostsModel::setAccountIdFilter(const QVariantList &accountIds) +{ + Q_D(MastodonPostsModel); + + d->database.setAccountIdFilter(accountIds); +} + +void MastodonPostsModel::refresh() +{ + Q_D(MastodonPostsModel); + + d->database.refresh(); +} + +void MastodonPostsModel::postsChanged() +{ + Q_D(MastodonPostsModel); + + SocialCacheModelData data; + QList postsData = d->database.posts(); + Q_FOREACH (const SocialPost::ConstPtr &post, postsData) { + QMap eventMap; + const QString accountName = d->database.accountName(post); + const QString postUrl = d->database.url(post); + const QString boostedBy = d->database.boostedBy(post); + + eventMap.insert(MastodonPostsModel::MastodonId, post->identifier()); + eventMap.insert(MastodonPostsModel::Name, post->name()); + eventMap.insert(MastodonPostsModel::AccountName, accountName); + eventMap.insert(MastodonPostsModel::Acct, accountName); + eventMap.insert(MastodonPostsModel::Body, post->body()); + eventMap.insert(MastodonPostsModel::Timestamp, post->timestamp()); + eventMap.insert(MastodonPostsModel::Icon, post->icon()); + eventMap.insert(MastodonPostsModel::Url, postUrl); + eventMap.insert(MastodonPostsModel::Link, postUrl); + eventMap.insert(MastodonPostsModel::BoostedBy, boostedBy); + eventMap.insert(MastodonPostsModel::RebloggedBy, boostedBy); + eventMap.insert(MastodonPostsModel::InstanceUrl, d->database.instanceUrl(post)); + + QVariantList images; + Q_FOREACH (const SocialPostImage::ConstPtr &image, post->images()) { + images.append(createImageData(image)); + } + eventMap.insert(MastodonPostsModel::Images, images); + + QVariantList accountsVariant; + Q_FOREACH (int account, post->accounts()) { + accountsVariant.append(account); + } + eventMap.insert(MastodonPostsModel::Accounts, accountsVariant); + data.append(eventMap); + } + + updateData(data); +} diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h new file mode 100644 index 0000000..9692729 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Open Mobile Platform LLC. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef MASTODONPOSTSMODEL_H +#define MASTODONPOSTSMODEL_H + +#include "abstractsocialcachemodel.h" + +class MastodonPostsModelPrivate; + +class MastodonPostsModel: public AbstractSocialCacheModel +{ + Q_OBJECT + Q_PROPERTY(QVariantList accountIdFilter READ accountIdFilter WRITE setAccountIdFilter NOTIFY accountIdFilterChanged) + +public: + enum MastodonPostsRole { + MastodonId = 0, + Name, + AccountName, + Acct, + Body, + Timestamp, + Icon, + Images, + Url, + Link, + BoostedBy, + RebloggedBy, + InstanceUrl, + Accounts + }; + + explicit MastodonPostsModel(QObject *parent = 0); + QHash roleNames() const; + + QVariantList accountIdFilter() const; + void setAccountIdFilter(const QVariantList &accountIds); + + void refresh(); + +signals: + void accountIdFilterChanged(); + +private slots: + void postsChanged(); + +private: + Q_DECLARE_PRIVATE(MastodonPostsModel) +}; + +#endif // MASTODONPOSTSMODEL_H diff --git a/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp b/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp new file mode 100644 index 0000000..35d95ca --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp @@ -0,0 +1,19 @@ +#include +#include + +#include "mastodonpostsmodel.h" + +class JollaEventsviewMastodonPlugin : public QQmlExtensionPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "com.jolla.eventsview.mastodon") + +public: + void registerTypes(const char *uri) override + { + Q_ASSERT(QLatin1String(uri) == QLatin1String("com.jolla.eventsview.mastodon")); + qmlRegisterType(uri, 1, 0, "MastodonPostsModel"); + } +}; + +#include "plugin.moc" diff --git a/eventsview-plugins/eventsview-plugin-mastodon/postimagehelper_p.h b/eventsview-plugins/eventsview-plugin-mastodon/postimagehelper_p.h new file mode 100644 index 0000000..fe61212 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/postimagehelper_p.h @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2013 Jolla Ltd. + * Contact: Lucien Xu + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef POSTIMAGEHELPER_P_H +#define POSTIMAGEHELPER_P_H + +#include + +static const char *URL_KEY = "url"; +static const char *TYPE_KEY = "type"; +static const char *TYPE_PHOTO = "photo"; +static const char *TYPE_VIDEO = "video"; + +inline static QVariantMap createImageData(const SocialPostImage::ConstPtr &image) +{ + QVariantMap imageData; + imageData.insert(QLatin1String(URL_KEY), image->url()); + switch (image->type()) { + case SocialPostImage::Video: + imageData.insert(QLatin1String(TYPE_KEY), QLatin1String(TYPE_VIDEO)); + break; + default: + imageData.insert(QLatin1String(TYPE_KEY), QLatin1String(TYPE_PHOTO)); + break; + } + return imageData; +} + +#endif // POSTIMAGEHELPER_P_H diff --git a/eventsview-plugins/eventsview-plugin-mastodon/qmldir b/eventsview-plugins/eventsview-plugin-mastodon/qmldir new file mode 100644 index 0000000..515a0a0 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/qmldir @@ -0,0 +1,2 @@ +module com.jolla.eventsview.mastodon +plugin jollaeventsviewmastodonplugin diff --git a/eventsview-plugins/eventsview-plugin-mastodon/synchronizelists_p.h b/eventsview-plugins/eventsview-plugin-mastodon/synchronizelists_p.h new file mode 100644 index 0000000..1e09e86 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/synchronizelists_p.h @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2013 Jolla Mobile + * + * You may use this file under the terms of the BSD license as follows: + * + * "Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Nemo Mobile nor the names of its contributors + * may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." + */ + +#ifndef SYNCHRONIZELISTS_P_H +#define SYNCHRONIZELISTS_P_H + +template +bool compareIdentity(const T &item, const T &reference) +{ + return item == reference; +} + +template +int insertRange(Agent *agent, int index, int count, const ReferenceList &source, int sourceIndex) +{ + agent->insertRange(index, count, source, sourceIndex); + return count; +} + +template +int removeRange(Agent *agent, int index, int count) +{ + agent->removeRange(index, count); + return 0; +} + +template +int updateRange(Agent *agent, int index, int count, const ReferenceList &source, int sourceIndex) +{ + Q_UNUSED(agent); + Q_UNUSED(index); + Q_UNUSED(source); + Q_UNUSED(sourceIndex); + return count; +} + +template +class SynchronizeList +{ +public: + SynchronizeList( + Agent *agent, + const CacheList &cache, + int &c, + const ReferenceList &reference, + int &r) + : agent(agent), cache(cache), c(c), reference(reference), r(r) + { + int lastEqualC = c; + int lastEqualR = r; + for (; c < cache.count() && r < reference.count(); ++c, ++r) { + if (compareIdentity(cache.at(c), reference.at(r))) { + continue; + } + + if (c > lastEqualC) { + lastEqualC += updateRange(agent, lastEqualC, c - lastEqualC, reference, lastEqualR); + c = lastEqualC; + lastEqualR = r; + } + + bool match = false; + + // Iterate through both the reference and cache lists in parallel looking for first + // point of commonality, when that is found resolve the differences and continue + // looking. + int count = 1; + for (; !match && c + count < cache.count() && r + count < reference.count(); ++count) { + typename CacheList::const_reference cacheItem = cache.at(c + count); + typename ReferenceList::const_reference referenceItem = reference.at(r + count); + + for (int i = 0; i <= count; ++i) { + if (cacheMatch(i, count, referenceItem) || referenceMatch(i, count, cacheItem)) { + match = true; + break; + } + } + } + + // Continue scanning the reference list if the cache has been exhausted. + for (int re = r + count; !match && re < reference.count(); ++re) { + typename ReferenceList::const_reference referenceItem = reference.at(re); + for (int i = 0; i < count; ++i) { + if (cacheMatch(i, re - r, referenceItem)) { + match = true; + break; + } + } + } + + // Continue scanning the cache if the reference list has been exhausted. + for (int ce = c + count; !match && ce < cache.count(); ++ce) { + typename CacheList::const_reference cacheItem = cache.at(ce); + for (int i = 0; i < count; ++i) { + if (referenceMatch(i, ce - c, cacheItem)) { + match = true; + break; + } + } + } + + if (!match) + return; + + lastEqualC = c; + lastEqualR = r; + } + + if (c > lastEqualC) { + updateRange(agent, lastEqualC, c - lastEqualC, reference, lastEqualR); + } + } + +private: + // Tests if the cached contact id at i matches a referenceId. + // If there is a match removes all items traversed in the cache since the previous match + // and inserts any items in the reference set found to to not be in the cache. + bool cacheMatch(int i, int count, typename ReferenceList::const_reference referenceItem) + { + if (compareIdentity(cache.at(c + i), referenceItem)) { + if (i > 0) + c += removeRange(agent, c, i); + c += insertRange(agent, c, count, reference, r); + r += count; + return true; + } else { + return false; + } + } + + // Tests if the reference contact id at i matches a cacheId. + // If there is a match inserts all items traversed in the reference set since the + // previous match and removes any items from the cache that were not found in the + // reference list. + bool referenceMatch(int i, int count, typename ReferenceList::const_reference cacheItem) + { + if (compareIdentity(reference.at(r + i), cacheItem)) { + c += removeRange(agent, c, count); + if (i > 0) + c += insertRange(agent, c, i, reference, r); + r += i; + return true; + } else { + return false; + } + } + + Agent * const agent; + const CacheList &cache; + int &c; + const ReferenceList &reference; + int &r; +}; + +template +void completeSynchronizeList( + Agent *agent, + const CacheList &cache, + int &cacheIndex, + const ReferenceList &reference, + int &referenceIndex) +{ + if (cacheIndex < cache.count()) { + agent->removeRange(cacheIndex, cache.count() - cacheIndex); + } + if (referenceIndex < reference.count()) { + agent->insertRange(cache.count(), reference.count() - referenceIndex, reference, referenceIndex); + } + + cacheIndex = 0; + referenceIndex = 0; +} + +template +void synchronizeList( + Agent *agent, + const CacheList &cache, + int &cacheIndex, + const ReferenceList &reference, + int &referenceIndex) +{ + SynchronizeList( + agent, cache, cacheIndex, reference, referenceIndex); +} + +template +void synchronizeList(Agent *agent, const CacheList &cache, const ReferenceList &reference) +{ + int cacheIndex = 0; + int referenceIndex = 0; + SynchronizeList( + agent, cache, cacheIndex, reference, referenceIndex); + completeSynchronizeList(agent, cache, cacheIndex, reference, referenceIndex); +} + +#endif diff --git a/eventsview-plugins/eventsview-plugins.pro b/eventsview-plugins/eventsview-plugins.pro new file mode 100644 index 0000000..d9b1842 --- /dev/null +++ b/eventsview-plugins/eventsview-plugins.pro @@ -0,0 +1,2 @@ +TEMPLATE = subdirs +SUBDIRS += eventsview-plugin-mastodon diff --git a/icons/icons.pro b/icons/icons.pro new file mode 100644 index 0000000..510f3dc --- /dev/null +++ b/icons/icons.pro @@ -0,0 +1,5 @@ +TEMPLATE = aux +THEMENAME = sailfish-default +CONFIG += sailfish-svg2png + +OTHER_FILES += $$PWD/svgs/* diff --git a/icons/svgs/icons/icon-l-mastodon.svg b/icons/svgs/icons/icon-l-mastodon.svg new file mode 100644 index 0000000..0f8baeb --- /dev/null +++ b/icons/svgs/icons/icon-l-mastodon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/rpm/sailfish-account-mastodon.spec b/rpm/sailfish-account-mastodon.spec new file mode 100644 index 0000000..1fac766 --- /dev/null +++ b/rpm/sailfish-account-mastodon.spec @@ -0,0 +1,133 @@ +Name: sailfish-account-mastodon +License: LGPLv2+ +Version: 0.1.0 +Release: 1 +Source0: %{name}-%{version}.tar.bz2 +Summary: Account plugin for Mastodon +BuildRequires: qt5-qmake +BuildRequires: qt5-qttools-linguist +BuildRequires: sailfish-svg2png +BuildRequires: pkgconfig(Qt5Core) +BuildRequires: pkgconfig(Qt5DBus) +BuildRequires: pkgconfig(Qt5Sql) +BuildRequires: pkgconfig(Qt5Network) +BuildRequires: pkgconfig(Qt5Qml) +BuildRequires: pkgconfig(mlite5) +BuildRequires: pkgconfig(buteosyncfw5) >= 0.10.0 +BuildRequires: pkgconfig(accounts-qt5) +BuildRequires: pkgconfig(libsignon-qt5) +BuildRequires: pkgconfig(socialcache) +BuildRequires: pkgconfig(libsailfishkeyprovider) +BuildRequires: pkgconfig(sailfishaccounts) +BuildRequires: pkgconfig(nemotransferengine-qt5) >= 2.0.0 +BuildRequires: pkgconfig(nemonotifications-qt5) +Requires: jolla-settings-accounts-extensions-onlinesync +Requires: qmf-oauth2-plugin >= 0.0.7 +Requires(post): %{_libexecdir}/manage-groups +Requires(postun): %{_libexecdir}/manage-groups + +%description +%{summary}. + +%package -n buteo-sync-plugin-mastodon-posts +Summary: Provides synchronisation of Mastodon posts +Requires: %{name} = %{version}-%{release} +Requires: buteo-syncfw-qt5-msyncd +Requires: systemd +Requires(post): systemd + +%description -n buteo-sync-plugin-mastodon-posts +Provides synchronisation of Mastodon posts. + +%package -n eventsview-extensions-mastodon +Summary: Provides integration of Mastodon posts into Events view +Requires: lipstick-jolla-home-qt5-components >= 1.2.50 +Requires: eventsview-extensions + +%description -n eventsview-extensions-mastodon +Provides integration of Mastodon posts into Events view. + +%package -n transferengine-plugin-mastodon +Summary: Mastodon image sharing plugin for Transfer Engine +Requires: sailfishsilica-qt5 >= 1.1.108 +Requires: declarative-transferengine-qt5 >= 0.3.13 +Requires: nemo-transferengine-qt5 >= 2.0.0 +Requires: %{name} = %{version}-%{release} + +%description -n transferengine-plugin-mastodon +Mastodon image sharing plugin for Transfer Engine. + +%package features-all +Summary: Meta package to include all Mastodon account features +Requires: %{name} = %{version}-%{release} +Requires: buteo-sync-plugin-mastodon-posts +Requires: eventsview-extensions-mastodon +Requires: transferengine-plugin-mastodon + +%description features-all +This package includes all Mastodon account features. + +%prep +%setup -q -n %{name}-%{version} + +%build +%qmake5 "VERSION=%{version}" +%make_build + +%install +%qmake5_install +cd icons +make INSTALL_ROOT=%{buildroot} install +for icon in $(find %{buildroot}%{_datadir}/themes/sailfish-default/silica -type f -name icon-l-mastodon.png); do + dir=$(dirname "$icon") + cp -a "$icon" "$dir/graphic-service-mastodon.png" + cp -a "$icon" "$dir/graphic-m-service-mastodon.png" + cp -a "$icon" "$dir/graphic-s-service-mastodon.png" +done + +%post +/sbin/ldconfig +%{_libexecdir}/manage-groups add account-mastodon || : + +%postun +/sbin/ldconfig +if [ "$1" -eq 0 ]; then + %{_libexecdir}/manage-groups remove account-mastodon || : +fi + +%files +%{_libdir}/libmastodoncommon.so.* +%exclude %{_libdir}/libmastodoncommon.so +%{_libdir}/libmastodonbuteocommon.so.* +%exclude %{_libdir}/libmastodonbuteocommon.so +%{_datadir}/accounts/providers/mastodon.provider +%{_datadir}/accounts/services/mastodon-microblog.service +%{_datadir}/accounts/services/mastodon-sharing.service +%{_datadir}/accounts/ui/MastodonSettingsDisplay.qml +%{_datadir}/accounts/ui/mastodon.qml +%{_datadir}/accounts/ui/mastodon-settings.qml +%{_datadir}/accounts/ui/mastodon-update.qml +%{_datadir}/themes/sailfish-default/silica/*/icons/graphic-service-mastodon.png +%{_datadir}/themes/sailfish-default/silica/*/icons/graphic-m-service-mastodon.png +%{_datadir}/themes/sailfish-default/silica/*/icons/graphic-s-service-mastodon.png +%{_datadir}/themes/sailfish-default/silica/*/icons/icon-l-mastodon.png + +%files -n buteo-sync-plugin-mastodon-posts +%{_libdir}/buteo-plugins-qt5/oopp/libmastodon-posts-client.so +%config %{_sysconfdir}/buteo/profiles/client/mastodon-posts.xml +%config %{_sysconfdir}/buteo/profiles/sync/mastodon.Posts.xml + +%files -n eventsview-extensions-mastodon +%{_libdir}/qt5/qml/com/jolla/eventsview/mastodon/* +%{_datadir}/lipstick/eventfeed/mastodon-delegate.qml +%{_datadir}/lipstick/eventfeed/MastodonFeedItem.qml +%{_datadir}/translations/lipstick-jolla-home-mastodon_eng_en.qm +%{_datadir}/translations/source/lipstick-jolla-home-mastodon.ts + +%files -n transferengine-plugin-mastodon +%{_libdir}/nemo-transferengine/plugins/sharing/libmastodonshareplugin.so +%{_libdir}/nemo-transferengine/plugins/transfer/libmastodontransferplugin.so +%{_datadir}/nemo-transferengine/plugins/sharing/MastodonShareImage.qml + +%files features-all +# Empty by design. diff --git a/sailfish-account-mastodon.pro b/sailfish-account-mastodon.pro new file mode 100644 index 0000000..3a8f747 --- /dev/null +++ b/sailfish-account-mastodon.pro @@ -0,0 +1,14 @@ +TEMPLATE = subdirs +SUBDIRS += \ + common \ + settings \ + transferengine-plugins \ + buteo-plugins \ + eventsview-plugins \ + icons + +buteo-plugins.depends = common +transferengine-plugins.depends = common +eventsview-plugins.depends = common + +OTHER_FILES += rpm/sailfish-account-mastodon.spec diff --git a/settings/accounts/accounts.pro b/settings/accounts/accounts.pro new file mode 100644 index 0000000..7adc97e --- /dev/null +++ b/settings/accounts/accounts.pro @@ -0,0 +1,27 @@ +TEMPLATE = aux + +OTHER_FILES += \ + $$PWD/providers/mastodon.provider \ + $$PWD/services/mastodon-microblog.service \ + $$PWD/services/mastodon-sharing.service \ + $$PWD/ui/MastodonSettingsDisplay.qml \ + $$PWD/ui/mastodon.qml \ + $$PWD/ui/mastodon-settings.qml \ + $$PWD/ui/mastodon-update.qml + +provider.files += $$PWD/providers/mastodon.provider +provider.path = /usr/share/accounts/providers/ + +services.files += \ + $$PWD/services/mastodon-microblog.service \ + $$PWD/services/mastodon-sharing.service +services.path = /usr/share/accounts/services/ + +ui.files += \ + $$PWD/ui/MastodonSettingsDisplay.qml \ + $$PWD/ui/mastodon.qml \ + $$PWD/ui/mastodon-settings.qml \ + $$PWD/ui/mastodon-update.qml +ui.path = /usr/share/accounts/ui/ + +INSTALLS += provider services ui diff --git a/settings/accounts/providers/mastodon.provider b/settings/accounts/providers/mastodon.provider new file mode 100644 index 0000000..acd9d91 --- /dev/null +++ b/settings/accounts/providers/mastodon.provider @@ -0,0 +1,32 @@ + + + + Mastodon + Mastodon social network + image://theme/graphic-service-mastodon + + + + + user-group:account-mastodon + + diff --git a/settings/accounts/services/mastodon-microblog.service b/settings/accounts/services/mastodon-microblog.service new file mode 100644 index 0000000..550333e --- /dev/null +++ b/settings/accounts/services/mastodon-microblog.service @@ -0,0 +1,29 @@ + + + microblogging + Posts + image://theme/icon-m-events + mastodon + + + diff --git a/settings/accounts/services/mastodon-sharing.service b/settings/accounts/services/mastodon-sharing.service new file mode 100644 index 0000000..c3ecf37 --- /dev/null +++ b/settings/accounts/services/mastodon-sharing.service @@ -0,0 +1,28 @@ + + + sharing + Sharing + image://theme/icon-m-share + mastodon + + + diff --git a/settings/accounts/ui/MastodonSettingsDisplay.qml b/settings/accounts/ui/MastodonSettingsDisplay.qml new file mode 100644 index 0000000..be79e3a --- /dev/null +++ b/settings/accounts/ui/MastodonSettingsDisplay.qml @@ -0,0 +1,92 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Accounts 1.0 +import com.jolla.settings.accounts 1.0 +import org.nemomobile.configuration 1.0 + +StandardAccountSettingsDisplay { + id: root + + settingsModified: true + + onAboutToSaveAccount: { + settingsLoader.updateAllSyncProfiles() + + if (eventsSyncSwitch.checked !== root.account.configurationValues("")["FeedViewAutoSync"]) { + root.account.setConfigurationValue("", "FeedViewAutoSync", eventsSyncSwitch.checked) + } + } + + StandardAccountSettingsLoader { + id: settingsLoader + account: root.account + accountProvider: root.accountProvider + accountManager: root.accountManager + autoEnableServices: root.autoEnableAccount + + onSettingsLoaded: { + syncServicesRepeater.model = syncServices + otherServicesDisplay.serviceModel = otherServices + + var autoSync = root.account.configurationValues("")["FeedViewAutoSync"] + var isNewAccount = root.autoEnableAccount + eventsSyncSwitch.checked = (isNewAccount || autoSync === true) + } + } + + Column { + id: syncServicesDisplay + width: parent.width + + SectionHeader { + //: Options for data to be downloaded from a remote server + //% "Download" + text: qsTrId("settings-accounts-la-download_options") + } + + Repeater { + id: syncServicesRepeater + TextSwitch { + checked: model.enabled + text: model.displayName + visible: text.length > 0 + onCheckedChanged: { + if (checked) { + root.account.enableWithService(model.serviceName) + } else { + root.account.disableWithService(model.serviceName) + } + } + } + } + + TextSwitch { + id: eventsSyncSwitch + + text: "Sync Mastodon feed automatically" + description: "Fetch new posts periodically when browsing Events Mastodon feed." + + onCheckedChanged: { + autoSyncConf.value = checked + } + } + } + + ConfigurationValue { + id: autoSyncConf + key: "/desktop/lipstick-jolla-home/events/auto_sync_feeds/" + root.account.identifier + } + + AccountServiceSettingsDisplay { + id: otherServicesDisplay + enabled: root.accountEnabled + + onUpdateServiceEnabledStatus: { + if (enabled) { + root.account.enableWithService(serviceName) + } else { + root.account.disableWithService(serviceName) + } + } + } +} diff --git a/settings/accounts/ui/mastodon-settings.qml b/settings/accounts/ui/mastodon-settings.qml new file mode 100644 index 0000000..ae79ce4 --- /dev/null +++ b/settings/accounts/ui/mastodon-settings.qml @@ -0,0 +1,66 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Accounts 1.0 +import com.jolla.settings.accounts 1.0 + +AccountSettingsAgent { + id: root + + initialPage: Page { + onPageContainerChanged: { + if (pageContainer == null && !credentialsUpdater.running) { + root.delayDeletion = true + settingsDisplay.saveAccount() + } + } + + Component.onDestruction: { + if (status == PageStatus.Active) { + settingsDisplay.saveAccount(true) + } + } + + AccountCredentialsUpdater { + id: credentialsUpdater + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: header.height + settingsDisplay.height + Theme.paddingLarge + + StandardAccountSettingsPullDownMenu { + visible: settingsDisplay.accountValid + allowSync: true + onCredentialsUpdateRequested: { + credentialsUpdater.replaceWithCredentialsUpdatePage(root.accountId) + } + onAccountDeletionRequested: { + root.accountDeletionRequested() + pageStack.pop() + } + onSyncRequested: { + settingsDisplay.saveAccountAndSync() + } + } + + PageHeader { + id: header + title: root.accountsHeaderText + } + + MastodonSettingsDisplay { + id: settingsDisplay + anchors.top: header.bottom + accountManager: root.accountManager + accountProvider: root.accountProvider + accountId: root.accountId + + onAccountSaveCompleted: { + root.delayDeletion = false + } + } + + VerticalScrollDecorator {} + } + } +} diff --git a/settings/accounts/ui/mastodon-update.qml b/settings/accounts/ui/mastodon-update.qml new file mode 100644 index 0000000..3a6c25c --- /dev/null +++ b/settings/accounts/ui/mastodon-update.qml @@ -0,0 +1,99 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Accounts 1.0 +import com.jolla.settings.accounts 1.0 + +AccountCredentialsAgent { + id: root + + property bool _started + + readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html" + + function normalizeApiHost(rawHost) { + var host = rawHost ? rawHost.trim() : "" + if (host.length === 0) { + host = "https://mastodon.social" + } + + host = host.replace(/^https?:\/\//i, "") + var pathSeparator = host.indexOf("/") + if (pathSeparator !== -1) { + host = host.substring(0, pathSeparator) + } + host = host.replace(/\/+$/, "") + + if (host.length === 0) { + host = "mastodon.social" + } + return "https://" + host.toLowerCase() + } + + function _valueFromServiceConfig(config, key) { + return config && config[key] ? config[key].toString() : "" + } + + function _startUpdate() { + if (_started || initialPage.status !== PageStatus.Active || account.status !== Account.Initialized) { + return + } + + var config = account.configurationValues("mastodon-microblog") + var apiHost = normalizeApiHost(_valueFromServiceConfig(config, "api/Host")) + var oauthHost = _valueFromServiceConfig(config, "auth/oauth2/web_server/Host") + if (oauthHost.length === 0) { + oauthHost = apiHost.replace(/^https?:\/\//i, "") + } + + var clientId = _valueFromServiceConfig(config, "auth/oauth2/web_server/ClientId") + var clientSecret = _valueFromServiceConfig(config, "auth/oauth2/web_server/ClientSecret") + if (clientId.length === 0 || clientSecret.length === 0) { + credentialsUpdateError("Missing Mastodon OAuth client credentials") + return + } + + _started = true + + var sessionData = { + "ClientId": clientId, + "ClientSecret": clientSecret, + "Host": oauthHost, + "AuthPath": "oauth/authorize", + "TokenPath": "oauth/token", + "ResponseType": "code", + "Scope": ["read", "write"], + "RedirectUri": callbackUri + } + initialPage.prepareAccountCredentialsUpdate(account, root.accountProvider, "mastodon-microblog", sessionData) + } + + Account { + id: account + identifier: root.accountId + + onStatusChanged: { + root._startUpdate() + } + } + + initialPage: OAuthAccountSetupPage { + onStatusChanged: { + root._startUpdate() + } + + onAccountCredentialsUpdated: { + root.credentialsUpdated(root.accountId) + root.goToEndDestination() + } + + onAccountCredentialsUpdateError: { + root.credentialsUpdateError(errorMessage) + } + + onPageContainerChanged: { + if (pageContainer == null) { + cancelSignIn() + } + } + } +} diff --git a/settings/accounts/ui/mastodon.qml b/settings/accounts/ui/mastodon.qml new file mode 100644 index 0000000..a789459 --- /dev/null +++ b/settings/accounts/ui/mastodon.qml @@ -0,0 +1,337 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Accounts 1.0 +import com.jolla.settings.accounts 1.0 + +AccountCreationAgent { + id: root + + property Item _oauthPage + property Item _settingsDialog + property QtObject _accountSetup + + property string _pendingApiHost + property bool _registering + + readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html" + + function normalizeApiHost(rawHost) { + var host = rawHost ? rawHost.trim() : "" + if (host.length === 0) { + return "" + } + + host = host.replace(/^https?:\/\//i, "") + var pathSeparator = host.indexOf("/") + if (pathSeparator !== -1) { + host = host.substring(0, pathSeparator) + } + host = host.replace(/\/+$/, "") + + if (host.length === 0) { + return "" + } + return "https://" + host.toLowerCase() + } + + function oauthHost(apiHost) { + return apiHost.replace(/^https?:\/\//i, "") + } + + function _displayName(apiHost) { + return oauthHost(apiHost) + } + + function _showRegistrationError(message, busyPage) { + _registering = false + accountCreationError(message) + if (busyPage) { + busyPage.state = "info" + busyPage.infoDescription = message + busyPage.infoExtraDescription = "" + busyPage.infoButtonText = "" + } + } + + function _showOAuthPage(context) { + _registering = false + if (_oauthPage != null) { + _oauthPage.cancelSignIn() + _oauthPage.destroy() + } + _oauthPage = oAuthComponent.createObject(root, { "context": context }) + pageStack.replace(_oauthPage) + } + + function _registerClientApplication(apiHost, busyPage) { + if (_registering) { + return + } + _registering = true + + var xhr = new XMLHttpRequest() + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return + } + + if (xhr.status < 200 || xhr.status >= 300) { + _showRegistrationError("Failed to register Mastodon app for " + apiHost, busyPage) + return + } + + var response + try { + response = JSON.parse(xhr.responseText) + } catch (err) { + _showRegistrationError("Invalid Mastodon app registration response", busyPage) + return + } + + if (!response.client_id || !response.client_secret) { + _showRegistrationError("Mastodon app registration did not return credentials", busyPage) + return + } + + _showOAuthPage({ + "apiHost": apiHost, + "oauthHost": oauthHost(apiHost), + "clientId": response.client_id, + "clientSecret": response.client_secret + }) + } + + var postData = [] + postData.push("client_name=" + encodeURIComponent("Sailfish Mastodon")) + postData.push("redirect_uris=" + encodeURIComponent(callbackUri)) + postData.push("scopes=" + encodeURIComponent("read write")) + postData.push("website=" + encodeURIComponent("https://sailfishos.org")) + + xhr.open("POST", apiHost + "/api/v1/apps") + xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") + xhr.send(postData.join("&")) + } + + function _handleAccountCreated(accountId, context) { + var props = { + "accountId": accountId, + "apiHost": context.apiHost, + "oauthHost": context.oauthHost, + "clientId": context.clientId, + "clientSecret": context.clientSecret + } + _accountSetup = accountSetupComponent.createObject(root, props) + _accountSetup.done.connect(function() { + accountCreated(accountId) + _goToSettings(accountId) + }) + _accountSetup.error.connect(function() { + accountCreationError("Failed to finish Mastodon account setup") + }) + } + + function _goToSettings(accountId) { + if (_settingsDialog != null) { + _settingsDialog.destroy() + } + _settingsDialog = settingsComponent.createObject(root, {"accountId": accountId}) + pageStack.replace(_settingsDialog) + } + + initialPage: Dialog { + id: setupDialog + + property string normalizedHost: root.normalizeApiHost(instanceField.text) + + canAccept: !root._registering && normalizedHost.length > 0 + acceptDestinationAction: PageStackAction.Push + acceptDestination: busyComponent + + onAccepted: { + root._pendingApiHost = normalizedHost + } + + DialogHeader { + id: header + //% "Sign in" + acceptText: qsTrId("settings_accounts-common-bt-sign_in") + } + + Column { + anchors.top: header.bottom + anchors.topMargin: Theme.paddingLarge + spacing: Theme.paddingLarge + width: parent.width + + Label { + x: Theme.horizontalPageMargin + width: parent.width - x * 2 + wrapMode: Text.Wrap + color: Theme.highlightColor + text: "Enter your Mastodon server, then sign in." + } + + TextField { + id: instanceField + x: Theme.horizontalPageMargin + width: parent.width - x * 2 + label: "Server" + placeholderText: "mastodon.social" + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhUrlCharactersOnly + EnterKey.iconSource: "image://theme/icon-m-enter-next" + EnterKey.onClicked: { + if (setupDialog.canAccept) { + setupDialog.accept() + } + } + } + } + } + + Component { + id: busyComponent + AccountBusyPage { + busyDescription: "Preparing Mastodon sign-in..." + onStatusChanged: { + if (status === PageStatus.Active && root._pendingApiHost.length > 0) { + root._registerClientApplication(root._pendingApiHost, this) + } + } + } + } + + Component { + id: oAuthComponent + OAuthAccountSetupPage { + property var context + + Component.onCompleted: { + var sessionData = { + "ClientId": context.clientId, + "ClientSecret": context.clientSecret, + "Host": context.oauthHost, + "AuthPath": "oauth/authorize", + "TokenPath": "oauth/token", + "ResponseType": "code", + "Scope": ["read", "write"], + "RedirectUri": root.callbackUri + } + prepareAccountCreation(root.accountProvider, "mastodon-microblog", sessionData) + } + + onAccountCreated: { + root._handleAccountCreated(accountId, context) + } + + onAccountCreationError: { + root.accountCreationError(errorMessage) + } + } + } + + Component { + id: accountSetupComponent + QtObject { + id: accountSetup + + property int accountId + property string apiHost + property string oauthHost + property string clientId + property string clientSecret + property bool hasConfigured + + signal done() + signal error() + + property Account newAccount: Account { + identifier: accountSetup.accountId + + onStatusChanged: { + if (status === Account.Initialized || status === Account.Synced) { + if (!accountSetup.hasConfigured) { + accountSetup.configure() + } else { + accountSetup.done() + } + } else if (status === Account.Invalid && accountSetup.hasConfigured) { + accountSetup.error() + } + } + } + + function configure() { + hasConfigured = true + + var services = ["mastodon-microblog", "mastodon-sharing"] + var providerDisplayName = root._displayName(apiHost) + if (providerDisplayName.length > 0) { + newAccount.displayName = providerDisplayName + } + + newAccount.setConfigurationValue("", "api/Host", apiHost) + newAccount.setConfigurationValue("", "FeedViewAutoSync", true) + for (var i = 0; i < services.length; ++i) { + var service = services[i] + newAccount.setConfigurationValue(service, "api/Host", apiHost) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/Host", oauthHost) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/AuthPath", "oauth/authorize") + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/TokenPath", "oauth/token") + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ResponseType", "code") + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/RedirectUri", root.callbackUri) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/Scope", ["read", "write"]) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ClientId", clientId) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ClientSecret", clientSecret) + } + + for (var j = 0; j < services.length; ++j) { + newAccount.enableWithService(services[j]) + } + + newAccount.sync() + } + } + } + + Component { + id: settingsComponent + Dialog { + property alias accountId: settingsDisplay.accountId + + acceptDestination: root.endDestination + acceptDestinationAction: root.endDestinationAction + acceptDestinationProperties: root.endDestinationProperties + acceptDestinationReplaceTarget: root.endDestinationReplaceTarget + backNavigation: false + + onAccepted: { + root.delayDeletion = true + settingsDisplay.saveAccountAndSync() + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: header.height + settingsDisplay.height + + DialogHeader { + id: header + } + + MastodonSettingsDisplay { + id: settingsDisplay + anchors.top: header.bottom + accountManager: root.accountManager + accountProvider: root.accountProvider + autoEnableAccount: true + + onAccountSaveCompleted: { + root.delayDeletion = false + } + } + + VerticalScrollDecorator {} + } + } + } + +} diff --git a/settings/settings.pro b/settings/settings.pro new file mode 100644 index 0000000..5f2c9b0 --- /dev/null +++ b/settings/settings.pro @@ -0,0 +1,2 @@ +TEMPLATE = subdirs +SUBDIRS += accounts diff --git a/transferengine-plugins/mastodonshareplugin/MastodonShareImage.qml b/transferengine-plugins/mastodonshareplugin/MastodonShareImage.qml new file mode 100644 index 0000000..56b4b4b --- /dev/null +++ b/transferengine-plugins/mastodonshareplugin/MastodonShareImage.qml @@ -0,0 +1,10 @@ +import QtQuick 2.6 +import Sailfish.Silica 1.0 +import Sailfish.TransferEngine 1.0 + +ShareFilePreview { + id: root + + metadataStripped: true + descriptionPlaceholderText: qsTr("Write a post") +} diff --git a/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp b/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp new file mode 100644 index 0000000..405b86e --- /dev/null +++ b/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp @@ -0,0 +1,52 @@ +#include "mastodonplugininfo.h" +#include "mastodonshareservicestatus.h" + +MastodonPluginInfo::MastodonPluginInfo() + : SharingPluginInfo() + , m_mastodonShareServiceStatus(new MastodonShareServiceStatus(this)) +{ + m_capabilities << QLatin1String("image/jpeg") + << QLatin1String("image/png"); + + connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceReady, + this, &MastodonPluginInfo::serviceReady); + connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceError, + this, &MastodonPluginInfo::infoError); +} + +MastodonPluginInfo::~MastodonPluginInfo() +{ +} + +QList MastodonPluginInfo::info() const +{ + return m_info; +} + +void MastodonPluginInfo::query() +{ + m_mastodonShareServiceStatus->queryStatus(MastodonShareServiceStatus::PassiveMode); +} + +void MastodonPluginInfo::serviceReady() +{ + m_info.clear(); + + for (int i = 0; i < m_mastodonShareServiceStatus->count(); ++i) { + SharingMethodInfo info; + + const MastodonShareServiceStatus::AccountDetails details = m_mastodonShareServiceStatus->details(i); + info.setDisplayName(details.providerName); + info.setSubtitle(details.displayName); + info.setAccountId(details.accountId); + + info.setMethodId(QLatin1String("Mastodon")); + info.setMethodIcon(QLatin1String("image://theme/graphic-m-service-mastodon")); + info.setShareUIPath(QLatin1String("/usr/share/nemo-transferengine/plugins/sharing/MastodonShareImage.qml")); + info.setCapabilities(m_capabilities); + + m_info << info; + } + + emit infoReady(); +} diff --git a/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h b/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h new file mode 100644 index 0000000..28eb479 --- /dev/null +++ b/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h @@ -0,0 +1,29 @@ +#ifndef MASTODONPLUGININFO_H +#define MASTODONPLUGININFO_H + +#include +#include + +class MastodonShareServiceStatus; + +class MastodonPluginInfo : public SharingPluginInfo +{ + Q_OBJECT + +public: + MastodonPluginInfo(); + ~MastodonPluginInfo(); + + QList info() const; + void query(); + +private Q_SLOTS: + void serviceReady(); + +private: + MastodonShareServiceStatus *m_mastodonShareServiceStatus; + QList m_info; + QStringList m_capabilities; +}; + +#endif // MASTODONPLUGININFO_H diff --git a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp new file mode 100644 index 0000000..ec7a732 --- /dev/null +++ b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp @@ -0,0 +1,23 @@ +#include "mastodonshareplugin.h" +#include "mastodonplugininfo.h" + +#include + +MastodonSharePlugin::MastodonSharePlugin() + : QObject(), SharingPluginInterface() +{ +} + +MastodonSharePlugin::~MastodonSharePlugin() +{ +} + +SharingPluginInfo *MastodonSharePlugin::infoObject() +{ + return new MastodonPluginInfo; +} + +QString MastodonSharePlugin::pluginId() const +{ + return QLatin1String("Mastodon"); +} diff --git a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h new file mode 100644 index 0000000..634d051 --- /dev/null +++ b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h @@ -0,0 +1,22 @@ +#ifndef MASTODONSHAREPLUGIN_H +#define MASTODONSHAREPLUGIN_H + +#include + +#include + +class Q_DECL_EXPORT MastodonSharePlugin : public QObject, public SharingPluginInterface +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.sailfishos.share.plugin.mastodon") + Q_INTERFACES(SharingPluginInterface) + +public: + MastodonSharePlugin(); + ~MastodonSharePlugin(); + + SharingPluginInfo *infoObject(); + QString pluginId() const; +}; + +#endif // MASTODONSHAREPLUGIN_H diff --git a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro new file mode 100644 index 0000000..6de949e --- /dev/null +++ b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro @@ -0,0 +1,25 @@ +TEMPLATE = lib +TARGET = $$qtLibraryTarget(mastodonshareplugin) +CONFIG += plugin +DEPENDPATH += . +INCLUDEPATH += .. + +CONFIG += link_pkgconfig +PKGCONFIG += nemotransferengine-qt5 accounts-qt5 sailfishaccounts libsignon-qt5 + +HEADERS += mastodonshareplugin.h \ + mastodonplugininfo.h \ + ../mastodonshareservicestatus.h + +SOURCES += mastodonshareplugin.cpp \ + mastodonplugininfo.cpp \ + ../mastodonshareservicestatus.cpp + +target.path = $$[QT_INSTALL_LIBS]/nemo-transferengine/plugins/sharing + +OTHER_FILES += *.qml + +shareui.files = MastodonShareImage.qml +shareui.path = /usr/share/nemo-transferengine/plugins/sharing + +INSTALLS += target shareui diff --git a/transferengine-plugins/mastodonshareservicestatus.cpp b/transferengine-plugins/mastodonshareservicestatus.cpp new file mode 100644 index 0000000..8c70e12 --- /dev/null +++ b/transferengine-plugins/mastodonshareservicestatus.cpp @@ -0,0 +1,367 @@ +#include "mastodonshareservicestatus.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +MastodonShareServiceStatus::MastodonShareServiceStatus(QObject *parent) + : QObject(parent) + , m_auth(new AccountAuthenticator(this)) + , m_accountManager(new Accounts::Manager(this)) + , m_serviceName(QStringLiteral("mastodon-sharing")) +{ +} + +QString MastodonShareServiceStatus::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 MastodonShareServiceStatus::signIn(int accountId) +{ + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); + if (!account) { + qWarning() << Q_FUNC_INFO << "Failed to retrieve account for id:" << accountId; + setAccountDetailsState(accountId, Error); + return; + } + + const Accounts::Service service(m_accountManager->service(m_serviceName)); + if (!service.isValid()) { + qWarning() << Q_FUNC_INFO << "Invalid auth service" << m_serviceName; + account->deleteLater(); + setAccountDetailsState(accountId, Error); + return; + } + + account->selectService(service); + + SignOn::Identity *identity = account->credentialsId() > 0 + ? SignOn::Identity::existingIdentity(account->credentialsId()) + : 0; + if (!identity) { + qWarning() << Q_FUNC_INFO << "account" << accountId << "has no valid credentials"; + account->deleteLater(); + setAccountDetailsState(accountId, Error); + return; + } + + Accounts::AccountService accountService(account, service); + const QString method = accountService.authData().method(); + const QString mechanism = accountService.authData().mechanism(); + + SignOn::AuthSession *session = identity->createSession(method); + if (!session) { + qWarning() << Q_FUNC_INFO << "could not create signon session for account" << accountId; + identity->deleteLater(); + account->deleteLater(); + setAccountDetailsState(accountId, Error); + return; + } + + QVariantMap signonSessionData = accountService.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); + } + + const int separator = configuredHost.indexOf(QLatin1Char('/')); + if (separator > -1) { + configuredHost.truncate(separator); + } + 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(account)); + session->setProperty("identity", QVariant::fromValue(identity)); + session->process(SignOn::SessionData(signonSessionData), mechanism); +} + +void MastodonShareServiceStatus::signOnResponse(const SignOn::SessionData &responseData) +{ + QVariantMap data; + Q_FOREACH (const QString &key, responseData.propertyNames()) { + data.insert(key, responseData.getProperty(key)); + } + + SignOn::AuthSession *session = qobject_cast(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + const int accountId = account ? account->id() : 0; + + QString accessToken = data.value(QLatin1String("AccessToken")).toString().trimmed(); + if (accessToken.isEmpty()) { + accessToken = data.value(QLatin1String("access_token")).toString().trimmed(); + } + + if (accountId > 0 && m_accountIdToDetailsIdx.contains(accountId)) { + AccountDetails &accountDetails(m_accountDetails[m_accountIdToDetailsIdx[accountId]]); + accountDetails.accessToken = accessToken; + setAccountDetailsState(accountId, accessToken.isEmpty() ? Error : Populated); + } + + session->disconnect(this); + if (identity) { + identity->destroySession(session); + identity->deleteLater(); + } + if (account) { + account->deleteLater(); + } +} + +void MastodonShareServiceStatus::signOnError(const SignOn::Error &error) +{ + SignOn::AuthSession *session = qobject_cast(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + const int accountId = account ? account->id() : 0; + + qWarning() << Q_FUNC_INFO << "failed to retrieve credentials for account" << accountId + << error.type() << error.message(); + + if (accountId > 0 && error.type() == SignOn::Error::UserInteraction) { + setCredentialsNeedUpdate(accountId, m_serviceName); + } + + session->disconnect(this); + if (identity) { + identity->destroySession(session); + identity->deleteLater(); + } + if (account) { + account->deleteLater(); + } + + if (accountId > 0) { + setAccountDetailsState(accountId, Error); + } +} + +void MastodonShareServiceStatus::setAccountDetailsState(int accountId, AccountDetailsState state) +{ + if (!m_accountIdToDetailsIdx.contains(accountId)) { + return; + } + + m_accountDetailsState[accountId] = state; + + bool anyWaiting = false; + bool anyPopulated = false; + Q_FOREACH (int id, m_accountDetailsState.keys()) { + AccountDetailsState accountState = m_accountDetailsState.value(id, Waiting); + if (accountState == Waiting) { + anyWaiting = true; + } else if (accountState == Populated) { + anyPopulated = true; + } + } + + if (!anyWaiting) { + if (anyPopulated) { + emit serviceReady(); + } else { + emit serviceError(QStringLiteral("Unable to retrieve Mastodon account credentials")); + } + } +} + +int MastodonShareServiceStatus::count() const +{ + return m_accountDetails.count(); +} + +bool MastodonShareServiceStatus::setCredentialsNeedUpdate(int accountId, const QString &serviceName) +{ + return m_auth->setCredentialsNeedUpdate(accountId, serviceName); +} + +void MastodonShareServiceStatus::queryStatus(QueryStatusMode mode) +{ + m_accountDetails.clear(); + m_accountIdToDetailsIdx.clear(); + m_accountDetailsState.clear(); + + bool signInActive = false; + Q_FOREACH (Accounts::AccountId id, m_accountManager->accountList()) { + Accounts::Account *acc = m_accountManager->account(id); + + if (!acc) { + qWarning() << Q_FUNC_INFO << "Failed to get account for id:" << id; + continue; + } + + acc->selectService(Accounts::Service()); + + const Accounts::Service service(m_accountManager->service(m_serviceName)); + const Accounts::ServiceList services = acc->services(); + bool serviceFound = false; + Q_FOREACH (const Accounts::Service &s, services) { + if (s.name() == m_serviceName) { + serviceFound = true; + break; + } + } + + if (acc->enabled() && service.isValid() && serviceFound) { + if (acc->value(QStringLiteral("CredentialsNeedUpdate")).toBool()) { + qWarning() << Q_FUNC_INFO << "Credentials need update for account id:" << id; + continue; + } + + acc->selectService(service); + if (acc->value(QStringLiteral("CredentialsNeedUpdate")).toBool()) { + qWarning() << Q_FUNC_INFO << "Credentials need update for account id:" << id; + acc->selectService(Accounts::Service()); + continue; + } + + if (!m_accountIdToDetailsIdx.contains(id)) { + AccountDetails details; + details.accountId = id; + details.apiHost = normalizeApiHost(acc->value(QStringLiteral("api/Host")).toString()); + + QUrl apiUrl(details.apiHost); + details.providerName = apiUrl.host(); + if (details.providerName.isEmpty()) { + details.providerName = details.apiHost; + if (details.providerName.startsWith(QLatin1String("https://"))) { + details.providerName.remove(0, 8); + } else if (details.providerName.startsWith(QLatin1String("http://"))) { + details.providerName.remove(0, 7); + } + const int separator = details.providerName.indexOf(QLatin1Char('/')); + if (separator > 0) { + details.providerName.truncate(separator); + } + } + + details.displayName = acc->displayName(); + + m_accountIdToDetailsIdx.insert(id, m_accountDetails.size()); + m_accountDetails.append(details); + } + + if (mode == SignInMode) { + signInActive = true; + m_accountDetailsState.insert(id, Waiting); + signIn(id); + } + + acc->selectService(Accounts::Service()); + } + } + + if (!signInActive) { + emit serviceReady(); + } +} + +MastodonShareServiceStatus::AccountDetails MastodonShareServiceStatus::details(int index) const +{ + if (index < 0 || index >= m_accountDetails.size()) { + qWarning() << Q_FUNC_INFO << "Index out of range"; + return AccountDetails(); + } + + return m_accountDetails.at(index); +} + +MastodonShareServiceStatus::AccountDetails MastodonShareServiceStatus::detailsByIdentifier(int accountIdentifier) const +{ + if (!m_accountIdToDetailsIdx.contains(accountIdentifier)) { + qWarning() << Q_FUNC_INFO << "No details known for account with identifier" << accountIdentifier; + return AccountDetails(); + } + + return m_accountDetails[m_accountIdToDetailsIdx[accountIdentifier]]; +} diff --git a/transferengine-plugins/mastodonshareservicestatus.h b/transferengine-plugins/mastodonshareservicestatus.h new file mode 100644 index 0000000..571efd5 --- /dev/null +++ b/transferengine-plugins/mastodonshareservicestatus.h @@ -0,0 +1,75 @@ +#ifndef MASTODONSHARESERVICESTATUS_H +#define MASTODONSHARESERVICESTATUS_H + +#include +#include +#include + +#include + +namespace Accounts { +class Account; +class Manager; +} + +namespace SignOn { +class Error; +class SessionData; +} + +class MastodonShareServiceStatus : public QObject +{ + Q_OBJECT + +public: + explicit MastodonShareServiceStatus(QObject *parent = 0); + + enum QueryStatusMode { + PassiveMode = 0, + SignInMode = 1 + }; + + void queryStatus(QueryStatusMode mode = SignInMode); + + struct AccountDetails { + int accountId = 0; + QString providerName; + QString displayName; + QString accessToken; + QString apiHost; + }; + + AccountDetails details(int index = 0) const; + AccountDetails detailsByIdentifier(int accountIdentifier) const; + int count() const; + + bool setCredentialsNeedUpdate(int accountId, const QString &serviceName); + +Q_SIGNALS: + void serviceReady(); + void serviceError(const QString &message); + +private Q_SLOTS: + void signOnResponse(const SignOn::SessionData &responseData); + void signOnError(const SignOn::Error &error); + +private: + enum AccountDetailsState { + Waiting, + Populated, + Error + }; + + static QString normalizeApiHost(const QString &rawHost); + void setAccountDetailsState(int accountId, AccountDetailsState state); + void signIn(int accountId); + + AccountAuthenticator *m_auth; + Accounts::Manager *m_accountManager; + QString m_serviceName; + QVector m_accountDetails; + QHash m_accountIdToDetailsIdx; + QHash m_accountDetailsState; +}; + +#endif // MASTODONSHARESERVICESTATUS_H diff --git a/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp b/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp new file mode 100644 index 0000000..a4b40a9 --- /dev/null +++ b/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp @@ -0,0 +1,244 @@ +#include "mastodonapi.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +MastodonApi::MastodonApi(QNetworkAccessManager *qnam, QObject *parent) + : QObject(parent) + , m_qnam(qnam) +{ +} + +MastodonApi::~MastodonApi() +{ +} + +QString MastodonApi::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; +} + +bool MastodonApi::uploadImage(const QString &filePath, + const QString &statusText, + const QString &mimeType, + const QString &apiHost, + const QString &accessToken) +{ + QFile file(filePath); + if (filePath.isEmpty() || !file.open(QIODevice::ReadOnly)) { + qWarning() << Q_FUNC_INFO << "error opening file:" << filePath; + return false; + } + + m_apiHost = normalizeApiHost(apiHost); + m_accessToken = accessToken; + m_statusText = statusText; + + if (m_accessToken.isEmpty()) { + qWarning() << Q_FUNC_INFO << "missing access token"; + return false; + } + + const QByteArray imageData = file.readAll(); + const QFileInfo fileInfo(filePath); + + QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + + QHttpPart filePart; + filePart.setHeader(QNetworkRequest::ContentDispositionHeader, + QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"") + .arg(fileInfo.fileName()))); + if (!mimeType.isEmpty()) { + filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(mimeType)); + } + filePart.setBody(imageData); + multiPart->append(filePart); + + QNetworkRequest request(QUrl(m_apiHost + QStringLiteral("/api/v1/media"))); + request.setRawHeader(QByteArrayLiteral("Authorization"), + QByteArrayLiteral("Bearer ") + m_accessToken.toUtf8()); + + QNetworkReply *reply = m_qnam->post(request, multiPart); + if (!reply) { + delete multiPart; + return false; + } + + multiPart->setParent(reply); + m_replies.insert(reply, UPLOAD_MEDIA); + + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), + this, SLOT(replyError(QNetworkReply::NetworkError))); + connect(reply, &QNetworkReply::uploadProgress, + this, &MastodonApi::uploadProgress); + connect(reply, &QNetworkReply::finished, + this, &MastodonApi::finished); + + return true; +} + +bool MastodonApi::postStatus(const QString &mediaId) +{ + if (mediaId.isEmpty()) { + qWarning() << Q_FUNC_INFO << "media id is empty"; + return false; + } + + QUrlQuery query; + if (!m_statusText.isEmpty()) { + query.addQueryItem(QStringLiteral("status"), m_statusText); + } + query.addQueryItem(QStringLiteral("media_ids[]"), mediaId); + + const QByteArray postData = query.query(QUrl::FullyEncoded).toUtf8(); + + QNetworkRequest request(QUrl(m_apiHost + QStringLiteral("/api/v1/statuses"))); + request.setRawHeader(QByteArrayLiteral("Authorization"), + QByteArrayLiteral("Bearer ") + m_accessToken.toUtf8()); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QVariant(QStringLiteral("application/x-www-form-urlencoded"))); + + QNetworkReply *reply = m_qnam->post(request, postData); + if (!reply) { + return false; + } + + m_replies.insert(reply, POST_STATUS); + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), + this, SLOT(replyError(QNetworkReply::NetworkError))); + connect(reply, &QNetworkReply::finished, + this, &MastodonApi::finished); + + return true; +} + +void MastodonApi::cancelUpload() +{ + if (m_replies.isEmpty()) { + qWarning() << Q_FUNC_INFO << "can't cancel upload"; + return; + } + + const QList replies = m_replies.keys(); + Q_FOREACH (QNetworkReply *reply, replies) { + reply->abort(); + } + m_replies.clear(); +} + +void MastodonApi::replyError(QNetworkReply::NetworkError error) +{ + Q_UNUSED(error) +} + +void MastodonApi::uploadProgress(qint64 sent, qint64 total) +{ + if (total > 0) { + emit transferProgressUpdated(sent / static_cast(total)); + } +} + +void MastodonApi::finished() +{ + QNetworkReply *reply = qobject_cast(sender()); + if (!reply || !m_replies.contains(reply)) { + return; + } + + const API_CALL apiCall = m_replies.take(reply); + const QByteArray data = reply->readAll(); + const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QNetworkReply::NetworkError error = reply->error(); + + reply->deleteLater(); + + if (apiCall == UPLOAD_MEDIA) { + if (error != QNetworkReply::NoError || httpCode < 200 || httpCode >= 300) { + finishTransfer(error == QNetworkReply::NoError ? QNetworkReply::UnknownNetworkError : error, + httpCode, + data); + return; + } + + QString mediaId; + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isObject()) { + const QJsonValue idValue = doc.object().value(QStringLiteral("id")); + if (idValue.isString()) { + mediaId = idValue.toString(); + } else if (idValue.isDouble()) { + mediaId = QString::number(static_cast(idValue.toDouble())); + } + } + + if (!postStatus(mediaId)) { + qWarning() << Q_FUNC_INFO << "unable to create mastodon status"; + emit transferError(); + } + return; + } + + if (apiCall == POST_STATUS) { + finishTransfer(error, httpCode, data); + return; + } + + emit transferError(); +} + +void MastodonApi::finishTransfer(QNetworkReply::NetworkError error, int httpCode, const QByteArray &data) +{ + if (httpCode == 401) { + emit credentialsExpired(); + } + + if (error != QNetworkReply::NoError) { + if (error == QNetworkReply::OperationCanceledError) { + emit transferCanceled(); + return; + } + + qWarning() << Q_FUNC_INFO << "network error:" << error << "httpCode:" << httpCode << "data:" << data; + emit transferError(); + return; + } + + if (httpCode < 200 || httpCode >= 300) { + qWarning() << Q_FUNC_INFO << "http error:" << httpCode << "data:" << data; + emit transferError(); + return; + } + + emit transferFinished(); +} diff --git a/transferengine-plugins/mastodontransferplugin/mastodonapi.h b/transferengine-plugins/mastodontransferplugin/mastodonapi.h new file mode 100644 index 0000000..0ec3653 --- /dev/null +++ b/transferengine-plugins/mastodontransferplugin/mastodonapi.h @@ -0,0 +1,56 @@ +#ifndef MASTODONAPI_H +#define MASTODONAPI_H + +#include +#include + +#include +#include + +class MastodonApi : public QObject +{ + Q_OBJECT + +public: + enum API_CALL { + NONE, + UPLOAD_MEDIA, + POST_STATUS + }; + + explicit MastodonApi(QNetworkAccessManager *qnam, QObject *parent = 0); + ~MastodonApi(); + + bool uploadImage(const QString &filePath, + const QString &statusText, + const QString &mimeType, + const QString &apiHost, + const QString &accessToken); + + void cancelUpload(); + +Q_SIGNALS: + void transferProgressUpdated(qreal progress); + void transferFinished(); + void transferError(); + void transferCanceled(); + void credentialsExpired(); + +private Q_SLOTS: + void replyError(QNetworkReply::NetworkError error); + void finished(); + void uploadProgress(qint64 received, qint64 total); + +private: + static QString normalizeApiHost(const QString &rawHost); + bool postStatus(const QString &mediaId); + void finishTransfer(QNetworkReply::NetworkError error, int httpCode, const QByteArray &data); + + QMap m_replies; + QNetworkAccessManager *m_qnam; + QString m_accessToken; + QString m_apiHost; + QString m_statusText; +}; + +#endif // MASTODONAPI_H diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp new file mode 100644 index 0000000..2ee4cd0 --- /dev/null +++ b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp @@ -0,0 +1,25 @@ +#include "mastodontransferplugin.h" +#include "mastodonuploader.h" + +#include +#include + +MastodonTransferPlugin::MastodonTransferPlugin() + : QObject(), TransferPluginInterface() + , m_qnam(new QNetworkAccessManager(this)) +{ +} + +MastodonTransferPlugin::~MastodonTransferPlugin() +{ +} + +MediaTransferInterface *MastodonTransferPlugin::transferObject() +{ + return new MastodonUploader(m_qnam, this); +} + +QString MastodonTransferPlugin::pluginId() const +{ + return QLatin1String("Mastodon"); +} diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h new file mode 100644 index 0000000..68cf188 --- /dev/null +++ b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h @@ -0,0 +1,27 @@ +#ifndef MASTODONTRANSFERPLUGIN_H +#define MASTODONTRANSFERPLUGIN_H + +#include + +#include + +class QNetworkAccessManager; + +class Q_DECL_EXPORT MastodonTransferPlugin : public QObject, public TransferPluginInterface +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.sailfishos.transfer.plugin.mastodon") + Q_INTERFACES(TransferPluginInterface) + +public: + MastodonTransferPlugin(); + ~MastodonTransferPlugin(); + + MediaTransferInterface *transferObject(); + QString pluginId() const; + +private: + QNetworkAccessManager *m_qnam; +}; + +#endif // MASTODONTRANSFERPLUGIN_H diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro new file mode 100644 index 0000000..b37bf17 --- /dev/null +++ b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro @@ -0,0 +1,24 @@ +TEMPLATE = lib +TARGET = $$qtLibraryTarget(mastodontransferplugin) +CONFIG += plugin +DEPENDPATH += . +INCLUDEPATH += .. + +QT += network + +CONFIG += link_pkgconfig +PKGCONFIG += nemotransferengine-qt5 accounts-qt5 sailfishaccounts libsignon-qt5 + +HEADERS += mastodontransferplugin.h \ + mastodonuploader.h \ + ../mastodonshareservicestatus.h \ + mastodonapi.h + +SOURCES += mastodontransferplugin.cpp \ + mastodonuploader.cpp \ + ../mastodonshareservicestatus.cpp \ + mastodonapi.cpp + +target.path = $$[QT_INSTALL_LIBS]/nemo-transferengine/plugins/transfer + +INSTALLS += target diff --git a/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp b/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp new file mode 100644 index 0000000..9e2fa1a --- /dev/null +++ b/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp @@ -0,0 +1,200 @@ +#include "mastodonuploader.h" +#include "mastodonapi.h" + +#include +#include + +#include +#include +#include + +#include + +MastodonUploader::MastodonUploader(QNetworkAccessManager *qnam, QObject *parent) + : MediaTransferInterface(parent) + , m_api(0) + , m_mastodonShareServiceStatus(0) + , m_qnam(qnam) + , m_useTmpFile(false) +{ +} + +MastodonUploader::~MastodonUploader() +{ +} + +QString MastodonUploader::displayName() const +{ + return tr("Mastodon"); +} + +QUrl MastodonUploader::serviceIcon() const +{ + return QUrl(QStringLiteral("image://theme/graphic-s-service-mastodon")); +} + +bool MastodonUploader::cancelEnabled() const +{ + return true; +} + +bool MastodonUploader::restartEnabled() const +{ + return true; +} + +void MastodonUploader::start() +{ + if (!mediaItem()) { + qWarning() << Q_FUNC_INFO << "NULL MediaItem. Can't continue"; + setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + + if (!m_mastodonShareServiceStatus) { + m_mastodonShareServiceStatus = new MastodonShareServiceStatus(this); + connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceReady, + this, &MastodonUploader::startUploading); + connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceError, + this, [this] (const QString &) { + transferError(); + }); + } + + m_mastodonShareServiceStatus->queryStatus(); +} + +void MastodonUploader::cancel() +{ + if (m_api) { + m_api->cancelUpload(); + } else { + qWarning() << Q_FUNC_INFO << "Can't cancel. NULL MastodonApi object!"; + } +} + +void MastodonUploader::startUploading() +{ + if (!m_mastodonShareServiceStatus) { + qWarning() << Q_FUNC_INFO << "NULL MastodonShareServiceStatus object!"; + return; + } + + const quint32 accountId = mediaItem()->value(MediaItem::AccountId).toInt(); + m_accountDetails = m_mastodonShareServiceStatus->detailsByIdentifier(accountId); + if (m_accountDetails.accountId <= 0 || m_accountDetails.accessToken.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Mastodon account details missing for id" << accountId; + transferError(); + return; + } + + postImage(); +} + +void MastodonUploader::transferFinished() +{ + setStatus(MediaTransferInterface::TransferFinished); +} + +void MastodonUploader::transferProgress(qreal progress) +{ + setProgress(progress); +} + +void MastodonUploader::transferError() +{ + setStatus(MediaTransferInterface::TransferInterrupted); + qWarning() << Q_FUNC_INFO << "Transfer interrupted"; +} + +void MastodonUploader::transferCanceled() +{ + setStatus(MediaTransferInterface::TransferCanceled); +} + +void MastodonUploader::credentialsExpired() +{ + const quint32 accountId = mediaItem()->value(MediaItem::AccountId).toInt(); + m_mastodonShareServiceStatus->setCredentialsNeedUpdate(accountId, QStringLiteral("mastodon-sharing")); +} + +void MastodonUploader::setStatus(MediaTransferInterface::TransferStatus status) +{ + const bool finished = (status == TransferCanceled + || status == TransferInterrupted + || status == TransferFinished); + if (m_useTmpFile && finished) { + QFile::remove(m_filePath); + m_useTmpFile = false; + m_filePath.clear(); + } + + MediaTransferInterface::setStatus(status); +} + +void MastodonUploader::postImage() +{ + m_useTmpFile = false; + m_filePath.clear(); + const QString sourceFile = mediaItem()->value(MediaItem::Url).toUrl().toLocalFile(); + if (sourceFile.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Empty source file"; + setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + + QMimeDatabase db; + const QMimeType mime = db.mimeTypeForFile(sourceFile); + const bool isJpeg = mime.name() == QLatin1String("image/jpeg"); + + if (isJpeg && mediaItem()->value(MediaItem::MetadataStripped).toBool()) { + m_useTmpFile = true; + m_filePath = ImageOperation::removeImageMetadata(sourceFile); + if (m_filePath.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Failed to remove metadata"; + MediaTransferInterface::setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + } + + const qreal scale = mediaItem()->value(MediaItem::ScalePercent).toReal(); + if (0 < scale && scale < 1) { + m_useTmpFile = true; + m_filePath = ImageOperation::scaleImage(sourceFile, scale, m_filePath); + if (m_filePath.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Failed to scale image"; + MediaTransferInterface::setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + } + + if (!m_useTmpFile) { + m_filePath = sourceFile; + } + + if (!m_api) { + m_api = new MastodonApi(m_qnam, this); + connect(m_api, &MastodonApi::transferProgressUpdated, + this, &MastodonUploader::transferProgress); + connect(m_api, &MastodonApi::transferFinished, + this, &MastodonUploader::transferFinished); + connect(m_api, &MastodonApi::transferError, + this, &MastodonUploader::transferError); + connect(m_api, &MastodonApi::transferCanceled, + this, &MastodonUploader::transferCanceled); + connect(m_api, &MastodonApi::credentialsExpired, + this, &MastodonUploader::credentialsExpired); + } + + const bool ok = m_api->uploadImage(m_filePath, + mediaItem()->value(MediaItem::Description).toString(), + mediaItem()->value(MediaItem::MimeType).toString(), + m_accountDetails.apiHost, + m_accountDetails.accessToken); + if (ok) { + setStatus(MediaTransferInterface::TransferStarted); + } else { + setStatus(MediaTransferInterface::TransferInterrupted); + qWarning() << Q_FUNC_INFO << "Failed to upload image"; + } +} diff --git a/transferengine-plugins/mastodontransferplugin/mastodonuploader.h b/transferengine-plugins/mastodontransferplugin/mastodonuploader.h new file mode 100644 index 0000000..b0ea263 --- /dev/null +++ b/transferengine-plugins/mastodontransferplugin/mastodonuploader.h @@ -0,0 +1,51 @@ +#ifndef MASTODONUPLOADER_H +#define MASTODONUPLOADER_H + +#include + +#include + +#include "mastodonshareservicestatus.h" + +class MastodonApi; + +class MastodonUploader : public MediaTransferInterface +{ + Q_OBJECT + +public: + MastodonUploader(QNetworkAccessManager *qnam, QObject *parent = 0); + ~MastodonUploader(); + + QString displayName() const; + QUrl serviceIcon() const; + bool cancelEnabled() const; + bool restartEnabled() const; + +public Q_SLOTS: + void start(); + void cancel(); + +private Q_SLOTS: + void startUploading(); + void transferFinished(); + void transferProgress(qreal progress); + void transferError(); + void transferCanceled(); + void credentialsExpired(); + +protected: + void setStatus(MediaTransferInterface::TransferStatus status); + +private: + void postImage(); + + MastodonApi *m_api; + MastodonShareServiceStatus *m_mastodonShareServiceStatus; + QNetworkAccessManager *m_qnam; + MastodonShareServiceStatus::AccountDetails m_accountDetails; + bool m_useTmpFile; + QString m_filePath; +}; + +#endif // MASTODONUPLOADER_H diff --git a/transferengine-plugins/transferengine-plugins.pro b/transferengine-plugins/transferengine-plugins.pro new file mode 100644 index 0000000..fef3cf5 --- /dev/null +++ b/transferengine-plugins/transferengine-plugins.pro @@ -0,0 +1,2 @@ +TEMPLATE = subdirs +SUBDIRS = mastodonshareplugin mastodontransferplugin -- cgit v1.2.3