diff --git a/discover/qml/UpdatesPage.qml b/discover/qml/UpdatesPage.qml index 57249f92..14157ea8 100644 --- a/discover/qml/UpdatesPage.qml +++ b/discover/qml/UpdatesPage.qml @@ -1,364 +1,365 @@ import QtQuick.Controls 2.3 import QtQuick.Layouts 1.1 import QtQuick 2.4 import org.kde.discover 2.0 import org.kde.discover.app 1.0 import "navigation.js" as Navigation import org.kde.kirigami 2.3 as Kirigami DiscoverPage { id: page title: i18n("Updates") property string footerLabel: "" property bool isBusy: false ResourcesUpdatesModel { id: resourcesUpdatesModel onPassiveMessage: window.showPassiveNotification(message) onIsProgressingChanged: { if (!isProgressing) { resourcesUpdatesModel.prepare() } } Component.onCompleted: { if (!isProgressing) { resourcesUpdatesModel.prepare() } } } UpdateModel { id: updateModel backend: resourcesUpdatesModel } Kirigami.Action { id: updateAction text: page.unselected>0 ? i18n("Update Selected") : i18n("Update All") visible: updateModel.toUpdateCount iconName: "update-none" enabled: !resourcesUpdatesModel.isProgressing && !ResourcesModel.isFetching onTriggered: resourcesUpdatesModel.updateAll() } footer: ScrollView { id: scv width: parent.width height: visible ? Kirigami.Units.gridUnit * 10 : 0 visible: log.contents.length > 0 TextArea { readOnly: true text: log.contents cursorPosition: text.length - 1 font.family: "monospace" ReadFile { id: log filter: ".*ALPM-SCRIPTLET\\] .*" path: "/var/log/pacman.log" } } } Kirigami.Action { id: cancelUpdateAction iconName: "dialog-cancel" text: i18n("Cancel") enabled: resourcesUpdatesModel.transaction && resourcesUpdatesModel.transaction.isCancellable onTriggered: resourcesUpdatesModel.transaction.cancel() } readonly property int unselected: (updateModel.totalUpdatesCount - updateModel.toUpdateCount) - readonly property QtObject currentAction: resourcesUpdatesModel.isProgressing ? cancelUpdateAction : updateAction - actions { - left: refreshAction - main: currentAction - } header: ToolBar { Kirigami.Theme.colorSet: Kirigami.Theme.Button Kirigami.Theme.inherit: false visible: (updateModel.totalUpdatesCount > 0 && resourcesUpdatesModel.isProgressing) || updateModel.hasUpdates RowLayout { anchors.fill: parent - enabled: page.currentAction.enabled + enabled: updateAction.enabled CheckBox { Layout.leftMargin: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing enabled: !resourcesUpdatesModel.isProgressing && !ResourcesModel.isFetching tristate: true checkState: updateModel.toUpdateCount === 0 ? Qt.Unchecked : updateModel.toUpdateCount === updateModel.totalUpdatesCount ? Qt.Checked : Qt.PartiallyChecked onClicked: { if (updateModel.toUpdateCount === 0) updateModel.checkAll() else updateModel.uncheckAll() } } Label { Layout.fillWidth: true text: page.unselected === 0 ? i18n("All updates selected (%1)", updateModel.updateSize) : i18np("%1/%2 update selected (%3)", "%1/%2 updates selected (%3)", updateModel.toUpdateCount, updateModel.totalUpdatesCount, updateModel.updateSize) elide: Text.ElideRight } } } supportsRefreshing: true onRefreshingChanged: { showPassiveNotification("Fetching updates...") ResourcesModel.updateAction.triggered() refreshing = false } ListView { id: updatesView currentIndex: -1 displaced: Transition { YAnimator { duration: Kirigami.Units.longDuration easing.type: Easing.InOutQuad } } footer: ColumnLayout { anchors.right: parent.right anchors.left: parent.left Kirigami.Heading { Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter visible: page.footerLabel !== "" text: page.footerLabel } BusyIndicator { id: indicator Layout.alignment: Qt.AlignHCenter Layout.minimumWidth: Kirigami.Units.gridUnit * 12 Layout.maximumWidth: Layout.minimumWidth Layout.minimumHeight: Layout.minimumWidth Layout.maximumHeight: Layout.minimumHeight visible: page.isBusy } Kirigami.Icon { Layout.alignment: Qt.AlignHCenter visible: !indicator.visible && page.footerLabel !== "" source: "update-none" opacity: 0.3 width: Kirigami.Units.gridUnit * 12 height: width } Button { Layout.alignment: Qt.AlignHCenter text: i18n("Restart") visible: resourcesUpdatesModel.needsReboot onClicked: app.reboot() } Item { visible: page.footerLabel === "" height: Kirigami.Units.gridUnit width: 1 } } model: QSortFilterProxyModel { sourceModel: updateModel sortRole: UpdateModel.SectionResourceProgressRole } section { property: "section" delegate: Kirigami.Heading { x: Kirigami.Units.gridUnit level: 2 text: section height: implicitHeight + Kirigami.Units.largeSpacing * 2 } } delegate: Kirigami.AbstractListItem { backgroundColor: Kirigami.Theme.backgroundColor highlighted: ListView.isCurrentItem onEnabledChanged: if (!enabled) { layout.extended = false; } visible: resourceState < 3 //3=AbstractBackendUpdater.Done Keys.onReturnPressed: { itemChecked.clicked() } Keys.onPressed: if (event.key===Qt.Key_Alt) layout.extended = true Keys.onReleased: if (event.key===Qt.Key_Alt) layout.extended = false ColumnLayout { id: layout property bool extended: false onExtendedChanged: if (extended) { updateModel.fetchUpdateDetails(index) } RowLayout { Layout.fillWidth: true Layout.fillHeight: true CheckBox { id: itemChecked Layout.leftMargin: Kirigami.Units.gridUnit Layout.alignment: Qt.AlignVCenter checked: model.checked === Qt.Checked onClicked: model.checked = (model.checked===Qt.Checked ? Qt.Unchecked : Qt.Checked) enabled: !resourcesUpdatesModel.isProgressing } Kirigami.Icon { width: Kirigami.Units.gridUnit * 2 Layout.preferredHeight: width source: decoration smooth: true } ColumnLayout { // App name Kirigami.Heading { Layout.fillWidth: true text: i18n("%1", display) level: 3 elide: Text.ElideRight } // Old and new version numbers; show when there's enough room Label { id: oldAndNewVersions Layout.fillWidth: true elide: Text.ElideRight text: i18n("%1 → %2", installedVersion, availableVersion) visible: !truncated } // Available version only, for when old+new would be elided. // Use squeezey text to gain more room, and if it's still so // so long that it would be elided, elide from the left so // the most important part on the right is still visible // All of this is mostly for the benefit of KDE Neon users, // since the version strings there are really really long Label { Layout.fillWidth: true elide: Text.ElideLeft text: availableVersion visible: !oldAndNewVersions.visible font.letterSpacing: -0.5 } } LabelBackground { Layout.minimumWidth: Kirigami.Units.gridUnit * 6 text: resourceState == 2 ? i18n("Installing") : size progress: resourceProgress/100 } } Frame { Layout.fillWidth: true implicitHeight: view.contentHeight visible: layout.extended && changelog.length>0 LinkLabel { id: view anchors { right: parent.right left: parent.left } text: changelog textFormat: Text.StyledText wrapMode: Text.WordWrap onLinkActivated: Qt.openUrlExternally(link) } //This saves a binding loop on implictHeight, as the Label //height is updated twice (first time with the wrong value) Behavior on implicitHeight { PropertyAnimation { duration: Kirigami.Units.shortDuration } } } Button { Layout.alignment: Qt.AlignRight text: i18n("More Information...") visible: layout.extended enabled: !resourcesUpdatesModel.isProgressing onClicked: Navigation.openApplication(resource) } } onClicked: { layout.extended = !layout.extended } } } readonly property alias secSinceUpdate: resourcesUpdatesModel.secsToLastUpdate - state: ( updateModel.hasUpdates ? "has-updates" - : resourcesUpdatesModel.isProgressing ? "progressing" + state: ( resourcesUpdatesModel.isProgressing ? "progressing" + : updateModel.hasUpdates ? "has-updates" : ResourcesModel.isFetching ? "fetching" : resourcesUpdatesModel.needsReboot ? "reboot" : secSinceUpdate < 0 ? "unknown" : secSinceUpdate === 0 ? "now-uptodate" : secSinceUpdate < 1000 * 60 * 60 * 24 ? "uptodate" : secSinceUpdate < 1000 * 60 * 60 * 24 * 7 ? "medium" : "low" ) states: [ State { name: "fetching" PropertyChanges { target: page; footerLabel: i18nc("@info", "Fetching updates...") } PropertyChanges { target: page; isBusy: true } }, State { name: "progressing" PropertyChanges { target: page; supportsRefreshing: false } - PropertyChanges { target: page; footerLabel: resourcesUpdatesModel.progress<=0 ? i18nc("@info", "Fetching updates...") : "" } - PropertyChanges { target: page; isBusy: true } + PropertyChanges { target: page.actions; main: cancelUpdateAction } }, State { name: "has-updates" PropertyChanges { target: page; title: i18nc("@info", "Updates") } + PropertyChanges { target: page.actions; main: updateAction } + PropertyChanges { target: page.actions; left: refreshAction } }, State { name: "reboot" PropertyChanges { target: page; footerLabel: i18nc("@info", "The system requires a restart to apply updates") } }, State { name: "now-uptodate" PropertyChanges { target: page; footerLabel: i18nc("@info", "Up to date") } + PropertyChanges { target: page.actions; main: refreshAction } }, State { name: "uptodate" PropertyChanges { target: page; footerLabel: i18nc("@info", "Up to date") } + PropertyChanges { target: page.actions; main: refreshAction } }, State { name: "medium" PropertyChanges { target: page; title: i18nc("@info", "Up to date") } + PropertyChanges { target: page.actions; main: refreshAction } }, State { name: "low" PropertyChanges { target: page; title: i18nc("@info", "Should check for updates") } + PropertyChanges { target: page.actions; main: refreshAction } }, State { name: "unknown" PropertyChanges { target: page; title: i18nc("@info", "It is unknown when the last check for updates was") } + PropertyChanges { target: page.actions; main: refreshAction } } ] } diff --git a/libdiscover/appstream/OdrsReviewsBackend.cpp b/libdiscover/appstream/OdrsReviewsBackend.cpp index fbcd5eff..75292b81 100644 --- a/libdiscover/appstream/OdrsReviewsBackend.cpp +++ b/libdiscover/appstream/OdrsReviewsBackend.cpp @@ -1,372 +1,373 @@ /*************************************************************************** * Copyright © 2013 Aleix Pol Gonzalez * * Copyright © 2017 Jan Grulich * * * * This program is free software; you can redistribute it and/or * * modify it under the terms of the GNU General Public License as * * published by the Free Software Foundation; either version 2 of * * the License or (at your option) version 3 or any later version * * accepted by the membership of KDE e.V. (or its successor approved * * by the membership of KDE e.V.), which shall act as a proxy * * defined in Section 14 of version 3 of the license. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * ***************************************************************************/ #include "OdrsReviewsBackend.h" #include #include #include #include #include #include #include #include #include #include "libdiscover_debug.h" #include #include #include #include #include #include #include #include #include // #define APIURL "http://127.0.0.1:5000/1.0/reviews/api" #define APIURL "https://odrs.gnome.org/1.0/reviews/api" OdrsReviewsBackend::OdrsReviewsBackend(AbstractResourcesBackend *parent) : AbstractReviewsBackend(parent) , m_isFetching(false) , m_nam(new QNetworkAccessManager(this)) { bool fetchRatings = false; const QUrl ratingsUrl(QStringLiteral(APIURL "/ratings")); const QUrl fileUrl = QUrl::fromLocalFile(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/ratings/ratings")); const QDir cacheDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); // Create $HOME/.cache/discover/ratings folder cacheDir.mkdir(QStringLiteral("ratings")); if (QFileInfo::exists(fileUrl.toLocalFile())) { QFileInfo file(fileUrl.toLocalFile()); // Refresh the cached ratings if they are older than one day if (file.lastModified().msecsTo(QDateTime::currentDateTime()) > 1000 * 60 * 60 * 24) { fetchRatings = true; } } else { fetchRatings = true; } if (fetchRatings) { m_isFetching = true; KIO::FileCopyJob *getJob = KIO::file_copy(ratingsUrl, fileUrl, -1, KIO::Overwrite | KIO::HideProgressInfo); connect(getJob, &KIO::FileCopyJob::result, this, &OdrsReviewsBackend::ratingsFetched); } else { parseRatings(); } } void OdrsReviewsBackend::ratingsFetched(KJob *job) { m_isFetching = false; if (job->error()) { qCWarning(LIBDISCOVER_LOG) << "Failed to fetch ratings " << job->errorString(); } else { parseRatings(); } } static QString osName() { + //TODO: port to KOSRelease QString osReleaseFilename; if (QFileInfo::exists(QStringLiteral("/etc/os-release"))) { osReleaseFilename = QStringLiteral("/etc/os-release"); } else if (QFileInfo::exists(QStringLiteral("/usr/lib/os-release"))) { osReleaseFilename = QStringLiteral("/usr/lib/os-release"); } if (osReleaseFilename.isEmpty()) { return QStringLiteral("Unknown"); } QFile osReleaseFile(osReleaseFilename); if (osReleaseFile.open(QIODevice::ReadOnly)) { QString line; QTextStream stream(&osReleaseFile); while (stream.readLineInto(&line)) { if (line.startsWith(QStringLiteral("NAME"))) { QStringRef name = line.midRef(5).trimmed(); if (name.startsWith(QLatin1Char('\"')) && name.endsWith(QLatin1Char('\"'))) name = name.mid(1, name.size()-2); return name.toString(); } } } return QStringLiteral("Unknown"); } static QString userHash() { QString machineId; QFile file(QStringLiteral("/etc/machine-id")); if (file.open(QIODevice::ReadOnly)) { machineId = QString::fromUtf8(file.readAll()); file.close(); } if (machineId.isEmpty()) { return QString(); } QString salted = QStringLiteral("gnome-software[%1:%2]").arg(KUser().loginName(), machineId); return QString::fromUtf8(QCryptographicHash::hash(salted.toUtf8(), QCryptographicHash::Sha1).toHex()); } void OdrsReviewsBackend::fetchReviews(AbstractResource *app, int page) { Q_UNUSED(page) m_isFetching = true; // Check cached reviews const QString fileName = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/reviews/%1.json").arg(app->appstreamId()); if (QFileInfo::exists(fileName)) { QFileInfo file(fileName); // Check if the reviews are not older than a week msecs * secs * hours * days if (file.lastModified().msecsTo(QDateTime::currentDateTime()) < 1000 * 60 * 60 * 24 * 7 ) { QFile reviewFile(fileName); if (reviewFile.open(QIODevice::ReadOnly)) { QByteArray reviews = reviewFile.readAll(); QJsonDocument document = QJsonDocument::fromJson(reviews); parseReviews(document, app); return; } } } const QJsonDocument document(QJsonObject{ {QStringLiteral("app_id"), app->appstreamId()}, {QStringLiteral("distro"), osName()}, {QStringLiteral("user_hash"), userHash()}, {QStringLiteral("version"), app->isInstalled() ? app->installedVersion() : app->availableVersion()}, {QStringLiteral("locale"), QLocale::system().name()}, {QStringLiteral("limit"), 0} }); QNetworkRequest request(QUrl(QStringLiteral(APIURL "/fetch"))); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8")); request.setHeader(QNetworkRequest::ContentLengthHeader, document.toJson().size()); // Store reference to the app for which we request reviews request.setOriginatingObject(app); auto reply = m_nam->post(request, document.toJson()); connect(reply, &QNetworkReply::finished, this, &OdrsReviewsBackend::reviewsFetched); } void OdrsReviewsBackend::reviewsFetched() { QNetworkReply* reply = qobject_cast(sender()); QScopedPointer replyPtr(reply); if (reply->error() != QNetworkReply::NoError) { qCWarning(LIBDISCOVER_LOG) << "error fetching reviews:" << reply->errorString(); m_isFetching = false; return; } QByteArray data = reply->readAll(); const QJsonDocument document = QJsonDocument::fromJson(data); AbstractResource *resource = qobject_cast(reply->request().originatingObject()); Q_ASSERT(resource); parseReviews(document, resource); // Store reviews to cache so we don't need to download them all the time if (document.array().isEmpty()) { return; } QJsonObject jsonObject = document.array().first().toObject(); if (jsonObject.isEmpty()) { return; } QFile file(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/reviews/%1.json").arg(jsonObject.value(QStringLiteral("app_id")).toString())); if (file.open(QIODevice::WriteOnly)) { file.write(document.toJson()); } } Rating * OdrsReviewsBackend::ratingForApplication(AbstractResource *app) const { if (app->appstreamId().isEmpty()) { return nullptr; } return m_ratings[app->appstreamId()]; } void OdrsReviewsBackend::submitUsefulness(Review *review, bool useful) { const QJsonDocument document(QJsonObject{ {QStringLiteral("app_id"), review->applicationName()}, {QStringLiteral("user_skey"), review->getMetadata(QStringLiteral("ODRS::user_skey")).toString()}, {QStringLiteral("user_hash"), userHash()}, {QStringLiteral("distro"), osName()}, {QStringLiteral("review_id"), QJsonValue(double(review->id()))} //if we really need uint64 we should get it in QJsonValue }); QNetworkRequest request(QUrl(QStringLiteral(APIURL) + (useful ? QLatin1String("/upvote") : QLatin1String("/downvote")))); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8")); request.setHeader(QNetworkRequest::ContentLengthHeader, document.toJson().size()); auto reply = m_nam->post(request, document.toJson()); connect(reply, &QNetworkReply::finished, this, &OdrsReviewsBackend::usefulnessSubmitted); } void OdrsReviewsBackend::usefulnessSubmitted() { QNetworkReply* reply = qobject_cast(sender()); if (reply->error() == QNetworkReply::NoError) { qCWarning(LIBDISCOVER_LOG) << "Usefullness submitted"; } else { qCWarning(LIBDISCOVER_LOG) << "Failed to submit usefulness: " << reply->errorString(); } reply->deleteLater(); } QString OdrsReviewsBackend::userName() const { return i18n("%1 (%2)", KUser().property(KUser::FullName).toString(), KUser().loginName()); } void OdrsReviewsBackend::submitReview(AbstractResource *res, const QString &summary, const QString &description, const QString &rating) { QJsonObject map = {{QStringLiteral("app_id"), res->appstreamId()}, {QStringLiteral("user_skey"), res->getMetadata(QStringLiteral("ODRS::user_skey")).toString()}, {QStringLiteral("user_hash"), userHash()}, {QStringLiteral("version"), res->isInstalled() ? res->installedVersion() : res->availableVersion()}, {QStringLiteral("locale"), QLocale::system().name()}, {QStringLiteral("distro"), osName()}, {QStringLiteral("user_display"), QJsonValue::fromVariant(KUser().property(KUser::FullName))}, {QStringLiteral("summary"), summary}, {QStringLiteral("description"), description}, {QStringLiteral("rating"), rating.toInt() * 10}}; const QJsonDocument document(map); QNetworkAccessManager *accessManager = new QNetworkAccessManager(this); QNetworkRequest request(QUrl(QStringLiteral(APIURL "/submit"))); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json; charset=utf-8")); request.setHeader(QNetworkRequest::ContentLengthHeader, document.toJson().size()); // Store what we need so we can immediately show our review once it is submitted // Use review_id 0 for now as odrs starts numbering from 1 and once reviews are re-downloaded we get correct id map.insert(QStringLiteral("review_id"), 0); res->addMetadata(QStringLiteral("ODRS::review_map"), map); request.setOriginatingObject(res); accessManager->post(request, document.toJson()); connect(accessManager, &QNetworkAccessManager::finished, this, &OdrsReviewsBackend::reviewSubmitted); } void OdrsReviewsBackend::reviewSubmitted(QNetworkReply *reply) { if (reply->error() == QNetworkReply::NoError) { qCWarning(LIBDISCOVER_LOG) << "Review submitted"; AbstractResource *resource = qobject_cast(reply->request().originatingObject()); const QJsonArray array = {resource->getMetadata(QStringLiteral("ODRS::review_map")).toObject()}; const QJsonDocument document(array); // Remove local file with reviews so we can re-download it next time to get our review QFile file(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/reviews/%1.json").arg(array.first().toObject().value(QStringLiteral("app_id")).toString())); file.remove(); parseReviews(document, resource); } else { qCWarning(LIBDISCOVER_LOG) << "Failed to submit review: " << reply->errorString(); } reply->deleteLater(); } void OdrsReviewsBackend::parseRatings() { QFile ratingsDocument(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QStringLiteral("/ratings/ratings")); if (ratingsDocument.open(QIODevice::ReadOnly)) { QJsonDocument jsonDocument = QJsonDocument::fromJson(ratingsDocument.readAll()); const QJsonObject jsonObject = jsonDocument.object(); m_ratings.reserve(jsonObject.size()); for (auto it = jsonObject.begin(); it != jsonObject.end(); it++) { QJsonObject appJsonObject = it.value().toObject(); const int ratingCount = appJsonObject.value(QLatin1String("total")).toInt(); QVariantMap ratingMap = { { QStringLiteral("star0"), appJsonObject.value(QLatin1String("star0")).toInt() }, { QStringLiteral("star1"), appJsonObject.value(QLatin1String("star1")).toInt() }, { QStringLiteral("star2"), appJsonObject.value(QLatin1String("star2")).toInt() }, { QStringLiteral("star3"), appJsonObject.value(QLatin1String("star3")).toInt() }, { QStringLiteral("star4"), appJsonObject.value(QLatin1String("star4")).toInt() }, { QStringLiteral("star5"), appJsonObject.value(QLatin1String("star5")).toInt() } }; Rating *rating = new Rating(it.key(), ratingCount, ratingMap); rating->setParent(this); m_ratings.insert(it.key(), rating); } ratingsDocument.close(); Q_EMIT ratingsReady(); } } void OdrsReviewsBackend::parseReviews(const QJsonDocument &document, AbstractResource *resource) { m_isFetching = false; Q_ASSERT(resource); if (!resource) { return; } QJsonArray reviews = document.array(); if (!reviews.isEmpty()) { QVector reviewList; for (auto it = reviews.begin(); it != reviews.end(); it++) { const QJsonObject review = it->toObject(); if (!review.isEmpty()) { const int usefulFavorable = review.value(QStringLiteral("karma_up")).toInt(); const int usefulTotal = review.value(QStringLiteral("karma_down")).toInt() + usefulFavorable; QDateTime dateTime; dateTime.setTime_t(review.value(QStringLiteral("date_created")).toInt()); ReviewPtr r(new Review(review.value(QStringLiteral("app_id")).toString(), resource->packageName(), review.value(QStringLiteral("locale")).toString(), review.value(QStringLiteral("summary")).toString(), review.value(QStringLiteral("description")).toString(), review.value(QStringLiteral("user_display")).toString(), dateTime, true, review.value(QStringLiteral("review_id")).toInt(), review.value(QStringLiteral("rating")).toInt() / 10, usefulTotal, usefulFavorable, review.value(QStringLiteral("version")).toString())); // We can also receive just a json with app name and user info so filter these out as there is no review if (!r->summary().isEmpty() && !r->reviewText().isEmpty()) { reviewList << r; // Needed for submitting usefulness r->addMetadata(QStringLiteral("ODRS::user_skey"), review.value(QStringLiteral("user_skey")).toString()); } // We should get at least user_skey needed for posting reviews resource->addMetadata(QStringLiteral("ODRS::user_skey"), review.value(QStringLiteral("user_skey")).toString()); } } Q_EMIT reviewsReady(resource, reviewList, false); } } bool OdrsReviewsBackend::isResourceSupported(AbstractResource* res) const { return !res->appstreamId().isEmpty(); }