From d0bc5b2b26c84a3a513f7845f6fb8ce03eebdc43 Mon Sep 17 00:00:00 2001 From: Bart De Vries Date: Sat, 19 Jun 2021 16:32:39 +0200 Subject: [PATCH] Add capability to check whether network connection is metered For now this only works with NetworkManager. The related settings are greyed out on systems not using NetworkManager. Some details of the implementation: - Implement settings in the settings menu to enable/disable feed updates, episode downloads and/ or image downloads on metered connections. If the option(s) is disabled, an overlay dialog is shown with options to "not allow", "allow once", or "allow always". - If the network is down, no attempt is made to download images and the fallback image will be used until the network is up again. This also solves an issue where the application hangs when the network is down and feed images have not been cached yet. - Next to this, part of the cachedImage implementation in Entry and Feed has been refactored to re-use code as part of the image() method in Fetcher. - In case something unexpected happens, an error will be logged. --- src/CMakeLists.txt | 1 + .../org.freedesktop.NetworkManager.xml | 14 +++ src/enclosure.cpp | 11 ++ src/entry.cpp | 11 +- src/feed.cpp | 11 +- src/fetcher.cpp | 83 ++++++++++++-- src/fetcher.h | 13 +++ src/main.cpp | 1 + src/qml/AddFeedSheet.qml | 11 +- src/qml/ConnectionCheckAction.qml | 102 ++++++++++++++++++ src/qml/DownloadListPage.qml | 6 +- src/qml/EntryListPage.qml | 17 ++- src/qml/EntryPage.qml | 5 +- src/qml/EpisodeListPage.qml | 4 +- src/qml/EpisodeSwipePage.qml | 2 +- src/qml/FeedListPage.qml | 4 +- src/qml/GenericEntryDelegate.qml | 4 +- src/qml/QueuePage.qml | 4 +- src/qml/SettingsPage.qml | 38 ++++++- src/qml/main.qml | 43 +++++++- src/resources.qrc | 1 + src/settingsmanager.kcfg | 14 +++ 22 files changed, 352 insertions(+), 48 deletions(-) create mode 100644 src/dbus-interfaces/org.freedesktop.NetworkManager.xml create mode 100644 src/qml/ConnectionCheckAction.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 34bb493f..2955df4f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -91,6 +91,7 @@ else() qt5_add_dbus_interface(SRCS dbus-interfaces/org.freedesktop.PowerManagement.Inhibit.xml inhibitinterface) qt5_add_dbus_interface(SRCS dbus-interfaces/org.gnome.SessionManager.xml gnomesessioninterface) + qt5_add_dbus_interface(SRCS dbus-interfaces/org.freedesktop.NetworkManager.xml NMinterface) endif() add_executable(kasts ${SRCS}) diff --git a/src/dbus-interfaces/org.freedesktop.NetworkManager.xml b/src/dbus-interfaces/org.freedesktop.NetworkManager.xml new file mode 100644 index 00000000..cebef229 --- /dev/null +++ b/src/dbus-interfaces/org.freedesktop.NetworkManager.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/enclosure.cpp b/src/enclosure.cpp index 0ff321f3..f32cda08 100644 --- a/src/enclosure.cpp +++ b/src/enclosure.cpp @@ -8,6 +8,7 @@ #include "enclosure.h" #include "enclosurelogging.h" +#include #include #include #include @@ -21,6 +22,7 @@ #include "error.h" #include "errorlogmodel.h" #include "fetcher.h" +#include "settingsmanager.h" Enclosure::Enclosure(Entry *entry) : QObject(entry) @@ -84,6 +86,15 @@ Enclosure::Status Enclosure::dbToStatus(int value) void Enclosure::download() { + if (Fetcher::instance().isMeteredConnection() && !SettingsManager::self()->allowMeteredEpisodeDownloads()) { + Q_EMIT downloadError(Error::Type::MeteredNotAllowed, + m_entry->feed()->url(), + m_entry->id(), + 0, + i18n("Podcast downloads not allowed due to user setting")); + return; + } + checkSizeOnDisk(); EnclosureDownloadJob *downloadJob = new EnclosureDownloadJob(m_url, path(), m_entry->title()); downloadJob->start(); diff --git a/src/entry.cpp b/src/entry.cpp index f44973ac..ef65c5a3 100644 --- a/src/entry.cpp +++ b/src/entry.cpp @@ -217,16 +217,7 @@ QString Entry::cachedImage() const image = m_feed->image(); } - if (image.isEmpty()) { // this will only happen if the feed also doesn't have an image - return QStringLiteral("no-image"); - } else { - QString imagePath = Fetcher::instance().image(image); - if (imagePath.isEmpty()) { - return QStringLiteral("fetching"); - } else { - return QStringLiteral("file://") + imagePath; - } - } + return Fetcher::instance().image(image); } bool Entry::queueStatus() const diff --git a/src/feed.cpp b/src/feed.cpp index b4e45bf7..4f977279 100644 --- a/src/feed.cpp +++ b/src/feed.cpp @@ -152,16 +152,7 @@ QString Feed::image() const QString Feed::cachedImage() const { - if (m_image.isEmpty()) { - return QStringLiteral("no-image"); - } else { - QString imagePath = Fetcher::instance().image(m_image); - if (imagePath.isEmpty()) { - return QStringLiteral("fetching"); - } else { - return QStringLiteral("file://") + imagePath; - } - } + return Fetcher::instance().image(m_image); } QString Feed::link() const diff --git a/src/fetcher.cpp b/src/fetcher.cpp index 583bbf35..2709b0b0 100644 --- a/src/fetcher.cpp +++ b/src/fetcher.cpp @@ -5,6 +5,7 @@ * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ +#include #include #include #include @@ -36,6 +37,13 @@ Fetcher::Fetcher() manager->setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy); manager->setStrictTransportSecurityEnabled(true); manager->enableStrictTransportSecurityStore(true); + +#if !defined Q_OS_ANDROID && !defined Q_OS_WIN + m_nmInterface = new OrgFreedesktopNetworkManagerInterface(QStringLiteral("org.freedesktop.NetworkManager"), + QStringLiteral("/org/freedesktop/NetworkManager"), + QDBusConnection::systemBus(), + this); +#endif } void Fetcher::fetch(const QString &url) @@ -80,6 +88,13 @@ void Fetcher::fetchAll() void Fetcher::retrieveFeed(const QString &url) { + if (isMeteredConnection() && !SettingsManager::self()->allowMeteredFeedUpdates()) { + Q_EMIT error(Error::Type::MeteredNotAllowed, url, QString(), 0, i18n("Podcast updates not allowed due to user setting")); + m_updateProgress++; + Q_EMIT updateProgressChanged(m_updateProgress); + return; + } + qCDebug(kastsFetcher) << "Starting to fetch" << url; Q_EMIT startedFetchingFeed(url); @@ -346,15 +361,31 @@ void Fetcher::processEnclosure(Syndication::EnclosurePtr enclosure, Syndication: QString Fetcher::image(const QString &url) const { - QString path = imagePath(url); - if (QFileInfo::exists(path)) { - if (QFileInfo(path).size() != 0) - return path; + if (url.isEmpty()) { + return QLatin1String("no-image"); } - download(url, path); + // if image is already cached, then return the path + QString path = imagePath(url); + if (QFileInfo::exists(path)) { + if (QFileInfo(path).size() != 0) { + return QStringLiteral("file://") + path; + } + } - return QLatin1String(""); + // if image has not yet been cached, then check for network connectivity if + // possible; and download the image + if (canCheckNetworkStatus()) { + if (networkConnected() && (!isMeteredConnection() || SettingsManager::self()->allowMeteredImageDownloads())) { + download(url, path); + } else { + return QLatin1String("no-image"); + } + } else { + download(url, path); + } + + return QLatin1String("fetching"); } QNetworkReply *Fetcher::download(const QString &url, const QString &filePath) const @@ -456,3 +487,43 @@ void Fetcher::setHeader(QNetworkRequest &request) const { request.setRawHeader("User-Agent", "Kasts/0.1; Syndication"); } + +bool Fetcher::canCheckNetworkStatus() const +{ +#if !defined Q_OS_ANDROID && !defined Q_OS_WIN + qCDebug(kastsFetcher) << "Can NetworkManager be reached?" << m_nmInterface->isValid(); + return (m_nmInterface && m_nmInterface->isValid()); +#else + return false; +#endif +} + +bool Fetcher::networkConnected() const +{ +#if !defined Q_OS_ANDROID && !defined Q_OS_WIN + qCDebug(kastsFetcher) << "Network connected?" << (m_nmInterface->state() >= 70) << m_nmInterface->state(); + return (m_nmInterface && m_nmInterface->state() >= 70); +#else + return true; +#endif +} + +bool Fetcher::isMeteredConnection() const +{ +#if !defined Q_OS_ANDROID && !defined Q_OS_WIN + if (canCheckNetworkStatus()) { + // Get network connection status through DBus (NetworkManager) + // state == 1: explicitly configured as metered + // state == 3: connection guessed as metered + uint state = m_nmInterface->metered(); + qCDebug(kastsFetcher) << "Network Status:" << state; + qCDebug(kastsFetcher) << "Connection is metered?" << (state == 1 || state == 3); + return (state == 1 || state == 3); + } else { + return false; + } +#else + // TODO: get network connection type for Android and windows + return false; +#endif +} diff --git a/src/fetcher.h b/src/fetcher.h index 2f3eff24..3c4ad929 100644 --- a/src/fetcher.h +++ b/src/fetcher.h @@ -16,6 +16,10 @@ #include "error.h" +#if !defined Q_OS_ANDROID && !defined Q_OS_WIN +#include "NMinterface.h" +#endif + class Fetcher : public QObject { Q_OBJECT @@ -41,6 +45,11 @@ public: QString imagePath(const QString &url) const; QString enclosurePath(const QString &url) const; + // Network status related methods + Q_INVOKABLE bool canCheckNetworkStatus() const; + bool networkConnected() const; + Q_INVOKABLE bool isMeteredConnection() const; + Q_SIGNALS: void startedFetchingFeed(const QString &url); void feedUpdated(const QString &url); @@ -79,4 +88,8 @@ private: int m_updateProgress; int m_updateTotal; bool m_updating; + +#if !defined Q_OS_ANDROID && !defined Q_OS_WIN + OrgFreedesktopNetworkManagerInterface *m_nmInterface; +#endif }; diff --git a/src/main.cpp b/src/main.cpp index 99f96708..dd4bf48c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -32,6 +32,7 @@ #include "androidlogging.h" #endif #include "audiomanager.h" +#include "author.h" #include "database.h" #include "datamanager.h" #include "downloadmodel.h" diff --git a/src/qml/AddFeedSheet.qml b/src/qml/AddFeedSheet.qml index 5fb839aa..3f44bcff 100644 --- a/src/qml/AddFeedSheet.qml +++ b/src/qml/AddFeedSheet.qml @@ -30,13 +30,22 @@ Kirigami.OverlaySheet { Layout.fillWidth: true text: "https://" } + + // This item can be used to trigger the addition of a feed; it will open an + // overlay with options in case the operation is not allowed by the settings + ConnectionCheckAction { + id: addFeed + function action() { + DataManager.addFeed(urlField.text) + } + } } footer: Controls.Button { text: i18n("Add Podcast") enabled: urlField.text onClicked: { - DataManager.addFeed(urlField.text) + addFeed.run() addSheet.close() } } diff --git a/src/qml/ConnectionCheckAction.qml b/src/qml/ConnectionCheckAction.qml new file mode 100644 index 00000000..f268fabd --- /dev/null +++ b/src/qml/ConnectionCheckAction.qml @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +import QtQuick 2.14 +import QtQuick.Controls 2.14 as Controls +import QtQuick.Layouts 1.14 + +import org.kde.kirigami 2.14 as Kirigami + +import org.kde.kasts 1.0 + +Kirigami.OverlaySheet { + id: overlay + parent: applicationWindow().overlay + + property string headingText: i18n("Podcast updates are currently not allowed on metered connections") + property bool condition: SettingsManager.allowMeteredFeedUpdates + + // Function to be overloaded where this is instantiated with another purpose + // than refreshing all feeds + function action() { + Fetcher.fetchAll(); + } + + // This function will be executed when "Don't allow" is chosen; can be overloaded + function abortAction() { } + + // This function will be executed when the "Allow once" action is chosen; can be overloaded + function allowOnceAction() { + SettingsManager.allowMeteredFeedUpdates = true; + action() + SettingsManager.allowMeteredFeedUpdates = false; + } + + // This function will be executed when the "Always allow" action is chosed; can be overloaded + function alwaysAllowAction() { + SettingsManager.allowMeteredFeedUpdates = true; + action() + } + + // this is the function that should be called if the action should be + // triggered conditionally (on the basis that the condition is passed) + function run() { + if (!Fetcher.isMeteredConnection() || condition) { + action(); + } else { + overlay.open(); + } + } + + header: Kirigami.Heading { + text: overlay.headingText + level: 2 + wrapMode: Text.Wrap + elide: Text.ElideRight + maximumLineCount: 3 + } + + contentItem: ColumnLayout { + + spacing: 0 + + Kirigami.BasicListItem { + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + leftPadding: Kirigami.Units.smallSpacing + rightPadding: 0 + text: i18n("Don't Allow") + onClicked: { + abortAction(); + close(); + } + } + + Kirigami.BasicListItem { + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + leftPadding: Kirigami.Units.smallSpacing + rightPadding: 0 + text: i18n("Allow Once") + onClicked: { + allowOnceAction(); + close(); + } + } + + Kirigami.BasicListItem { + Layout.fillWidth: true + Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + leftPadding: Kirigami.Units.smallSpacing + rightPadding: 0 + text: i18n("Always Allow") + onClicked: { + alwaysAllowAction(); + close(); + } + } + } +} diff --git a/src/qml/DownloadListPage.qml b/src/qml/DownloadListPage.qml index 3c939fbf..a420a3f3 100644 --- a/src/qml/DownloadListPage.qml +++ b/src/qml/DownloadListPage.qml @@ -21,16 +21,16 @@ Kirigami.ScrollablePage { supportsRefreshing: true onRefreshingChanged: { if(refreshing) { - Fetcher.fetchAll() - refreshing = false + updateAllFeeds.run(); + refreshing = false; } } actions.main: Kirigami.Action { iconName: "view-refresh" text: i18n("Refresh All Podcasts") + onTriggered: refreshing = true visible: !Kirigami.Settings.isMobile - onTriggered: Fetcher.fetchAll() } Kirigami.PlaceholderMessage { diff --git a/src/qml/EntryListPage.qml b/src/qml/EntryListPage.qml index ac827a47..56f215d6 100644 --- a/src/qml/EntryListPage.qml +++ b/src/qml/EntryListPage.qml @@ -21,11 +21,26 @@ Kirigami.ScrollablePage { title: feed.name supportsRefreshing: true - onRefreshingChanged: + onRefreshingChanged: { if(refreshing) { + updateFeed.run() + } + } + + // Overlay dialog box showing options what to do on metered connections + ConnectionCheckAction { + id: updateFeed + + function action() { feed.refresh() } + function abortAction() { + page.refreshing = false + } + } + + // Make sure that this feed is also showing as "refreshing" on FeedListPage Connections { target: feed function onRefreshingChanged(refreshing) { diff --git a/src/qml/EntryPage.qml b/src/qml/EntryPage.qml index 6553274d..42e16998 100644 --- a/src/qml/EntryPage.qml +++ b/src/qml/EntryPage.qml @@ -105,7 +105,8 @@ Kirigami.ScrollablePage { if (!entry.enclosure) { Qt.openUrlExternally(entry.link) } else if (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) { - entry.enclosure.download() + downloadOverlay.entry = entry; + downloadOverlay.run(); } else if (entry.enclosure.status === Enclosure.Downloading) { entry.enclosure.cancelDownload() } else if (!entry.queueStatus) { @@ -164,4 +165,4 @@ Kirigami.ScrollablePage { } } ] -} +} \ No newline at end of file diff --git a/src/qml/EpisodeListPage.qml b/src/qml/EpisodeListPage.qml index e2966bff..09696946 100644 --- a/src/qml/EpisodeListPage.qml +++ b/src/qml/EpisodeListPage.qml @@ -20,8 +20,8 @@ Kirigami.ScrollablePage { supportsRefreshing: true onRefreshingChanged: { if(refreshing) { - Fetcher.fetchAll() - refreshing = false + updateAllFeeds.run(); + refreshing = false; } } diff --git a/src/qml/EpisodeSwipePage.qml b/src/qml/EpisodeSwipePage.qml index d558a92a..7cc69e96 100644 --- a/src/qml/EpisodeSwipePage.qml +++ b/src/qml/EpisodeSwipePage.qml @@ -22,7 +22,7 @@ Kirigami.Page { iconName: "view-refresh" text: i18n("Refresh All Podcasts") visible: !Kirigami.Settings.isMobile - onTriggered: Fetcher.fetchAll() + onTriggered: updateAllFeeds.run() } header: Loader { diff --git a/src/qml/FeedListPage.qml b/src/qml/FeedListPage.qml index d7f4b04f..342dd6e9 100644 --- a/src/qml/FeedListPage.qml +++ b/src/qml/FeedListPage.qml @@ -26,8 +26,8 @@ Kirigami.ScrollablePage { supportsRefreshing: true onRefreshingChanged: { if(refreshing) { - Fetcher.fetchAll() - refreshing = false + updateAllFeeds.run(); + refreshing = false; } } diff --git a/src/qml/GenericEntryDelegate.qml b/src/qml/GenericEntryDelegate.qml index 1e0f33b7..105ee815 100644 --- a/src/qml/GenericEntryDelegate.qml +++ b/src/qml/GenericEntryDelegate.qml @@ -194,8 +194,8 @@ Kirigami.SwipeListItem { text: i18n("Download") icon.name: "download" onTriggered: { - entry.queueStatus = true; - entry.enclosure.download(); + downloadOverlay.entry = entry; + downloadOverlay.run(); } visible: (!isDownloads || entry.enclosure.status === Enclosure.PartiallyDownloaded) && entry.enclosure && (entry.enclosure.status === Enclosure.Downloadable || entry.enclosure.status === Enclosure.PartiallyDownloaded) }, diff --git a/src/qml/QueuePage.qml b/src/qml/QueuePage.qml index 9c1f87fe..ffaa9890 100644 --- a/src/qml/QueuePage.qml +++ b/src/qml/QueuePage.qml @@ -22,8 +22,8 @@ Kirigami.ScrollablePage { supportsRefreshing: true onRefreshingChanged: { if(refreshing) { - Fetcher.fetchAll() - refreshing = false + updateAllFeeds.run(); + refreshing = false; } } diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml index d2486da8..eba22f90 100644 --- a/src/qml/SettingsPage.qml +++ b/src/qml/SettingsPage.qml @@ -20,7 +20,18 @@ Kirigami.ScrollablePage { Kirigami.Heading { Kirigami.FormData.isSection: true + text: i18n("Appearance") + } + Controls.CheckBox { + id: alwaysShowFeedTitles + checked: SettingsManager.alwaysShowFeedTitles + text: i18n("Always show podcast titles in subscription view") + onToggled: SettingsManager.alwaysShowFeedTitles = checked + } + + Kirigami.Heading { + Kirigami.FormData.isSection: true text: i18n("Play Settings") } @@ -68,15 +79,32 @@ Kirigami.ScrollablePage { Kirigami.Heading { Kirigami.FormData.isSection: true - text: i18n("Appearance") + text: i18n("Network") } + Controls.CheckBox { + id: allowMeteredFeedUpdates + checked: SettingsManager.allowMeteredFeedUpdates || !Fetcher.canCheckNetworkStatus() + Kirigami.FormData.label: i18n("On metered connections:") + text: i18n("Allow podcast updates") + onToggled: SettingsManager.allowMeteredFeedUpdates = checked + enabled: Fetcher.canCheckNetworkStatus() + } Controls.CheckBox { - id: alwaysShowFeedTitles - checked: SettingsManager.alwaysShowFeedTitles - text: i18n("Always show podcast titles in subscription view") - onToggled: SettingsManager.alwaysShowFeedTitles = checked + id: allowMeteredEpisodeDownloads + checked: SettingsManager.allowMeteredEpisodeDownloads || !Fetcher.canCheckNetworkStatus() + text: i18n("Allow episode downloads") + onToggled: SettingsManager.allowMeteredEpisodeDownloads = checked + enabled: Fetcher.canCheckNetworkStatus() + } + + Controls.CheckBox { + id: allowMeteredImageDownloads + checked: SettingsManager.allowMeteredImageDownloads || !Fetcher.canCheckNetworkStatus() + text: i18n("Allow image downloads") + onToggled: SettingsManager.allowMeteredImageDownloads = checked + enabled: Fetcher.canCheckNetworkStatus() } Kirigami.Heading { diff --git a/src/qml/main.qml b/src/qml/main.qml index 014c146e..56668f3c 100644 --- a/src/qml/main.qml +++ b/src/qml/main.qml @@ -58,7 +58,13 @@ Kirigami.ApplicationWindow { : 0 currentPage = SettingsManager.lastOpenedPage pageStack.initialPage = getPage(SettingsManager.lastOpenedPage) - if (SettingsManager.refreshOnStartup) Fetcher.fetchAll(); + + // Refresh feeds on startup if allowed + if (SettingsManager.refreshOnStartup) { + if (SettingsManager.allowMeteredFeedUpdates || !Fetcher.isMeteredConnection()) { + Fetcher.fetchAll(); + } + } } globalDrawer: Kirigami.GlobalDrawer { @@ -211,6 +217,9 @@ Kirigami.ApplicationWindow { } + // Notification that shows the progress of feed updates + // It mimicks the behaviour of an InlineMessage, because InlineMessage does + // not allow to add a BusyIndicator UpdateNotification { z: 2 id: updateNotification @@ -222,6 +231,7 @@ Kirigami.ApplicationWindow { } } + // This InlineMessage is used for displaying error messages ErrorNotification { id: errorNotification } @@ -230,4 +240,35 @@ Kirigami.ApplicationWindow { ErrorListOverlay { id: errorOverlay } + + // This item can be used to trigger an update of all feeds; it will open an + // overlay with options in case the operation is not allowed by the settings + ConnectionCheckAction { + id: updateAllFeeds + } + + // Overlay with options what to do when metered downloads are not allowed + ConnectionCheckAction { + id: downloadOverlay + + headingText: i18n("Podcast downloads are currently not allowed on metered connections") + condition: SettingsManager.allowMeteredEpisodeDownloads + property var entry: undefined + + function action() { + entry.queueStatus = true; + entry.enclosure.download(); + } + + function allowOnceAction() { + SettingsManager.allowMeteredEpisodeDownloads = true; + action(); + SettingsManager.allowMeteredEpisodeDownloads = false; + } + + function alwaysAllowAction() { + SettingsManager.allowMeteredEpisodeDownloads = true; + action(); + } + } } diff --git a/src/resources.qrc b/src/resources.qrc index 62cf8b85..f4128146 100755 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -25,6 +25,7 @@ qml/UpdateNotification.qml qml/HeaderBar.qml qml/ErrorNotification.qml + qml/ConnectionCheckAction.qml qtquickcontrols2.conf ../kasts.svg diff --git a/src/settingsmanager.kcfg b/src/settingsmanager.kcfg index 6dca4c99..59f16284 100644 --- a/src/settingsmanager.kcfg +++ b/src/settingsmanager.kcfg @@ -38,6 +38,20 @@ true + + + + false + + + + false + + + + false + +