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.
This commit is contained in:
Bart De Vries 2021-06-19 16:32:39 +02:00
parent acef37fa58
commit d0bc5b2b26
22 changed files with 352 additions and 48 deletions

View File

@ -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})

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-FileCopyrightText: none
SPDX-License-Identifier: CC0-1.0
-->
<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node name="/">
<interface name="org.freedesktop.NetworkManager">
<method name="state">
<arg name="state" type="u" direction="out"/>
</method>
<property name="Metered" type="u" access="read"/>
</interface>
</node>

View File

@ -8,6 +8,7 @@
#include "enclosure.h"
#include "enclosurelogging.h"
#include <KLocalizedString>
#include <QFile>
#include <QNetworkReply>
#include <QSqlQuery>
@ -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();

View File

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

View File

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

View File

@ -5,6 +5,7 @@
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#include <KLocalizedString>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
@ -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
}

View File

@ -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
};

View File

@ -32,6 +32,7 @@
#include "androidlogging.h"
#endif
#include "audiomanager.h"
#include "author.h"
#include "database.h"
#include "datamanager.h"
#include "downloadmodel.h"

View File

@ -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()
}
}

View File

@ -0,0 +1,102 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* 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();
}
}
}
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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 {
}
}
]
}
}

View File

@ -20,8 +20,8 @@ Kirigami.ScrollablePage {
supportsRefreshing: true
onRefreshingChanged: {
if(refreshing) {
Fetcher.fetchAll()
refreshing = false
updateAllFeeds.run();
refreshing = false;
}
}

View File

@ -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 {

View File

@ -26,8 +26,8 @@ Kirigami.ScrollablePage {
supportsRefreshing: true
onRefreshingChanged: {
if(refreshing) {
Fetcher.fetchAll()
refreshing = false
updateAllFeeds.run();
refreshing = false;
}
}

View File

@ -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)
},

View File

@ -22,8 +22,8 @@ Kirigami.ScrollablePage {
supportsRefreshing: true
onRefreshingChanged: {
if(refreshing) {
Fetcher.fetchAll()
refreshing = false
updateAllFeeds.run();
refreshing = false;
}
}

View File

@ -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 {

View File

@ -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();
}
}
}

View File

@ -25,6 +25,7 @@
<file alias="UpdateNotification.qml">qml/UpdateNotification.qml</file>
<file alias="HeaderBar.qml">qml/HeaderBar.qml</file>
<file alias="ErrorNotification.qml">qml/ErrorNotification.qml</file>
<file alias="ConnectionCheckAction.qml">qml/ConnectionCheckAction.qml</file>
<file>qtquickcontrols2.conf</file>
<file alias="logo.svg">../kasts.svg</file>
</qresource>

View File

@ -38,6 +38,20 @@
<default>true</default>
</entry>
</group>
<group name="Network">
<entry name="allowMeteredFeedUpdates" type="Bool">
<label>Allow podcast updates on metered connections</label>
<default>false</default>
</entry>
<entry name="allowMeteredEpisodeDownloads" type="Bool">
<label>Allow podcast downloads on metered connections</label>
<default>false</default>
</entry>
<entry name="allowMeteredImageDownloads" type="Bool">
<label>Allow image downloads on metered connections</label>
<default>false</default>
</entry>
</group>
<group name="Persistency">
<entry name="lastOpenedPage" type="String">
<label>The top-level page that was open at shutdown</label>