diff options
| author | Andrew Branson <andrew.branson@jolla.com> | 2026-02-10 10:41:02 +0100 |
|---|---|---|
| committer | Andrew Branson <andrew.branson@jolla.com> | 2026-02-10 17:09:39 +0100 |
| commit | 4351f4627ba9e71775438dd26c9acddd002c7e11 (patch) | |
| tree | 3c72c980c5c81507109087bda67052b7ec8216b6 /transferengine-plugins | |
Initial commit
Diffstat (limited to 'transferengine-plugins')
16 files changed, 1232 insertions, 0 deletions
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 |
