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 + +