summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrew Branson <andrew.branson@jolla.com>2026-02-10 10:41:02 +0100
committerAndrew Branson <andrew.branson@jolla.com>2026-02-10 17:09:39 +0100
commit4351f4627ba9e71775438dd26c9acddd002c7e11 (patch)
tree3c72c980c5c81507109087bda67052b7ec8216b6
Initial commit
-rw-r--r--.gitignore8
-rw-r--r--README.md71
-rw-r--r--buteo-plugins/buteo-common/buteo-common.pri9
-rw-r--r--buteo-plugins/buteo-common/buteo-common.pro29
-rw-r--r--buteo-plugins/buteo-common/buteosyncfw_p.h39
-rw-r--r--buteo-plugins/buteo-common/socialdbuteoplugin.cpp338
-rw-r--r--buteo-plugins/buteo-common/socialdbuteoplugin.h75
-rw-r--r--buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.cpp37
-rw-r--r--buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.h40
-rw-r--r--buteo-plugins/buteo-common/socialnetworksyncadaptor.cpp470
-rw-r--r--buteo-plugins/buteo-common/socialnetworksyncadaptor.h155
-rw-r--r--buteo-plugins/buteo-common/trace.cpp25
-rw-r--r--buteo-plugins/buteo-common/trace.h30
-rw-r--r--buteo-plugins/buteo-plugins.pro6
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro37
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml4
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml15
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp313
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h70
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp49
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h54
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp316
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h61
-rw-r--r--common/common.pri4
-rw-r--r--common/common.pro21
-rw-r--r--common/mastodonpostsdatabase.cpp87
-rw-r--r--common/mastodonpostsdatabase.h46
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml143
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.cpp190
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel.h72
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/abstractsocialcachemodel_p.h53
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro61
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon.ts24
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon_eng_en.qmbin0 -> 299 bytes
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml167
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp132
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h67
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp19
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/postimagehelper_p.h45
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/qmldir2
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/synchronizelists_p.h224
-rw-r--r--eventsview-plugins/eventsview-plugins.pro2
-rw-r--r--icons/icons.pro5
-rw-r--r--icons/svgs/icons/icon-l-mastodon.svg10
-rw-r--r--rpm/sailfish-account-mastodon.spec133
-rw-r--r--sailfish-account-mastodon.pro14
-rw-r--r--settings/accounts/accounts.pro27
-rw-r--r--settings/accounts/providers/mastodon.provider32
-rw-r--r--settings/accounts/services/mastodon-microblog.service29
-rw-r--r--settings/accounts/services/mastodon-sharing.service28
-rw-r--r--settings/accounts/ui/MastodonSettingsDisplay.qml92
-rw-r--r--settings/accounts/ui/mastodon-settings.qml66
-rw-r--r--settings/accounts/ui/mastodon-update.qml99
-rw-r--r--settings/accounts/ui/mastodon.qml337
-rw-r--r--settings/settings.pro2
-rw-r--r--transferengine-plugins/mastodonshareplugin/MastodonShareImage.qml10
-rw-r--r--transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp52
-rw-r--r--transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h29
-rw-r--r--transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp23
-rw-r--r--transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h22
-rw-r--r--transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro25
-rw-r--r--transferengine-plugins/mastodonshareservicestatus.cpp367
-rw-r--r--transferengine-plugins/mastodonshareservicestatus.h75
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodonapi.cpp244
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodonapi.h56
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp25
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h27
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro24
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp200
-rw-r--r--transferengine-plugins/mastodontransferplugin/mastodonuploader.h51
-rw-r--r--transferengine-plugins/transferengine-plugins.pro2
71 files changed, 5716 insertions, 0 deletions
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 <chris.adams@jollamobile.com>
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#ifndef SOCIALD_BUTEOSYNCFW_P_H
+#define SOCIALD_BUTEOSYNCFW_P_H
+
+#include <SyncCommonDefs.h>
+#include <SyncPluginBase.h>
+#include <ProfileManager.h>
+#include <ClientPlugin.h>
+#include <SyncResults.h>
+#include <ProfileEngineDefs.h>
+#include <SyncProfile.h>
+#include <Profile.h>
+#include <PluginCbInterface.h>
+
+#ifndef SOCIALD_TEST_DEFINE
+#define PRIVILEGED_DATA_DIR QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + QLatin1String("/.local/share/system/privileged")
+#endif
+
+#endif // SOCIALD_BUTEOSYNCFW_P_H
diff --git a/buteo-plugins/buteo-common/socialdbuteoplugin.cpp b/buteo-plugins/buteo-common/socialdbuteoplugin.cpp
new file mode 100644
index 0000000..8b27f84
--- /dev/null
+++ b/buteo-plugins/buteo-common/socialdbuteoplugin.cpp
@@ -0,0 +1,338 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2014 Jolla Ltd.
+ ** Contact: Chris Adams <chris.adams@jolla.com>
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#include "socialdbuteoplugin.h"
+#include "socialnetworksyncadaptor.h"
+#include "trace.h"
+
+#include <QCoreApplication>
+#include <QTranslator>
+
+#include <QDBusMessage>
+#include <QDBusConnection>
+#include <QDBusPendingCall>
+
+#include "buteosyncfw_p.h"
+
+#include <Accounts/Manager>
+#include <Accounts/Account>
+#include <Accounts/Service>
+
+namespace {
+ const QString SyncProfileTemplatesKey = QStringLiteral("sync_profile_templates");
+
+ QString SyncProfileIdKey(const QString &templateProfileName)
+ {
+ return QStringLiteral("%1/%2").arg(templateProfileName).arg(Buteo::KEY_PROFILE_ID);
+ }
+
+ QString createProfile(Buteo::ProfileManager *profileManager,
+ const QString &templateProfileName,
+ Accounts::Account *account,
+ const Accounts::Service &srv,
+ bool enableProfile,
+ const QVariantMap &properties)
+ {
+ if (!account || !srv.isValid()) {
+ qWarning() << "Invalid account or service";
+ return QString();
+ }
+ if (templateProfileName.isEmpty()) {
+ qWarning() << "Invalid templateProfileName";
+ return QString();
+ }
+
+ Accounts::Service prevService = account->selectedService();
+ account->selectService(srv);
+
+ Buteo::SyncProfile *templateProfile = profileManager->syncProfile(templateProfileName);
+ if (!templateProfile) {
+ account->selectService(prevService);
+ qWarning() << "Unable to load template profile:" << templateProfileName;
+ return QString();
+ }
+
+ Buteo::SyncProfile *profile = templateProfile->clone();
+ if (!profile) {
+ delete templateProfile;
+ account->selectService(prevService);
+ qWarning() << "unable to clone template profile:" << templateProfileName;
+ return QString();
+ }
+
+ QString accountIdStr = QString::number(account->id());
+ profile->setName(templateProfileName + "-" + accountIdStr);
+ profile->setKey(Buteo::KEY_DISPLAY_NAME, templateProfileName + "-" + account->displayName().toHtmlEscaped());
+ profile->setKey(Buteo::KEY_ACCOUNT_ID, accountIdStr);
+ profile->setBoolKey(Buteo::KEY_USE_ACCOUNTS, true);
+ profile->setEnabled(enableProfile);
+
+ // enable the profile schedule
+ Buteo::SyncSchedule schedule = profile->syncSchedule();
+ schedule.setScheduleEnabled(true);
+ profile->setSyncSchedule(schedule);
+
+ // set custom properties; note this may override any properties already set
+ Q_FOREACH (const QString &key, properties.keys()) {
+ profile->setKey(key, properties[key].toString());
+ }
+
+ QString profileId = profileManager->updateProfile(*profile);
+ if (profileId.isEmpty()) {
+ qWarning() << "Unable to save sync profile" << templateProfile->name();
+ } else {
+ account->setValue(SyncProfileIdKey(templateProfile->name()), profile->name());
+ }
+
+ account->selectService(prevService);
+ delete profile;
+ delete templateProfile;
+
+ return profileId;
+ }
+}
+
+SocialdButeoPlugin::SocialdButeoPlugin(const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface *callbackInterface,
+ const QString &socialServiceName,
+ const QString &dataTypeName)
+ : ClientPlugin(pluginName, profile, callbackInterface)
+ , m_socialNetworkSyncAdaptor(nullptr)
+ , m_socialServiceName(socialServiceName)
+ , m_dataTypeName(dataTypeName)
+ , m_profileAccountId(0)
+{
+}
+
+SocialdButeoPlugin::~SocialdButeoPlugin()
+{
+}
+
+bool SocialdButeoPlugin::init()
+{
+ m_profileAccountId = profile().key(Buteo::KEY_ACCOUNT_ID).toInt();
+ m_socialNetworkSyncAdaptor = createSocialNetworkSyncAdaptor();
+ if (m_socialNetworkSyncAdaptor) {
+ connect(m_socialNetworkSyncAdaptor, &SocialNetworkSyncAdaptor::statusChanged,
+ this, &SocialdButeoPlugin::syncStatusChanged);
+ return true;
+ }
+
+ return false;
+}
+
+bool SocialdButeoPlugin::uninit()
+{
+ delete m_socialNetworkSyncAdaptor;
+ m_socialNetworkSyncAdaptor = nullptr;
+ return true;
+}
+
+bool SocialdButeoPlugin::startSync()
+{
+ // if the profile being triggered is the template profile, then we
+ // need to ensure that the appropriate per-account profiles exist.
+ if (m_profileAccountId == 0) {
+ QList<Buteo::SyncProfile*> perAccountProfiles = ensurePerAccountSyncProfilesExist();
+ m_socialNetworkSyncAdaptor->setAccountSyncProfile(NULL);
+
+ // we need to trigger sync with each profile separately,
+ // or (due to scheduling/etc) another plugin instance might
+ // be created to sync that profile at the same time, and
+ // we don't handle concurrency.
+ foreach (Buteo::SyncProfile *perAccountProfile, perAccountProfiles) {
+ QDBusMessage message = QDBusMessage::createMethodCall(
+ "com.meego.msyncd", "/synchronizer", "com.meego.msyncd", "startSync");
+ message.setArguments(QVariantList() << perAccountProfile->name());
+ QDBusConnection::sessionBus().asyncCall(message);
+ }
+ } else {
+ m_socialNetworkSyncAdaptor->setAccountSyncProfile(profile().clone());
+ }
+
+ // now perform sync. Note that for the template profile case, this will
+ // result in a purge operation occurring (checking for removed accounts and
+ // purging any synced data associated with those accounts).
+ if (m_socialNetworkSyncAdaptor && m_socialNetworkSyncAdaptor->enabled()) {
+ if (m_socialNetworkSyncAdaptor->status() == SocialNetworkSyncAdaptor::Inactive) {
+ qCDebug(lcSocialPlugin) << "performing sync of" << m_dataTypeName << "from" << m_socialServiceName
+ << "for account" << m_profileAccountId;
+ m_socialNetworkSyncAdaptor->sync(m_dataTypeName, m_profileAccountId);
+ return true;
+ } else {
+ qCDebug(lcSocialPlugin) << m_socialServiceName << "sync adaptor for" << m_dataTypeName
+ << "is still busy with last sync of account" << m_profileAccountId;
+ }
+ } else {
+ qCDebug(lcSocialPlugin) << "no enabled" << m_socialServiceName << "sync adaptor for" << m_dataTypeName;
+ }
+ return false;
+}
+
+void SocialdButeoPlugin::abortSync(Sync::SyncStatus status)
+{
+ // note: it seems buteo automatically calls abortSync on network connectivity loss...
+ qCInfo(lcSocialPlugin) << "aborting sync with status:" << status;
+ m_socialNetworkSyncAdaptor->abortSync(status);
+}
+
+bool SocialdButeoPlugin::cleanUp()
+{
+ m_profileAccountId = profile().key(Buteo::KEY_ACCOUNT_ID).toInt();
+ if (!m_socialNetworkSyncAdaptor) {
+ // might have already been initialized by the OOP framework via init().
+ m_socialNetworkSyncAdaptor = createSocialNetworkSyncAdaptor();
+ }
+
+ if (m_socialNetworkSyncAdaptor && m_profileAccountId > 0) {
+ m_socialNetworkSyncAdaptor->purgeDataForOldAccount(m_profileAccountId,
+ SocialNetworkSyncAdaptor::CleanUpPurge);
+ }
+
+ return true;
+}
+
+Buteo::SyncResults SocialdButeoPlugin::getSyncResults() const
+{
+ return m_syncResults;
+}
+
+void SocialdButeoPlugin::connectivityStateChanged(Sync::ConnectivityType type, bool state)
+{
+ // See TransportTracker.cpp:149 for example
+ // Sync::CONNECTIVITY_INTERNET, true|false
+ qCInfo(lcSocialPlugin) << "notified of connectivity change:" << type << state;
+ if (type == Sync::CONNECTIVITY_INTERNET && state == false) {
+ // we lost connectivity during sync.
+ abortSync(Sync::SYNC_CONNECTION_ERROR);
+ }
+}
+
+void SocialdButeoPlugin::syncStatusChanged()
+{
+ if (m_socialNetworkSyncAdaptor) {
+ SocialNetworkSyncAdaptor::Status syncStatus = m_socialNetworkSyncAdaptor->status();
+ // Busy change comes when sync starts -> let's ignore that.
+ if (syncStatus == SocialNetworkSyncAdaptor::Inactive) {
+ updateResults(Buteo::SyncResults(QDateTime::currentDateTime(),
+ Buteo::SyncResults::SYNC_RESULT_SUCCESS,
+ Buteo::SyncResults::NO_ERROR));
+ emit success(getProfileName(), QString("%1 update succeeded").arg(getProfileName()));
+ } else if (syncStatus != SocialNetworkSyncAdaptor::Busy) {
+ updateResults(Buteo::SyncResults(QDateTime::currentDateTime(),
+ Buteo::SyncResults::SYNC_RESULT_FAILED,
+ Buteo::SyncResults::ABORTED));
+ emit error(getProfileName(), QString("%1 update failed").arg(getProfileName()),
+ Buteo::SyncResults::ABORTED);
+ }
+ } else {
+ updateResults(Buteo::SyncResults(QDateTime::currentDateTime(),
+ Buteo::SyncResults::SYNC_RESULT_FAILED,
+ Buteo::SyncResults::ABORTED));
+ emit error(getProfileName(), QString("%1 update failed").arg(getProfileName()), Buteo::SyncResults::ABORTED);
+ }
+}
+
+void SocialdButeoPlugin::updateResults(const Buteo::SyncResults &results)
+{
+ m_syncResults = results;
+ m_syncResults.setScheduled(true);
+}
+
+// This function is called when the non-per-account profile is triggered.
+// The implementation does:
+// - get all profiles from the ProfileManager
+// - get all accounts from the AccountManager
+// - build a mapping of profile -> account for the current data type. (should be one-to-one for the datatype).
+// - any account which doesn't have a profile, print an error.
+// - check the enabled status of the account -> ensure that the enabled status is reflected in the profile.
+// It then returns a list of the appropriate (per account for this data-type) sync profiles.
+// The caller takes ownership of the list.
+QList<Buteo::SyncProfile*> SocialdButeoPlugin::ensurePerAccountSyncProfilesExist()
+{
+ Accounts::Manager am;
+ Accounts::AccountIdList accountIds = am.accountList();
+ QList<Buteo::SyncProfile*> syncProfiles = m_profileManager.allSyncProfiles();
+ QMap<Accounts::Account*, Buteo::SyncProfile*> perAccountProfiles;
+
+ Accounts::Service dataTypeSyncService = am.service(m_socialNetworkSyncAdaptor->syncServiceName());
+ if (!dataTypeSyncService.isValid()) {
+ qWarning() << Q_FUNC_INFO << "Invalid data type sync service name specified:"
+ << m_socialNetworkSyncAdaptor->syncServiceName();
+ return QList<Buteo::SyncProfile*>();
+ }
+
+ for (int i = 0; i < accountIds.size(); ++i) {
+ Accounts::Account *currAccount = Accounts::Account::fromId(&am, accountIds.at(i), this);
+ if (!currAccount || currAccount->id() == 0
+ || m_socialNetworkSyncAdaptor->syncServiceName().split('-').first() != currAccount->providerName()) {
+ // we only generate per-account sync profiles for accounts which
+ // are provided by the provider which provides our sync service.
+ continue;
+ }
+
+ // for the current account, find the associated sync profile.
+ bool foundProfile = false;
+ for (int j = 0; j < syncProfiles.size(); ++j) {
+ if (syncProfiles[j]->key(Buteo::KEY_ACCOUNT_ID).toInt() == QString::number(currAccount->id()).toInt()
+ && syncProfiles[j]->clientProfile() != NULL
+ && syncProfiles[j]->clientProfile()->name() == profile().clientProfile()->name()) {
+ // we have found the sync profile for this datatype for this account.
+ foundProfile = true;
+ perAccountProfiles.insert(currAccount, syncProfiles.takeAt(j));
+ break;
+ }
+ }
+
+ if (!foundProfile) {
+ // it should have been generated for the account when the account was added.
+ qCInfo(lcSocialPlugin) << "no per-account" << profile().name()
+ << "sync profile exists for account:" << currAccount->id();
+
+ // create the per-account profile... we shouldn't need to do this...
+ QString profileName = createProfile(&m_profileManager, profile().name(), currAccount, dataTypeSyncService, true, QVariantMap());
+ Buteo::SyncProfile *newProfile = m_profileManager.syncProfile(profileName);
+ if (!newProfile) {
+ qCWarning(lcSocialPlugin) << "unable to create per-account" << profile().name()
+ << "sync profile for account:" << currAccount->id();
+ } else {
+ // enable the sync schedule for the profile.
+ Buteo::SyncSchedule schedule = newProfile->syncSchedule();
+ schedule.setScheduleEnabled(true);
+ newProfile->setSyncSchedule(schedule);
+ m_profileManager.updateProfile(*newProfile);
+ // and return the profile in the map.
+ perAccountProfiles.insert(currAccount, newProfile);
+ }
+ }
+ }
+
+ // Every account now has the appropriate sync profile.
+ qDeleteAll(syncProfiles); // these are for the wrong data type, ignore them.
+ QList<Buteo::SyncProfile *> retn;
+ foreach (Accounts::Account *acc, perAccountProfiles.keys()) {
+ retn.append(perAccountProfiles[acc]);
+ acc->deleteLater();
+ }
+
+ return retn;
+}
diff --git a/buteo-plugins/buteo-common/socialdbuteoplugin.h b/buteo-plugins/buteo-common/socialdbuteoplugin.h
new file mode 100644
index 0000000..57de171
--- /dev/null
+++ b/buteo-plugins/buteo-common/socialdbuteoplugin.h
@@ -0,0 +1,75 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2013-2014 Jolla Ltd.
+ ** Contact: Raine Makelainen <raine.makelainen@jollamobile.com>
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#ifndef SOCIALDBUTEOPLUGIN_H
+#define SOCIALDBUTEOPLUGIN_H
+
+#include <QtCore/qglobal.h>
+#include "buteosyncfw_p.h"
+
+/*
+ Datatype-specific implementations of this class
+ allow per-account sync profiles for that data type.
+*/
+
+class SocialNetworkSyncAdaptor;
+class Q_DECL_EXPORT SocialdButeoPlugin : public Buteo::ClientPlugin
+{
+ Q_OBJECT
+
+protected:
+ virtual SocialNetworkSyncAdaptor *createSocialNetworkSyncAdaptor() = 0;
+
+public:
+ SocialdButeoPlugin(const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface *cbInterface,
+ const QString &socialServiceName,
+ const QString &dataTypeName);
+ virtual ~SocialdButeoPlugin();
+
+ bool init() override;
+ bool uninit() override;
+ bool startSync() override;
+ void abortSync(Sync::SyncStatus status = Sync::SYNC_ABORTED) override;
+ Buteo::SyncResults getSyncResults() const override;
+ bool cleanUp() override;
+
+public Q_SLOTS:
+ void connectivityStateChanged(Sync::ConnectivityType type, bool state) override;
+
+private Q_SLOTS:
+ void syncStatusChanged();
+
+protected:
+ QList<Buteo::SyncProfile*> ensurePerAccountSyncProfilesExist();
+
+private:
+ void updateResults(const Buteo::SyncResults &results);
+ Buteo::SyncResults m_syncResults;
+ Buteo::ProfileManager m_profileManager;
+ SocialNetworkSyncAdaptor *m_socialNetworkSyncAdaptor;
+ QString m_socialServiceName;
+ QString m_dataTypeName;
+ int m_profileAccountId;
+};
+
+#endif // SOCIALDBUTEOPLUGIN_H
diff --git a/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.cpp b/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.cpp
new file mode 100644
index 0000000..7030ce3
--- /dev/null
+++ b/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.cpp
@@ -0,0 +1,37 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2013-2014 Jolla Ltd.
+ ** Contact: Chris Adams <chris.adams@jollamobile.com>
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#include "socialdnetworkaccessmanager_p.h"
+
+/* The default implementation is just a normal QNetworkAccessManager */
+
+SocialdNetworkAccessManager::SocialdNetworkAccessManager(QObject *parent)
+ : QNetworkAccessManager(parent)
+{
+}
+
+QNetworkReply *SocialdNetworkAccessManager::createRequest(
+ QNetworkAccessManager::Operation op,
+ const QNetworkRequest &req,
+ QIODevice *outgoingData)
+{
+ return QNetworkAccessManager::createRequest(op, req, outgoingData);
+}
diff --git a/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.h b/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.h
new file mode 100644
index 0000000..846489b
--- /dev/null
+++ b/buteo-plugins/buteo-common/socialdnetworkaccessmanager_p.h
@@ -0,0 +1,40 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2013-2014 Jolla Ltd.
+ ** Contact: Chris Adams <chris.adams@jollamobile.com>
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#ifndef SOCIALD_QNAMFACTORY_P_H
+#define SOCIALD_QNAMFACTORY_P_H
+
+#include <QNetworkAccessManager>
+
+class SocialdNetworkAccessManager : public QNetworkAccessManager
+{
+ Q_OBJECT
+
+public:
+ SocialdNetworkAccessManager(QObject *parent = 0);
+
+protected:
+ QNetworkReply *createRequest(QNetworkAccessManager::Operation op,
+ const QNetworkRequest &req,
+ QIODevice *outgoingData = 0) override;
+};
+
+#endif
diff --git a/buteo-plugins/buteo-common/socialnetworksyncadaptor.cpp b/buteo-plugins/buteo-common/socialnetworksyncadaptor.cpp
new file mode 100644
index 0000000..4451d5f
--- /dev/null
+++ b/buteo-plugins/buteo-common/socialnetworksyncadaptor.cpp
@@ -0,0 +1,470 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2013-2014 Jolla Ltd.
+ ** Contact: Chris Adams <chris.adams@jollamobile.com>
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#include "socialnetworksyncadaptor.h"
+#include "socialdnetworkaccessmanager_p.h"
+#include "trace.h"
+
+#include <QtCore/QJsonDocument>
+#include <QtCore/QTimer>
+#include <QtSql/QSqlDatabase>
+#include <QtSql/QSqlQuery>
+#include <QtSql/QSqlError>
+#include <QtSql/QSqlRecord>
+
+#include <QtNetwork/QNetworkAccessManager>
+#include <QtNetwork/QNetworkReply>
+
+#include "buteosyncfw_p.h"
+
+// libaccounts-qt5
+#include <Accounts/Manager>
+#include <Accounts/Account>
+#include <Accounts/Service>
+
+// libsocialcache
+#include <socialcache/socialimagesdatabase.h>
+#include <socialcache/socialnetworksyncdatabase.h>
+
+namespace {
+ QStringList validDataTypesInitialiser()
+ {
+ return QStringList()
+ << QStringLiteral("Contacts")
+ << QStringLiteral("Calendars")
+ << QStringLiteral("Notifications")
+ << QStringLiteral("Images")
+ << QStringLiteral("Videos")
+ << QStringLiteral("Posts")
+ << QStringLiteral("Messages")
+ << QStringLiteral("Emails")
+ << QStringLiteral("Signon")
+ << QStringLiteral("Backup")
+ << QStringLiteral("BackupQuery")
+ << QStringLiteral("BackupRestore");
+ }
+}
+
+SocialNetworkSyncAdaptor::SocialNetworkSyncAdaptor(const QString &serviceName,
+ SocialNetworkSyncAdaptor::DataType dataType,
+ QNetworkAccessManager *qnam,
+ QObject *parent)
+ : QObject(parent)
+ , m_dataType(dataType)
+ , m_accountManager(new Accounts::Manager(this))
+ , m_networkAccessManager(qnam != 0 ? qnam : new SocialdNetworkAccessManager)
+ , m_accountSyncProfile(NULL)
+ , m_syncDb(new SocialNetworkSyncDatabase())
+ , m_status(SocialNetworkSyncAdaptor::Invalid)
+ , m_enabled(false)
+ , m_syncAborted(false)
+ , m_serviceName(serviceName)
+{
+}
+
+SocialNetworkSyncAdaptor::~SocialNetworkSyncAdaptor()
+{
+ delete m_networkAccessManager;
+ delete m_accountSyncProfile;
+ delete m_syncDb;
+}
+
+// The SocialNetworkSyncAdaptor takes ownership of the sync profiles.
+void SocialNetworkSyncAdaptor::setAccountSyncProfile(Buteo::SyncProfile* perAccountSyncProfile)
+{
+ delete m_accountSyncProfile;
+ m_accountSyncProfile = perAccountSyncProfile;
+}
+
+SocialNetworkSyncAdaptor::Status SocialNetworkSyncAdaptor::status() const
+{
+ return m_status;
+}
+
+bool SocialNetworkSyncAdaptor::enabled() const
+{
+ return m_enabled;
+}
+
+QString SocialNetworkSyncAdaptor::serviceName() const
+{
+ return m_serviceName;
+}
+
+bool SocialNetworkSyncAdaptor::syncAborted() const
+{
+ return m_syncAborted;
+}
+
+void SocialNetworkSyncAdaptor::sync(const QString &dataType, int accountId)
+{
+ Q_UNUSED(dataType)
+ Q_UNUSED(accountId)
+ qCWarning(lcSocialPlugin) << "sync() must be overridden by derived types";
+}
+
+void SocialNetworkSyncAdaptor::abortSync(Sync::SyncStatus status)
+{
+ qCInfo(lcSocialPlugin) << "forcing timeout of outstanding replies due to abort:" << status;
+ m_syncAborted = true;
+ triggerReplyTimeouts();
+}
+
+/*!
+ * \brief SocialNetworkSyncAdaptor::checkAccount
+ * \param account
+ * \return true if synchronization of this adaptor's datatype is enabled for the account
+ *
+ * The default implementation checks that the account is enabled
+ * with the accounts&sso service associated with this sync adaptor.
+ */
+bool SocialNetworkSyncAdaptor::checkAccount(Accounts::Account *account)
+{
+ bool globallyEnabled = account->enabled();
+ Accounts::Service srv(m_accountManager->service(syncServiceName()));
+ if (!srv.isValid()) {
+ qCInfo(lcSocialPlugin) << "invalid service" << syncServiceName() << "specified, account" << account->id()
+ << "will be disabled for" << m_serviceName << dataTypeName(m_dataType) << "sync";
+ return false;
+ }
+ account->selectService(srv);
+ bool serviceEnabled = account->enabled();
+ account->selectService(Accounts::Service());
+ return globallyEnabled && serviceEnabled;
+}
+
+/*!
+ \internal
+ Called when the semaphores for all accounts have been decreased
+ to zero. This is the final function which is called prior to
+ telling buteo that the sync plugin can be destroyed.
+ The implementation MUST be synchronous.
+*/
+void SocialNetworkSyncAdaptor::finalCleanup()
+{
+}
+
+/*!
+ \internal
+ Called when the semaphores decreased to 0, this method is used
+ to finalize something, like saving all data to a database.
+
+ You can call incrementSemaphore to perform asynchronous tasks
+ in this method. finalize will then be called again when the
+ asynchronous task is finished (and when decrementSemaphore is
+ called), be sure to have a condition check in order not to run
+ into an infinite loop.
+
+ It is unsafe to call decrementSemaphore in this method, as
+ the semaphore handling method will find that the semaphore
+ went to 0 twice and will perform cleanup operations twice.
+ Please call decrementSemaphore at the end of the asynchronous
+ task (preferably in a slot), and only call incrementSemaphore
+ for asynchronous tasks.
+ */
+void SocialNetworkSyncAdaptor::finalize(int accountId)
+{
+ Q_UNUSED(accountId)
+}
+
+/*!
+ \internal
+ Returns the last sync timestamp for the given service, account and data type.
+ If data from prior to this timestamp is received in subsequent requests, it does not need to be synced.
+ This function will return an invalid QDateTime if no synchronisation has occurred.
+*/
+QDateTime SocialNetworkSyncAdaptor::lastSyncTimestamp(const QString &serviceName,
+ const QString &dataType,
+ int accountId) const
+{
+ return m_syncDb->lastSyncTimestamp(serviceName, dataType, accountId);
+}
+
+/*!
+ \internal
+ Updates the last sync timestamp for the given service, account and data type to the given \a timestamp.
+*/
+bool SocialNetworkSyncAdaptor::updateLastSyncTimestamp(const QString &serviceName,
+ const QString &dataType,
+ int accountId,
+ const QDateTime &timestamp)
+{
+ // Workaround
+ // TODO: do better, with a queue
+ m_syncDb->addSyncTimestamp(serviceName, dataType, accountId, timestamp);
+ m_syncDb->commit();
+ m_syncDb->wait();
+ return m_syncDb->writeStatus() == AbstractSocialCacheDatabase::Finished;
+}
+
+/*!
+ \internal
+ Returns the list of identifiers of accounts which have been synced for
+ the given \a dataType.
+*/
+QList<int> SocialNetworkSyncAdaptor::syncedAccounts(const QString &dataType)
+{
+ return m_syncDb->syncedAccounts(m_serviceName, dataType);
+}
+
+/*!
+ * \internal
+ * Changes status if there is real change and emits statusChanged() signal.
+ */
+void SocialNetworkSyncAdaptor::setStatus(Status status)
+{
+ if (m_status != status) {
+ m_status = status;
+ emit statusChanged();
+ }
+}
+
+/*!
+ * \internal
+ * Should be used in constructors to set the initial state
+ * of enabled and status, without emitting signals
+ *
+ */
+void SocialNetworkSyncAdaptor::setInitialActive(bool enabled)
+{
+ m_enabled = enabled;
+ if (enabled) {
+ m_status = Inactive;
+ } else {
+ m_status = Invalid;
+ }
+}
+
+/*!
+ * \internal
+ * Should be called by any specific sync adapter when
+ * they've finished syncing data. The transition from
+ * busy status to inactive status is what causes the
+ * Buteo plugin to emit the sync results (and allows
+ * subsequent syncs to occur).
+ */
+void SocialNetworkSyncAdaptor::setFinishedInactive()
+{
+ finalCleanup();
+ qCInfo(lcSocialPlugin) << "Finished" << m_serviceName << SocialNetworkSyncAdaptor::dataTypeName(m_dataType)
+ << "sync at:" << QDateTime::currentDateTime().toString(Qt::ISODate);
+ setStatus(SocialNetworkSyncAdaptor::Inactive);
+}
+
+void SocialNetworkSyncAdaptor::incrementSemaphore(int accountId)
+{
+ int semaphoreValue = m_accountSyncSemaphores.value(accountId);
+ semaphoreValue += 1;
+ m_accountSyncSemaphores.insert(accountId, semaphoreValue);
+ qCDebug(lcSocialPlugin) << "incremented busy semaphore for account" << accountId << "to:" << semaphoreValue;
+}
+
+void SocialNetworkSyncAdaptor::decrementSemaphore(int accountId)
+{
+ if (!m_accountSyncSemaphores.contains(accountId)) {
+ qCWarning(lcSocialPlugin) << "no such semaphore for account" << accountId;
+ return;
+ }
+
+ int semaphoreValue = m_accountSyncSemaphores.value(accountId);
+ semaphoreValue -= 1;
+ qCDebug(lcSocialPlugin) << "decremented busy semaphore for account" << accountId << "to:" << semaphoreValue;
+ if (semaphoreValue < 0) {
+ qCWarning(lcSocialPlugin) << "busy semaphore is negative for account" << accountId;
+ return;
+ }
+ m_accountSyncSemaphores.insert(accountId, semaphoreValue);
+
+ if (semaphoreValue == 0) {
+ finalize(accountId);
+
+ // With the newer implementation, in finalize we can raise semaphores,
+ // so if after calling finalize, the semaphore count is not the same anymore,
+ // we shouldn't update the sync timestamp
+ if (m_accountSyncSemaphores.value(accountId) > 0) {
+ return;
+ }
+
+ // finished all outstanding sync requests for this account.
+ // update the sync time in the global sociald database.
+ updateLastSyncTimestamp(m_serviceName,
+ SocialNetworkSyncAdaptor::dataTypeName(m_dataType), accountId,
+ QDateTime::currentDateTime().toTimeSpec(Qt::UTC));
+
+ // if all outstanding requests for all accounts have finished,
+ // then update our status to Inactive / ready to handle more sync requests.
+ bool allAreZero = true;
+ QList<int> semaphores = m_accountSyncSemaphores.values();
+ foreach (int sv, semaphores) {
+ if (sv != 0) {
+ allAreZero = false;
+ break;
+ }
+ }
+
+ if (allAreZero) {
+ setFinishedInactive(); // Finished!
+ }
+ }
+}
+
+void SocialNetworkSyncAdaptor::timeoutReply()
+{
+ QTimer *timer = qobject_cast<QTimer*>(sender());
+ QNetworkReply *reply = timer->property("networkReply").value<QNetworkReply*>();
+ int accountId = timer->property("accountId").toInt();
+
+ qCWarning(lcSocialPlugin) << "network request timed out while performing sync with account" << accountId;
+
+ m_networkReplyTimeouts[accountId].remove(reply);
+ reply->setProperty("isError", QVariant::fromValue<bool>(true));
+ reply->finished(); // invoke finished, so that the error handling there decrements the semaphore etc.
+ reply->disconnect();
+}
+
+void SocialNetworkSyncAdaptor::setupReplyTimeout(int accountId, QNetworkReply *reply, int msecs)
+{
+ // this function should be called whenever a new network request is performed.
+ QTimer *timer = new QTimer(this);
+ timer->setSingleShot(true);
+ timer->setInterval(msecs);
+ timer->setProperty("accountId", accountId);
+ timer->setProperty("networkReply", QVariant::fromValue<QNetworkReply*>(reply));
+ connect(timer, &QTimer::timeout, this, &SocialNetworkSyncAdaptor::timeoutReply);
+ timer->start();
+ m_networkReplyTimeouts[accountId].insert(reply, timer);
+}
+
+void SocialNetworkSyncAdaptor::removeReplyTimeout(int accountId, QNetworkReply *reply)
+{
+ // this function should be called by the finished() handler for the reply.
+ QTimer *timer = m_networkReplyTimeouts[accountId].value(reply);
+ if (!reply) {
+ return;
+ }
+
+ delete timer;
+ m_networkReplyTimeouts[accountId].remove(reply);
+}
+
+void SocialNetworkSyncAdaptor::triggerReplyTimeouts()
+{
+ // if we've lost network connectivity, we should immediately timeout all replies.
+ Q_FOREACH (int accountId, m_networkReplyTimeouts.keys()) {
+ Q_FOREACH (QTimer *timer, m_networkReplyTimeouts[accountId]) {
+ timer->stop();
+ timer->setInterval(1);
+ timer->start();
+ }
+ }
+}
+
+QJsonObject SocialNetworkSyncAdaptor::parseJsonObjectReplyData(const QByteArray &replyData, bool *ok)
+{
+ QJsonDocument jsonDocument = QJsonDocument::fromJson(replyData);
+ *ok = !jsonDocument.isEmpty();
+ if (*ok && jsonDocument.isObject()) {
+ return jsonDocument.object();
+ }
+ *ok = false;
+ return QJsonObject();
+}
+
+QJsonArray SocialNetworkSyncAdaptor::parseJsonArrayReplyData(const QByteArray &replyData, bool *ok)
+{
+ QJsonDocument jsonDocument = QJsonDocument::fromJson(replyData);
+ *ok = !jsonDocument.isEmpty();
+ if (*ok && jsonDocument.isArray()) {
+ return jsonDocument.array();
+ }
+ *ok = false;
+ return QJsonArray();
+}
+
+/*
+ Valid data types are data types which are known to the API.
+ Note that just because a data type is valid does not mean
+ that it will necessarily be supported by a given social network
+ sync adaptor.
+*/
+QStringList SocialNetworkSyncAdaptor::validDataTypes()
+{
+ static QStringList retn(validDataTypesInitialiser());
+ return retn;
+}
+
+/*
+ String for Enum since the DBus API uses strings
+*/
+QString SocialNetworkSyncAdaptor::dataTypeName(SocialNetworkSyncAdaptor::DataType t)
+{
+ switch (t) {
+ case SocialNetworkSyncAdaptor::Contacts: return QStringLiteral("Contacts");
+ case SocialNetworkSyncAdaptor::Calendars: return QStringLiteral("Calendars");
+ case SocialNetworkSyncAdaptor::Notifications: return QStringLiteral("Notifications");
+ case SocialNetworkSyncAdaptor::Images: return QStringLiteral("Images");
+ case SocialNetworkSyncAdaptor::Videos: return QStringLiteral("Videos");
+ case SocialNetworkSyncAdaptor::Posts: return QStringLiteral("Posts");
+ case SocialNetworkSyncAdaptor::Messages: return QStringLiteral("Messages");
+ case SocialNetworkSyncAdaptor::Emails: return QStringLiteral("Emails");
+ case SocialNetworkSyncAdaptor::Signon: return QStringLiteral("Signon");
+ case SocialNetworkSyncAdaptor::Backup: return QStringLiteral("Backup");
+ case SocialNetworkSyncAdaptor::BackupQuery: return QStringLiteral("BackupQuery");
+ case SocialNetworkSyncAdaptor::BackupRestore: return QStringLiteral("BackupRestore");
+ default: break;
+ }
+
+ return QString();
+}
+
+void SocialNetworkSyncAdaptor::purgeCachedImages(SocialImagesDatabase *database,
+ int accountId)
+{
+ database->queryImages(accountId);
+ database->wait();
+
+ QList<SocialImage::ConstPtr> images = database->images();
+ foreach (SocialImage::ConstPtr image, images) {
+ qCDebug(lcSocialPlugin) << "Purge cached image " << image->imageFile() << " for account " << image->accountId();
+ QFile::remove(image->imageFile());
+ }
+
+ database->removeImages(images);
+ database->commit();
+ database->wait();
+}
+
+void SocialNetworkSyncAdaptor::purgeExpiredImages(SocialImagesDatabase *database,
+ int accountId)
+{
+ database->queryExpired(accountId);
+ database->wait();
+
+ QList<SocialImage::ConstPtr> images = database->images();
+ foreach (SocialImage::ConstPtr image, images) {
+ qCDebug(lcSocialPlugin) << "Purge expired image " << image->imageFile() << " for account " << image->accountId();
+ QFile::remove(image->imageFile());
+ }
+
+ database->removeImages(images);
+ database->commit();
+ database->wait();
+}
diff --git a/buteo-plugins/buteo-common/socialnetworksyncadaptor.h b/buteo-plugins/buteo-common/socialnetworksyncadaptor.h
new file mode 100644
index 0000000..99adeb3
--- /dev/null
+++ b/buteo-plugins/buteo-common/socialnetworksyncadaptor.h
@@ -0,0 +1,155 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2013-2014 Jolla Ltd.
+ ** Contact: Chris Adams <chris.adams@jollamobile.com>
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#ifndef SOCIALNETWORKSYNCADAPTOR_H
+#define SOCIALNETWORKSYNCADAPTOR_H
+
+#include <QtCore/QObject>
+#include <QtCore/QDateTime>
+#include <QtCore/QString>
+#include <QtCore/QJsonObject>
+#include <QtCore/QJsonArray>
+#include <QtCore/QMap>
+#include <QtCore/QList>
+
+#include "buteosyncfw_p.h"
+
+class QSqlDatabase;
+class QNetworkAccessManager;
+class QTimer;
+class QNetworkReply;
+class SocialNetworkSyncDatabase;
+class SocialImagesDatabase;
+
+namespace Accounts {
+ class Account;
+ class Manager;
+}
+
+class SocialNetworkSyncAdaptor : public QObject
+{
+ Q_OBJECT
+ Q_PROPERTY(Status status READ status NOTIFY statusChanged)
+ Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged)
+
+public:
+ enum Status {
+ Initializing = 0,
+ Inactive,
+ Busy,
+ Error,
+ Invalid
+ };
+
+ enum PurgeMode {
+ SyncPurge = 0,
+ CleanUpPurge
+ };
+
+ enum DataType {
+ Contacts = 1, // "Contacts"
+ Calendars, // "Calendars"
+ Notifications, // "Notifications"
+ Images, // "Images"
+ Videos, // "Videos"
+ Posts, // "Posts"
+ Messages, // "Messages"
+ Emails, // "Emails"
+ Signon, // "Signon" -- for refreshing AccessTokens etc.
+ Backup, // "Backup"
+ BackupQuery, // "BackupQuery"
+ BackupRestore // "BackupRestore"
+ };
+ static QStringList validDataTypes();
+ static QString dataTypeName(DataType t);
+
+public:
+ SocialNetworkSyncAdaptor(const QString &serviceName, SocialNetworkSyncAdaptor::DataType dataType,
+ QNetworkAccessManager *qnam, QObject *parent);
+ virtual ~SocialNetworkSyncAdaptor();
+
+ virtual QString syncServiceName() const = 0;
+ void setAccountSyncProfile(Buteo::SyncProfile* perAccountSyncProfile);
+
+ Status status() const;
+ bool enabled() const;
+ QString serviceName() const;
+
+ virtual void sync(const QString &dataType, int accountId = 0);
+ virtual void purgeDataForOldAccount(int accountId, PurgeMode mode = SyncPurge) = 0;
+ virtual void abortSync(Sync::SyncStatus status);
+
+Q_SIGNALS:
+ void statusChanged();
+ void enabledChanged();
+
+protected:
+ virtual bool checkAccount(Accounts::Account *account);
+ virtual void finalCleanup();
+ virtual void finalize(int accountId);
+ QDateTime lastSyncTimestamp(const QString &serviceName, const QString &dataType,
+ int accountId) const;
+ bool updateLastSyncTimestamp(const QString &serviceName, const QString &dataType,
+ int accountId, const QDateTime &timestamp);
+ QList<int> syncedAccounts(const QString &dataType);
+ void setStatus(Status status);
+ void setInitialActive(bool enabled);
+ void setFinishedInactive();
+
+ // whether the sync has been aborted (perhaps due to network connection loss)
+ bool syncAborted() const;
+
+ // Semaphore system
+ void incrementSemaphore(int accountId);
+ void decrementSemaphore(int accountId);
+
+ // network reply timeouts
+ void setupReplyTimeout(int accountId, QNetworkReply *reply, int msecs = 60000);
+ void removeReplyTimeout(int accountId, QNetworkReply *reply);
+ void triggerReplyTimeouts();
+
+ // Parsing methods
+ static QJsonObject parseJsonObjectReplyData(const QByteArray &replyData, bool *ok);
+ static QJsonArray parseJsonArrayReplyData(const QByteArray &replyData, bool *ok);
+
+ // Cache management
+ void purgeCachedImages(SocialImagesDatabase *database, int accountId);
+ void purgeExpiredImages(SocialImagesDatabase *database, int accountId);
+
+ const SocialNetworkSyncAdaptor::DataType m_dataType;
+ Accounts::Manager * const m_accountManager;
+ QNetworkAccessManager * const m_networkAccessManager;
+ Buteo::SyncProfile *m_accountSyncProfile;
+
+protected Q_SLOTS:
+ virtual void timeoutReply();
+
+private:
+ SocialNetworkSyncDatabase *m_syncDb;
+ SocialNetworkSyncAdaptor::Status m_status;
+ bool m_enabled;
+ bool m_syncAborted;
+ QString m_serviceName;
+ QMap<int, int> m_accountSyncSemaphores;
+ QMap<int, QMap<QNetworkReply*, QTimer *> > m_networkReplyTimeouts;
+};
+
+#endif // SOCIALNETWORKSYNCADAPTOR_H
diff --git a/buteo-plugins/buteo-common/trace.cpp b/buteo-plugins/buteo-common/trace.cpp
new file mode 100644
index 0000000..ffcdf5b
--- /dev/null
+++ b/buteo-plugins/buteo-common/trace.cpp
@@ -0,0 +1,25 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2021 Jolla Ltd.
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#include "trace.h"
+
+Q_LOGGING_CATEGORY(lcSocialPlugin, "buteo.plugin.social", QtWarningMsg)
+Q_LOGGING_CATEGORY(lcSocialPluginTrace, "buteo.plugin.social.trace", QtWarningMsg)
+
diff --git a/buteo-plugins/buteo-common/trace.h b/buteo-plugins/buteo-common/trace.h
new file mode 100644
index 0000000..b0aeeeb
--- /dev/null
+++ b/buteo-plugins/buteo-common/trace.h
@@ -0,0 +1,30 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2013-2014 Jolla Ltd.
+ ** Contact: Chris Adams <chris.adams@jollamobile.com>
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#ifndef TRACE_H
+#define TRACE_H
+
+#include <QLoggingCategory>
+
+Q_DECLARE_LOGGING_CATEGORY(lcSocialPlugin)
+Q_DECLARE_LOGGING_CATEGORY(lcSocialPluginTrace)
+
+#endif // TRACE_H
diff --git a/buteo-plugins/buteo-plugins.pro b/buteo-plugins/buteo-plugins.pro
new file mode 100644
index 0000000..5a48850
--- /dev/null
+++ b/buteo-plugins/buteo-plugins.pro
@@ -0,0 +1,6 @@
+TEMPLATE = subdirs
+SUBDIRS += \
+ buteo-common \
+ buteo-sync-plugin-mastodon-posts
+
+buteo-sync-plugin-mastodon-posts.depends = buteo-common
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro b/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro
new file mode 100644
index 0000000..86387b2
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro
@@ -0,0 +1,37 @@
+TARGET = mastodon-posts-client
+
+QT -= gui
+
+include($$PWD/../buteo-common/buteo-common.pri)
+include($$PWD/../../common/common.pri)
+
+CONFIG += link_pkgconfig
+PKGCONFIG += mlite5 nemonotifications-qt5
+
+INCLUDEPATH += $$PWD
+
+SOURCES += \
+ $$PWD/mastodondatatypesyncadaptor.cpp \
+ $$PWD/mastodonpostsplugin.cpp \
+ $$PWD/mastodonpostssyncadaptor.cpp
+
+HEADERS += \
+ $$PWD/mastodondatatypesyncadaptor.h \
+ $$PWD/mastodonpostsplugin.h \
+ $$PWD/mastodonpostssyncadaptor.h
+
+OTHER_FILES += \
+ $$PWD/mastodon-posts.xml \
+ $$PWD/mastodon.Posts.xml
+
+TEMPLATE = lib
+CONFIG += plugin
+target.path = $$[QT_INSTALL_LIBS]/buteo-plugins-qt5/oopp
+
+sync.path = /etc/buteo/profiles/sync
+sync.files = mastodon.Posts.xml
+
+client.path = /etc/buteo/profiles/client
+client.files = mastodon-posts.xml
+
+INSTALLS += target sync client
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml
new file mode 100644
index 0000000..7f1fec2
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<profile name="mastodon-posts" type="client" >
+ <field name="Sync Direction" />
+</profile>
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml
new file mode 100644
index 0000000..44183a6
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<profile name="mastodon.Posts" type="sync" >
+ <key name="category" value="eventfeed" />
+ <key name="enabled" value="false" />
+ <key name="use_accounts" value="false" />
+ <key name="destinationtype" value="online" />
+ <key name="hidden" value="true" />
+ <key name="displayname" value="Mastodon Posts"/>
+
+ <schedule enabled="false" interval="30" days="1,2,3,4,5,6,7" syncconfiguredtime="" time="" />
+
+ <profile name="mastodon-posts" type="client" >
+ <key name="Sync Direction" value="from-remote" />
+ </profile>
+</profile>
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp
new file mode 100644
index 0000000..977b33b
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp
@@ -0,0 +1,313 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2026 Open Mobile Platform LLC.
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#include "mastodondatatypesyncadaptor.h"
+#include "trace.h"
+
+#include <QtCore/QVariantMap>
+#include <QtCore/QUrl>
+#include <QtNetwork/QNetworkRequest>
+
+// libaccounts-qt5
+#include <Accounts/Manager>
+#include <Accounts/Account>
+#include <Accounts/Service>
+#include <Accounts/AccountService>
+
+// libsignon-qt5
+#include <SignOn/Identity>
+#include <SignOn/AuthSession>
+#include <SignOn/SessionData>
+
+MastodonDataTypeSyncAdaptor::MastodonDataTypeSyncAdaptor(
+ SocialNetworkSyncAdaptor::DataType dataType,
+ QObject *parent)
+ : SocialNetworkSyncAdaptor(QStringLiteral("mastodon"), dataType, 0, parent)
+{
+}
+
+MastodonDataTypeSyncAdaptor::~MastodonDataTypeSyncAdaptor()
+{
+}
+
+void MastodonDataTypeSyncAdaptor::sync(const QString &dataTypeString, int accountId)
+{
+ if (dataTypeString != SocialNetworkSyncAdaptor::dataTypeName(m_dataType)) {
+ qCWarning(lcSocialPlugin) << "Mastodon" << SocialNetworkSyncAdaptor::dataTypeName(m_dataType)
+ << "sync adaptor was asked to sync" << dataTypeString;
+ setStatus(SocialNetworkSyncAdaptor::Error);
+ return;
+ }
+
+ setStatus(SocialNetworkSyncAdaptor::Busy);
+ updateDataForAccount(accountId);
+ qCDebug(lcSocialPlugin) << "successfully triggered sync with profile:" << m_accountSyncProfile->name();
+}
+
+void MastodonDataTypeSyncAdaptor::updateDataForAccount(int accountId)
+{
+ Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this);
+ if (!account) {
+ qCWarning(lcSocialPlugin) << "existing account with id" << accountId << "couldn't be retrieved";
+ setStatus(SocialNetworkSyncAdaptor::Error);
+ return;
+ }
+
+ incrementSemaphore(accountId);
+ signIn(account);
+}
+
+QString MastodonDataTypeSyncAdaptor::apiHost(int accountId) const
+{
+ return m_apiHosts.value(accountId, QStringLiteral("https://mastodon.social"));
+}
+
+void MastodonDataTypeSyncAdaptor::errorHandler(QNetworkReply::NetworkError err)
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
+ if (!reply) {
+ return;
+ }
+
+ const int accountId = reply->property("accountId").toInt();
+ const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+
+ qCWarning(lcSocialPlugin) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType)
+ << "request with account" << accountId
+ << "experienced error:" << err
+ << "HTTP:" << httpStatus;
+
+ reply->setProperty("isError", QVariant::fromValue<bool>(true));
+
+ if (httpStatus == 401 || err == QNetworkReply::AuthenticationRequiredError) {
+ Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this);
+ if (account) {
+ setCredentialsNeedUpdate(account);
+ }
+ }
+}
+
+void MastodonDataTypeSyncAdaptor::sslErrorsHandler(const QList<QSslError> &errs)
+{
+ QString sslerrs;
+ foreach (const QSslError &e, errs) {
+ sslerrs += e.errorString() + QLatin1String("; ");
+ }
+ if (!sslerrs.isEmpty()) {
+ sslerrs.chop(2);
+ }
+
+ qCWarning(lcSocialPlugin) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType)
+ << "request with account" << sender()->property("accountId").toInt()
+ << "experienced ssl errors:" << sslerrs;
+ sender()->setProperty("isError", QVariant::fromValue<bool>(true));
+}
+
+void MastodonDataTypeSyncAdaptor::setCredentialsNeedUpdate(Accounts::Account *account)
+{
+ qCInfo(lcSocialPlugin) << "sociald:Mastodon: setting CredentialsNeedUpdate to true for account:" << account->id();
+ Accounts::Service srv(m_accountManager->service(syncServiceName()));
+ account->selectService(srv);
+ account->setValue(QStringLiteral("CredentialsNeedUpdate"), QVariant::fromValue<bool>(true));
+ account->setValue(QStringLiteral("CredentialsNeedUpdateFrom"), QVariant::fromValue<QString>(QString::fromLatin1("sociald-mastodon")));
+ account->selectService(Accounts::Service());
+ account->syncAndBlock();
+}
+
+QString MastodonDataTypeSyncAdaptor::normalizeApiHost(const QString &rawHost)
+{
+ QString host = rawHost.trimmed();
+ if (host.isEmpty()) {
+ host = QStringLiteral("https://mastodon.social");
+ }
+ if (!host.startsWith(QLatin1String("https://"))
+ && !host.startsWith(QLatin1String("http://"))) {
+ host.prepend(QStringLiteral("https://"));
+ }
+
+ QUrl url(host);
+ if (!url.isValid() || url.host().isEmpty()) {
+ return QStringLiteral("https://mastodon.social");
+ }
+
+ QString normalized = QString::fromLatin1(url.toEncoded(QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment));
+ if (normalized.endsWith(QLatin1Char('/'))) {
+ normalized.chop(1);
+ }
+ return normalized;
+}
+
+void MastodonDataTypeSyncAdaptor::signIn(Accounts::Account *account)
+{
+ const int accountId = account->id();
+ if (!checkAccount(account)) {
+ decrementSemaphore(accountId);
+ return;
+ }
+
+ Accounts::Service srv(m_accountManager->service(syncServiceName()));
+ account->selectService(srv);
+ SignOn::Identity *identity = account->credentialsId() > 0
+ ? SignOn::Identity::existingIdentity(account->credentialsId())
+ : 0;
+ if (!identity) {
+ qCWarning(lcSocialPlugin) << "account" << accountId << "has no valid credentials, cannot sign in";
+ decrementSemaphore(accountId);
+ return;
+ }
+
+ Accounts::AccountService accSrv(account, srv);
+ const QString method = accSrv.authData().method();
+ const QString mechanism = accSrv.authData().mechanism();
+ SignOn::AuthSession *session = identity->createSession(method);
+ if (!session) {
+ qCWarning(lcSocialPlugin) << "could not create signon session for account" << accountId;
+ identity->deleteLater();
+ decrementSemaphore(accountId);
+ return;
+ }
+
+ QVariantMap signonSessionData = accSrv.authData().parameters();
+ QString configuredHost = account->value(QStringLiteral("auth/oauth2/web_server/Host")).toString().trimmed();
+ if (configuredHost.isEmpty()) {
+ configuredHost = normalizeApiHost(account->value(QStringLiteral("api/Host")).toString());
+ }
+ if (configuredHost.startsWith(QLatin1String("https://"))) {
+ configuredHost.remove(0, 8);
+ } else if (configuredHost.startsWith(QLatin1String("http://"))) {
+ configuredHost.remove(0, 7);
+ }
+ while (configuredHost.endsWith(QLatin1Char('/'))) {
+ configuredHost.chop(1);
+ }
+ if (configuredHost.isEmpty()) {
+ configuredHost = QStringLiteral("mastodon.social");
+ }
+ signonSessionData.insert(QStringLiteral("Host"), configuredHost);
+
+ const QString authPath = account->value(QStringLiteral("auth/oauth2/web_server/AuthPath")).toString().trimmed();
+ if (!authPath.isEmpty()) {
+ signonSessionData.insert(QStringLiteral("AuthPath"), authPath);
+ }
+
+ const QString tokenPath = account->value(QStringLiteral("auth/oauth2/web_server/TokenPath")).toString().trimmed();
+ if (!tokenPath.isEmpty()) {
+ signonSessionData.insert(QStringLiteral("TokenPath"), tokenPath);
+ }
+
+ const QString responseType = account->value(QStringLiteral("auth/oauth2/web_server/ResponseType")).toString().trimmed();
+ if (!responseType.isEmpty()) {
+ signonSessionData.insert(QStringLiteral("ResponseType"), responseType);
+ }
+
+ const QString redirectUri = account->value(QStringLiteral("auth/oauth2/web_server/RedirectUri")).toString().trimmed();
+ if (!redirectUri.isEmpty()) {
+ signonSessionData.insert(QStringLiteral("RedirectUri"), redirectUri);
+ }
+
+ const QVariant scopeValue = account->value(QStringLiteral("auth/oauth2/web_server/Scope"));
+ if (scopeValue.isValid()) {
+ signonSessionData.insert(QStringLiteral("Scope"), scopeValue);
+ }
+
+ const QString clientId = account->value(QStringLiteral("auth/oauth2/web_server/ClientId")).toString().trimmed();
+ if (!clientId.isEmpty()) {
+ signonSessionData.insert(QStringLiteral("ClientId"), clientId);
+ }
+
+ const QString clientSecret = account->value(QStringLiteral("auth/oauth2/web_server/ClientSecret")).toString().trimmed();
+ if (!clientSecret.isEmpty()) {
+ signonSessionData.insert(QStringLiteral("ClientSecret"), clientSecret);
+ }
+
+ signonSessionData.insert(QStringLiteral("UiPolicy"), SignOn::NoUserInteractionPolicy);
+
+ connect(session, SIGNAL(response(SignOn::SessionData)),
+ this, SLOT(signOnResponse(SignOn::SessionData)),
+ Qt::UniqueConnection);
+ connect(session, SIGNAL(error(SignOn::Error)),
+ this, SLOT(signOnError(SignOn::Error)),
+ Qt::UniqueConnection);
+
+ session->setProperty("account", QVariant::fromValue<Accounts::Account*>(account));
+ session->setProperty("identity", QVariant::fromValue<SignOn::Identity*>(identity));
+ session->process(SignOn::SessionData(signonSessionData), mechanism);
+}
+
+void MastodonDataTypeSyncAdaptor::signOnError(const SignOn::Error &error)
+{
+ SignOn::AuthSession *session = qobject_cast<SignOn::AuthSession*>(sender());
+ Accounts::Account *account = session->property("account").value<Accounts::Account*>();
+ SignOn::Identity *identity = session->property("identity").value<SignOn::Identity*>();
+ const int accountId = account->id();
+
+ qCWarning(lcSocialPlugin) << "credentials for account with id" << accountId
+ << "couldn't be retrieved:" << error.type() << error.message();
+
+ if (error.type() == SignOn::Error::UserInteraction) {
+ setCredentialsNeedUpdate(account);
+ }
+
+ session->disconnect(this);
+ identity->destroySession(session);
+ identity->deleteLater();
+ account->deleteLater();
+
+ setStatus(SocialNetworkSyncAdaptor::Error);
+ decrementSemaphore(accountId);
+}
+
+void MastodonDataTypeSyncAdaptor::signOnResponse(const SignOn::SessionData &responseData)
+{
+ QVariantMap data;
+ foreach (const QString &key, responseData.propertyNames()) {
+ data.insert(key, responseData.getProperty(key));
+ }
+
+ QString accessToken;
+ SignOn::AuthSession *session = qobject_cast<SignOn::AuthSession*>(sender());
+ Accounts::Account *account = session->property("account").value<Accounts::Account*>();
+ SignOn::Identity *identity = session->property("identity").value<SignOn::Identity*>();
+ const int accountId = account->id();
+
+ accessToken = data.value(QLatin1String("AccessToken")).toString().trimmed();
+ if (accessToken.isEmpty()) {
+ accessToken = data.value(QLatin1String("access_token")).toString().trimmed();
+ }
+ if (accessToken.isEmpty()) {
+ qCWarning(lcSocialPlugin) << "signon response for account with id" << accountId
+ << "contained no access token; keys:" << data.keys();
+ }
+
+ m_apiHosts.insert(accountId, normalizeApiHost(account->value(QStringLiteral("api/Host")).toString()));
+
+ session->disconnect(this);
+ identity->destroySession(session);
+ identity->deleteLater();
+ account->deleteLater();
+
+ if (!accessToken.isEmpty()) {
+ beginSync(accountId, accessToken);
+ } else {
+ setStatus(SocialNetworkSyncAdaptor::Error);
+ }
+
+ decrementSemaphore(accountId);
+}
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h
new file mode 100644
index 0000000..f295cd8
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h
@@ -0,0 +1,70 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2026 Open Mobile Platform LLC.
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#ifndef MASTODONDATATYPESYNCADAPTOR_H
+#define MASTODONDATATYPESYNCADAPTOR_H
+
+#include "socialnetworksyncadaptor.h"
+
+#include <QtCore/QMap>
+#include <QtNetwork/QNetworkReply>
+#include <QtNetwork/QSslError>
+
+namespace Accounts {
+ class Account;
+}
+namespace SignOn {
+ class Error;
+ class SessionData;
+}
+
+class MastodonDataTypeSyncAdaptor : public SocialNetworkSyncAdaptor
+{
+ Q_OBJECT
+
+public:
+ MastodonDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::DataType dataType, QObject *parent);
+ virtual ~MastodonDataTypeSyncAdaptor();
+
+ void sync(const QString &dataTypeString, int accountId) override;
+
+protected:
+ QString apiHost(int accountId) const;
+ virtual void updateDataForAccount(int accountId);
+ virtual void beginSync(int accountId, const QString &accessToken) = 0;
+
+protected Q_SLOTS:
+ virtual void errorHandler(QNetworkReply::NetworkError err);
+ virtual void sslErrorsHandler(const QList<QSslError> &errs);
+
+private Q_SLOTS:
+ void signOnError(const SignOn::Error &error);
+ void signOnResponse(const SignOn::SessionData &responseData);
+
+private:
+ static QString normalizeApiHost(const QString &rawHost);
+ void setCredentialsNeedUpdate(Accounts::Account *account);
+ void signIn(Accounts::Account *account);
+
+private:
+ QMap<int, QString> m_apiHosts;
+};
+
+#endif // MASTODONDATATYPESYNCADAPTOR_H
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp
new file mode 100644
index 0000000..d3c8d50
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp
@@ -0,0 +1,49 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2026 Open Mobile Platform LLC.
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#include "mastodonpostsplugin.h"
+#include "mastodonpostssyncadaptor.h"
+#include "socialnetworksyncadaptor.h"
+
+MastodonPostsPlugin::MastodonPostsPlugin(const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface *callbackInterface)
+ : SocialdButeoPlugin(pluginName, profile, callbackInterface,
+ QStringLiteral("mastodon"),
+ SocialNetworkSyncAdaptor::dataTypeName(SocialNetworkSyncAdaptor::Posts))
+{
+}
+
+MastodonPostsPlugin::~MastodonPostsPlugin()
+{
+}
+
+SocialNetworkSyncAdaptor *MastodonPostsPlugin::createSocialNetworkSyncAdaptor()
+{
+ return new MastodonPostsSyncAdaptor(this);
+}
+
+Buteo::ClientPlugin* MastodonPostsPluginLoader::createClientPlugin(
+ const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface* cbInterface)
+{
+ return new MastodonPostsPlugin(pluginName, profile, cbInterface);
+}
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h
new file mode 100644
index 0000000..35dbb56
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h
@@ -0,0 +1,54 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2026 Open Mobile Platform LLC.
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#ifndef MASTODONPOSTSPLUGIN_H
+#define MASTODONPOSTSPLUGIN_H
+
+#include "socialdbuteoplugin.h"
+
+#include <buteosyncfw5/SyncPluginLoader.h>
+
+class Q_DECL_EXPORT MastodonPostsPlugin : public SocialdButeoPlugin
+{
+ Q_OBJECT
+
+public:
+ MastodonPostsPlugin(const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface *cbInterface);
+ ~MastodonPostsPlugin();
+
+protected:
+ SocialNetworkSyncAdaptor *createSocialNetworkSyncAdaptor() override;
+};
+
+class MastodonPostsPluginLoader : public Buteo::SyncPluginLoader
+{
+ Q_OBJECT
+ Q_PLUGIN_METADATA(IID "org.sailfishos.plugins.sync.MastodonPostsPluginLoader")
+ Q_INTERFACES(Buteo::SyncPluginLoader)
+
+public:
+ Buteo::ClientPlugin* createClientPlugin(const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface* cbInterface) override;
+};
+
+#endif // MASTODONPOSTSPLUGIN_H
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp
new file mode 100644
index 0000000..7ccf0a2
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp
@@ -0,0 +1,316 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2026 Open Mobile Platform LLC.
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#include "mastodonpostssyncadaptor.h"
+#include "trace.h"
+
+#include <QtCore/QJsonArray>
+#include <QtCore/QJsonObject>
+#include <QtCore/QJsonValue>
+#include <QtCore/QRegularExpression>
+#include <QtCore/QUrl>
+#include <QtCore/QUrlQuery>
+#include <QtNetwork/QNetworkRequest>
+
+namespace {
+ QString decodeHtmlEntities(QString text)
+ {
+ text.replace(QStringLiteral("&quot;"), QStringLiteral("\""));
+ text.replace(QStringLiteral("&apos;"), QStringLiteral("'"));
+ text.replace(QStringLiteral("&lt;"), QStringLiteral("<"));
+ text.replace(QStringLiteral("&gt;"), QStringLiteral(">"));
+ text.replace(QStringLiteral("&amp;"), QStringLiteral("&"));
+ text.replace(QStringLiteral("&nbsp;"), 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 &timestampString)
+{
+ QDateTime timestamp;
+
+#if QT_VERSION >= QT_VERSION_CHECK(5, 8, 0)
+ timestamp = QDateTime::fromString(timestampString, Qt::ISODateWithMs);
+ if (timestamp.isValid()) {
+ return timestamp;
+ }
+#endif
+
+ timestamp = QDateTime::fromString(timestampString, Qt::ISODate);
+ if (timestamp.isValid()) {
+ return timestamp;
+ }
+
+ // Qt 5.6 cannot parse ISO-8601 timestamps with fractional seconds.
+ const int timeSeparator = timestampString.indexOf(QLatin1Char('T'));
+ const int fractionSeparator = timestampString.indexOf(QLatin1Char('.'), timeSeparator + 1);
+ if (timeSeparator > -1 && fractionSeparator > -1) {
+ int timezoneSeparator = timestampString.indexOf(QLatin1Char('Z'), fractionSeparator + 1);
+ if (timezoneSeparator == -1) {
+ timezoneSeparator = timestampString.indexOf(QLatin1Char('+'), fractionSeparator + 1);
+ }
+ if (timezoneSeparator == -1) {
+ timezoneSeparator = timestampString.indexOf(QLatin1Char('-'), fractionSeparator + 1);
+ }
+
+ QString stripped = timestampString;
+ if (timezoneSeparator > -1) {
+ stripped.remove(fractionSeparator, timezoneSeparator - fractionSeparator);
+ } else {
+ stripped.truncate(fractionSeparator);
+ }
+
+ timestamp = QDateTime::fromString(stripped, Qt::ISODate);
+ }
+
+ return timestamp;
+}
+
+void MastodonPostsSyncAdaptor::requestPosts(int accountId, const QString &accessToken)
+{
+ QUrl url(apiHost(accountId) + QStringLiteral("/api/v1/timelines/home"));
+
+ QUrlQuery query(url);
+ query.addQueryItem(QStringLiteral("limit"), QStringLiteral("20"));
+ url.setQuery(query);
+
+ QNetworkRequest request(url);
+ request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(accessToken).toUtf8());
+
+ QNetworkReply *reply = m_networkAccessManager->get(request);
+ if (reply) {
+ reply->setProperty("accountId", accountId);
+ connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(errorHandler(QNetworkReply::NetworkError)));
+ connect(reply, SIGNAL(sslErrors(QList<QSslError>)), this, SLOT(sslErrorsHandler(QList<QSslError>)));
+ connect(reply, SIGNAL(finished()), this, SLOT(finishedPostsHandler()));
+
+ incrementSemaphore(accountId);
+ setupReplyTimeout(accountId, reply);
+ } else {
+ qCWarning(lcSocialPlugin) << "unable to request home timeline posts from Mastodon account with id" << accountId;
+ }
+}
+
+void MastodonPostsSyncAdaptor::finishedPostsHandler()
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
+ if (!reply) {
+ return;
+ }
+
+ const bool isError = reply->property("isError").toBool();
+ const int accountId = reply->property("accountId").toInt();
+ QByteArray replyData = reply->readAll();
+
+ disconnect(reply);
+ reply->deleteLater();
+ removeReplyTimeout(accountId, reply);
+
+ bool ok = false;
+ QJsonArray statuses = parseJsonArrayReplyData(replyData, &ok);
+ if (!isError && ok) {
+ if (!statuses.size()) {
+ qCDebug(lcSocialPlugin) << "no feed posts received for account" << accountId;
+ decrementSemaphore(accountId);
+ return;
+ }
+
+ m_db.removePosts(accountId);
+
+ const int sinceSpan = m_accountSyncProfile
+ ? m_accountSyncProfile->key(Buteo::KEY_SYNC_SINCE_DAYS_PAST, QStringLiteral("7")).toInt()
+ : 7;
+
+ foreach (const QJsonValue &statusValue, statuses) {
+ const QJsonObject statusObject = statusValue.toObject();
+ if (statusObject.isEmpty()) {
+ continue;
+ }
+
+ QJsonObject postObject = statusObject;
+ QString boostedBy;
+ if (statusObject.contains(QStringLiteral("reblog"))
+ && statusObject.value(QStringLiteral("reblog")).isObject()
+ && !statusObject.value(QStringLiteral("reblog")).isNull()) {
+ boostedBy = displayNameForAccount(statusObject.value(QStringLiteral("account")).toObject());
+ postObject = statusObject.value(QStringLiteral("reblog")).toObject();
+ }
+
+ QDateTime eventTimestamp = parseTimestamp(statusObject.value(QStringLiteral("created_at")).toString());
+ if (!eventTimestamp.isValid()) {
+ eventTimestamp = parseTimestamp(postObject.value(QStringLiteral("created_at")).toString());
+ }
+ if (!eventTimestamp.isValid()) {
+ continue;
+ }
+
+ if (eventTimestamp.daysTo(QDateTime::currentDateTime()) > sinceSpan) {
+ continue;
+ }
+
+ const QJsonObject account = postObject.value(QStringLiteral("account")).toObject();
+ const QString displayName = displayNameForAccount(account);
+ const QString accountName = account.value(QStringLiteral("acct")).toString();
+ QString icon = account.value(QStringLiteral("avatar_static")).toString();
+ if (icon.isEmpty()) {
+ icon = account.value(QStringLiteral("avatar")).toString();
+ }
+
+ QString identifier = postObject.value(QStringLiteral("id")).toVariant().toString();
+ if (identifier.isEmpty()) {
+ continue;
+ }
+
+ QString url = postObject.value(QStringLiteral("url")).toString();
+ if (url.isEmpty() && !accountName.isEmpty()) {
+ url = QStringLiteral("%1/@%2/%3").arg(apiHost(accountId), accountName, identifier);
+ }
+
+ const QString body = sanitizeContent(postObject.value(QStringLiteral("content")).toString());
+
+ QList<QPair<QString, SocialPostImage::ImageType> > imageList;
+ const QJsonArray mediaAttachments = postObject.value(QStringLiteral("media_attachments")).toArray();
+ foreach (const QJsonValue &attachmentValue, mediaAttachments) {
+ const QJsonObject attachment = attachmentValue.toObject();
+ const QString mediaType = attachment.value(QStringLiteral("type")).toString();
+
+ QString mediaUrl;
+ SocialPostImage::ImageType imageType = SocialPostImage::Invalid;
+ if (mediaType == QLatin1String("image")) {
+ mediaUrl = attachment.value(QStringLiteral("url")).toString();
+ imageType = SocialPostImage::Photo;
+ } else if (mediaType == QLatin1String("video") || mediaType == QLatin1String("gifv")) {
+ mediaUrl = attachment.value(QStringLiteral("preview_url")).toString();
+ if (mediaUrl.isEmpty()) {
+ mediaUrl = attachment.value(QStringLiteral("url")).toString();
+ }
+ imageType = SocialPostImage::Video;
+ }
+
+ if (!mediaUrl.isEmpty() && imageType != SocialPostImage::Invalid) {
+ imageList.append(qMakePair(mediaUrl, imageType));
+ }
+ }
+
+ m_db.addMastodonPost(identifier,
+ displayName,
+ accountName,
+ body,
+ eventTimestamp,
+ icon,
+ imageList,
+ url,
+ boostedBy,
+ apiHost(accountId),
+ accountId);
+ }
+ } else {
+ qCWarning(lcSocialPlugin) << "unable to parse event feed data from request with account" << accountId
+ << ", got:" << QString::fromUtf8(replyData);
+ }
+
+ decrementSemaphore(accountId);
+}
diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h
new file mode 100644
index 0000000..10f8b1c
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h
@@ -0,0 +1,61 @@
+/****************************************************************************
+ **
+ ** Copyright (C) 2026 Open Mobile Platform LLC.
+ **
+ ** This program/library is free software; you can redistribute it and/or
+ ** modify it under the terms of the GNU Lesser General Public License
+ ** version 2.1 as published by the Free Software Foundation.
+ **
+ ** This program/library is distributed in the hope that it will be useful,
+ ** but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ ** Lesser General Public License for more details.
+ **
+ ** You should have received a copy of the GNU Lesser General Public
+ ** License along with this program/library; if not, write to the Free
+ ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ ** 02110-1301 USA
+ **
+ ****************************************************************************/
+
+#ifndef MASTODONPOSTSSYNCADAPTOR_H
+#define MASTODONPOSTSSYNCADAPTOR_H
+
+#include "mastodondatatypesyncadaptor.h"
+
+#include <QtCore/QDateTime>
+#include <QtNetwork/QNetworkReply>
+
+#include "mastodonpostsdatabase.h"
+#include <socialcache/socialimagesdatabase.h>
+
+class MastodonPostsSyncAdaptor : public MastodonDataTypeSyncAdaptor
+{
+ Q_OBJECT
+
+public:
+ MastodonPostsSyncAdaptor(QObject *parent);
+ ~MastodonPostsSyncAdaptor();
+
+ QString syncServiceName() const override;
+
+protected:
+ void purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode mode) override;
+ void beginSync(int accountId, const QString &accessToken) override;
+ void finalize(int accountId) override;
+
+private:
+ static QString sanitizeContent(const QString &content);
+ static QDateTime parseTimestamp(const QString &timestampString);
+
+ 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 &timestamp,
+ const QString &icon,
+ const QList<QPair<QString, SocialPostImage::ImageType> > &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 <socialcache/abstractsocialpostcachedatabase.h>
+
+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 &timestamp,
+ const QString &icon,
+ const QList<QPair<QString, SocialPostImage::ImageType> > &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 <lucien.xu@jollamobile.com>
+ *
+ * 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 <synchronizelists_p.h>
+
+#include <QtCore/QDebug>
+#include <QtCore/QMutexLocker>
+
+template <> bool compareIdentity<SocialCacheModelRow>(
+ const SocialCacheModelRow &item, const SocialCacheModelRow &reference)
+{
+ return item.value(0) == reference.value(0);
+}
+
+template <>
+int updateRange<AbstractSocialCacheModelPrivate, SocialCacheModelData>(
+ 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 <lucien.xu@jollamobile.com>
+ *
+ * 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 <QtCore/QAbstractListModel>
+
+typedef QMap<int, QVariant> SocialCacheModelRow;
+typedef QList<SocialCacheModelRow> 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<AbstractSocialCacheModelPrivate> 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 <lucien.xu@jollamobile.com>
+ *
+ * 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 <QtCore/QMap>
+
+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<QMap<int, QVariant> > 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1">
+<context>
+ <name></name>
+ <message id="lipstick-jolla-home-la-mastodon_posts">
+ <location filename="mastodon-delegate.qml" line="19"/>
+ <source>Posts</source>
+ <extracomment>Mastodon posts</extracomment>
+ <translation type="unfinished"></translation>
+ </message>
+ <message id="lipstick-jolla-home-la-show-more-in-mastodon">
+ <location filename="mastodon-delegate.qml" line="56"/>
+ <source>Show more in Mastodon</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message id="lipstick-jolla-home-la-boosted_by">
+ <location filename="MastodonFeedItem.qml" line="61"/>
+ <source>%1 boosted</source>
+ <extracomment>Shown above a post that is boosted by another user. %1 = name of user who boosted</extracomment>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
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
--- /dev/null
+++ b/eventsview-plugins/eventsview-plugin-mastodon/lipstick-jolla-home-mastodon_eng_en.qm
Binary files 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<int, QByteArray> MastodonPostsModel::roleNames() const
+{
+ QHash<int, QByteArray> 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<SocialPost::ConstPtr> postsData = d->database.posts();
+ Q_FOREACH (const SocialPost::ConstPtr &post, postsData) {
+ QMap<int, QVariant> 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<int, QByteArray> 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 <QQmlExtensionPlugin>
+#include <QtQml>
+
+#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<MastodonPostsModel>(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 <lucien.xu@jollamobile.com>
+ *
+ * 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 <QtCore/QVariantMap>
+
+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 <andrew.den.exter@jollamobile.com>
+ *
+ * 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 <typename T>
+bool compareIdentity(const T &item, const T &reference)
+{
+ return item == reference;
+}
+
+template <typename Agent, typename ReferenceList>
+int insertRange(Agent *agent, int index, int count, const ReferenceList &source, int sourceIndex)
+{
+ agent->insertRange(index, count, source, sourceIndex);
+ return count;
+}
+
+template <typename Agent>
+int removeRange(Agent *agent, int index, int count)
+{
+ agent->removeRange(index, count);
+ return 0;
+}
+
+template <typename Agent, typename ReferenceList>
+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 <typename Agent, typename CacheList, typename ReferenceList>
+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 <typename Agent, typename CacheList, typename ReferenceList>
+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 <typename Agent, typename CacheList, typename ReferenceList>
+void synchronizeList(
+ Agent *agent,
+ const CacheList &cache,
+ int &cacheIndex,
+ const ReferenceList &reference,
+ int &referenceIndex)
+{
+ SynchronizeList<Agent, CacheList, ReferenceList>(
+ agent, cache, cacheIndex, reference, referenceIndex);
+}
+
+template <typename Agent, typename CacheList, typename ReferenceList>
+void synchronizeList(Agent *agent, const CacheList &cache, const ReferenceList &reference)
+{
+ int cacheIndex = 0;
+ int referenceIndex = 0;
+ SynchronizeList<Agent, CacheList, ReferenceList>(
+ 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 @@
+<svg width="75" height="79" viewBox="0 0 75 79" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M73.8393 17.4898C72.6973 9.00165 65.2994 2.31235 56.5296 1.01614C55.05 0.797115 49.4441 0 36.4582 0H36.3612C23.3717 0 20.585 0.797115 19.1054 1.01614C10.5798 2.27644 2.79399 8.28712 0.904997 16.8758C-0.00358524 21.1056 -0.100549 25.7949 0.0682394 30.0965C0.308852 36.2651 0.355538 42.423 0.91577 48.5665C1.30307 52.6474 1.97872 56.6957 2.93763 60.6812C4.73325 68.042 12.0019 74.1676 19.1233 76.6666C26.7478 79.2728 34.9474 79.7055 42.8039 77.9162C43.6682 77.7151 44.5217 77.4817 45.3645 77.216C47.275 76.6092 49.5123 75.9305 51.1571 74.7385C51.1797 74.7217 51.1982 74.7001 51.2112 74.6753C51.2243 74.6504 51.2316 74.6229 51.2325 74.5948V68.6416C51.2321 68.6154 51.2259 68.5896 51.2142 68.5661C51.2025 68.5426 51.1858 68.522 51.1651 68.5058C51.1444 68.4896 51.1204 68.4783 51.0948 68.4726C51.0692 68.4669 51.0426 68.467 51.0171 68.4729C45.9835 69.675 40.8254 70.2777 35.6502 70.2682C26.7439 70.2682 24.3486 66.042 23.6626 64.2826C23.1113 62.762 22.7612 61.1759 22.6212 59.5646C22.6197 59.5375 22.6247 59.5105 22.6357 59.4857C22.6466 59.4609 22.6633 59.4391 22.6843 59.422C22.7053 59.4048 22.73 59.3929 22.7565 59.3871C22.783 59.3813 22.8104 59.3818 22.8367 59.3886C27.7864 60.5826 32.8604 61.1853 37.9522 61.1839C39.1768 61.1839 40.3978 61.1839 41.6224 61.1516C46.7435 61.008 52.1411 60.7459 57.1796 59.7621C57.3053 59.7369 57.431 59.7154 57.5387 59.6831C65.4861 58.157 73.0493 53.3672 73.8178 41.2381C73.8465 40.7606 73.9184 36.2364 73.9184 35.7409C73.9219 34.0569 74.4606 23.7949 73.8393 17.4898Z" fill="url(#paint0_linear_549_34)"/>
+<path d="M61.2484 27.0263V48.114H52.8916V27.6475C52.8916 23.3388 51.096 21.1413 47.4437 21.1413C43.4287 21.1413 41.4177 23.7409 41.4177 28.8755V40.0782H33.1111V28.8755C33.1111 23.7409 31.0965 21.1413 27.0815 21.1413C23.4507 21.1413 21.6371 23.3388 21.6371 27.6475V48.114H13.2839V27.0263C13.2839 22.7176 14.384 19.2946 16.5843 16.7572C18.8539 14.2258 21.8311 12.926 25.5264 12.926C29.8036 12.926 33.0357 14.5705 35.1905 17.8559L37.2698 21.346L39.3527 17.8559C41.5074 14.5705 44.7395 12.926 49.0095 12.926C52.7013 12.926 55.6784 14.2258 57.9553 16.7572C60.1531 19.2922 61.2508 22.7152 61.2484 27.0263Z" fill="white"/>
+<defs>
+<linearGradient id="paint0_linear_549_34" x1="37.0692" y1="0" x2="37.0692" y2="79" gradientUnits="userSpaceOnUse">
+<stop stop-color="#6364FF"/>
+<stop offset="1" stop-color="#563ACC"/>
+</linearGradient>
+</defs>
+</svg>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE provider>
+<provider version="1.0" id="mastodon">
+ <name>Mastodon</name>
+ <description>Mastodon social network</description>
+ <icon>image://theme/graphic-service-mastodon</icon>
+
+ <template>
+ <group name="auth">
+ <setting name="method">oauth2</setting>
+ <setting name="mechanism">web_server</setting>
+ <group name="oauth2">
+ <group name="web_server">
+ <setting name="Host">mastodon.social</setting>
+ <setting name="AllowedSchemes" type="as">["https"]</setting>
+ <setting name="AuthPath">oauth/authorize</setting>
+ <setting name="TokenPath">oauth/token</setting>
+ <setting name="ResponseType">code</setting>
+ <setting name="Scope" type="as">["read","write"]</setting>
+ <setting name="RedirectUri">http://ipv4.jolla.com/online/status.html</setting>
+ </group>
+ </group>
+ </group>
+ <group name="api">
+ <setting name="Host">https://mastodon.social</setting>
+ </group>
+ </template>
+
+ <tags>
+ <tag>user-group:account-mastodon</tag>
+ </tags>
+</provider>
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 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<service id="mastodon-microblog">
+ <type>microblogging</type>
+ <name>Posts</name>
+ <icon>image://theme/icon-m-events</icon>
+ <provider>mastodon</provider>
+
+ <template>
+ <setting name="sync_profile_templates" type="as">["mastodon.Posts"]</setting>
+ <group name="auth">
+ <setting name="method">oauth2</setting>
+ <setting name="mechanism">web_server</setting>
+ <group name="oauth2">
+ <group name="web_server">
+ <setting name="Host">mastodon.social</setting>
+ <setting name="AllowedSchemes" type="as">["https"]</setting>
+ <setting name="AuthPath">oauth/authorize</setting>
+ <setting name="TokenPath">oauth/token</setting>
+ <setting name="ResponseType">code</setting>
+ <setting name="Scope" type="as">["read","write"]</setting>
+ <setting name="RedirectUri">http://ipv4.jolla.com/online/status.html</setting>
+ </group>
+ </group>
+ </group>
+ <group name="api">
+ <setting name="Host">https://mastodon.social</setting>
+ </group>
+ </template>
+</service>
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 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<service id="mastodon-sharing">
+ <type>sharing</type>
+ <name>Sharing</name>
+ <icon>image://theme/icon-m-share</icon>
+ <provider>mastodon</provider>
+
+ <template>
+ <group name="auth">
+ <setting name="method">oauth2</setting>
+ <setting name="mechanism">web_server</setting>
+ <group name="oauth2">
+ <group name="web_server">
+ <setting name="Host">mastodon.social</setting>
+ <setting name="AllowedSchemes" type="as">["https"]</setting>
+ <setting name="AuthPath">oauth/authorize</setting>
+ <setting name="TokenPath">oauth/token</setting>
+ <setting name="ResponseType">code</setting>
+ <setting name="Scope" type="as">["read","write"]</setting>
+ <setting name="RedirectUri">http://ipv4.jolla.com/online/status.html</setting>
+ </group>
+ </group>
+ </group>
+ <group name="api">
+ <setting name="Host">https://mastodon.social</setting>
+ </group>
+ </template>
+</service>
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<SharingMethodInfo> 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 <sharingplugininfo.h>
+#include <QStringList>
+
+class MastodonShareServiceStatus;
+
+class MastodonPluginInfo : public SharingPluginInfo
+{
+ Q_OBJECT
+
+public:
+ MastodonPluginInfo();
+ ~MastodonPluginInfo();
+
+ QList<SharingMethodInfo> info() const;
+ void query();
+
+private Q_SLOTS:
+ void serviceReady();
+
+private:
+ MastodonShareServiceStatus *m_mastodonShareServiceStatus;
+ QList<SharingMethodInfo> 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 <QtPlugin>
+
+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 <QtCore/QObject>
+
+#include <sharingplugininterface.h>
+
+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 <Accounts/Account>
+#include <Accounts/AccountService>
+#include <Accounts/Manager>
+#include <Accounts/Service>
+
+#include <SignOn/AuthSession>
+#include <SignOn/Error>
+#include <SignOn/Identity>
+#include <SignOn/SessionData>
+
+#include <QtCore/QUrl>
+#include <QtCore/QVariantMap>
+#include <QtDebug>
+
+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<Accounts::Account *>(account));
+ session->setProperty("identity", QVariant::fromValue<SignOn::Identity *>(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<SignOn::AuthSession *>(sender());
+ Accounts::Account *account = session->property("account").value<Accounts::Account *>();
+ SignOn::Identity *identity = session->property("identity").value<SignOn::Identity *>();
+ const int accountId = account ? 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<SignOn::AuthSession *>(sender());
+ Accounts::Account *account = session->property("account").value<Accounts::Account *>();
+ SignOn::Identity *identity = session->property("identity").value<SignOn::Identity *>();
+ const int accountId = account ? 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 <QtCore/QHash>
+#include <QtCore/QObject>
+#include <QtCore/QVector>
+
+#include <accountauthenticator.h>
+
+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<AccountDetails> m_accountDetails;
+ QHash<int, int> m_accountIdToDetailsIdx;
+ QHash<int, AccountDetailsState> 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 <QtCore/QFile>
+#include <QtCore/QFileInfo>
+#include <QtNetwork/QHttpMultiPart>
+#include <QtCore/QJsonDocument>
+#include <QtCore/QJsonObject>
+#include <QtCore/QUrl>
+#include <QtCore/QUrlQuery>
+
+#include <QtNetwork/QNetworkRequest>
+
+#include <QtDebug>
+
+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<QNetworkReply*> 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<qreal>(total));
+ }
+}
+
+void MastodonApi::finished()
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply*>(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<qint64>(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 <QtCore/QMap>
+#include <QtCore/QObject>
+
+#include <QtNetwork/QNetworkAccessManager>
+#include <QtNetwork/QNetworkReply>
+
+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<QNetworkReply*, API_CALL> 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 <QtPlugin>
+#include <QNetworkAccessManager>
+
+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 <QtCore/QObject>
+
+#include <transferplugininterface.h>
+
+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 <imageoperation.h>
+#include <mediaitem.h>
+
+#include <QtCore/QFile>
+#include <QtCore/QMimeDatabase>
+#include <QtCore/QMimeType>
+
+#include <QtDebug>
+
+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 <QtNetwork/QNetworkAccessManager>
+
+#include <mediatransferinterface.h>
+
+#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