File indexing completed on 2025-01-05 04:29:54

0001 /**
0002  * SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
0003  * SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
0004  *
0005  * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0006  */
0007 
0008 #include "sync.h"
0009 #include "synclogging.h"
0010 
0011 #include <QDateTime>
0012 #include <QDir>
0013 #include <QFile>
0014 #include <QFileInfo>
0015 #include <QSqlQuery>
0016 #include <QString>
0017 #include <QSysInfo>
0018 #include <QTimer>
0019 
0020 #include <KFormat>
0021 #include <KLocalizedString>
0022 
0023 #include <qt6keychain/keychain.h>
0024 
0025 #include "audiomanager.h"
0026 #include "database.h"
0027 #include "datamanager.h"
0028 #include "entry.h"
0029 #include "fetcher.h"
0030 #include "models/errorlogmodel.h"
0031 #include "settingsmanager.h"
0032 #include "sync/gpodder/devicerequest.h"
0033 #include "sync/gpodder/episodeactionrequest.h"
0034 #include "sync/gpodder/gpodder.h"
0035 #include "sync/gpodder/logoutrequest.h"
0036 #include "sync/gpodder/subscriptionrequest.h"
0037 #include "sync/gpodder/syncrequest.h"
0038 #include "sync/gpodder/updatedevicerequest.h"
0039 #include "sync/gpodder/updatesyncrequest.h"
0040 #include "sync/gpodder/uploadepisodeactionrequest.h"
0041 #include "sync/gpodder/uploadsubscriptionrequest.h"
0042 #include "sync/syncjob.h"
0043 #include "sync/syncutils.h"
0044 #include "utils/fetchfeedsjob.h"
0045 #include "utils/networkconnectionmanager.h"
0046 #include "utils/storagemanager.h"
0047 
0048 using namespace SyncUtils;
0049 
0050 Sync::Sync()
0051     : QObject()
0052 {
0053     connect(this, &Sync::error, &ErrorLogModel::instance(), &ErrorLogModel::monitorErrorMessages);
0054     connect(&AudioManager::instance(), &AudioManager::playbackStateChanged, this, &Sync::doQuickSync);
0055 
0056     retrieveCredentialsFromConfig();
0057 }
0058 
0059 void Sync::retrieveCredentialsFromConfig()
0060 {
0061     if (!SettingsManager::self()->syncEnabled()) {
0062         m_syncEnabled = false;
0063         Q_EMIT syncEnabledChanged();
0064     } else if (!SettingsManager::self()->syncUsername().isEmpty()) {
0065         m_username = SettingsManager::self()->syncUsername();
0066         m_hostname = SettingsManager::self()->syncHostname();
0067         m_provider = static_cast<Provider>(SettingsManager::self()->syncProvider());
0068 
0069         connect(this, &Sync::passwordRetrievalFinished, this, [=](QString password) {
0070             disconnect(this, &Sync::passwordRetrievalFinished, this, nullptr);
0071             if (!password.isEmpty()) {
0072                 m_syncEnabled = SettingsManager::self()->syncEnabled();
0073                 m_password = password;
0074                 m_hostname = SettingsManager::self()->syncHostname();
0075 
0076                 if (m_provider == Provider::GPodderNet) {
0077                     m_device = SettingsManager::self()->syncDevice();
0078                     m_deviceName = SettingsManager::self()->syncDeviceName();
0079 
0080                     if (m_syncEnabled && !m_username.isEmpty() && !m_password.isEmpty() && !m_device.isEmpty()) {
0081                         if (m_hostname.isEmpty()) { // use default official server
0082                             m_gpodder = new GPodder(m_username, m_password, this);
0083                         } else { // i.e. custom gpodder host
0084                             m_gpodder = new GPodder(m_username, m_password, m_hostname, m_provider, this);
0085                         }
0086                     }
0087                 } else if (m_provider == Provider::GPodderNextcloud) {
0088                     if (m_syncEnabled && !m_username.isEmpty() && !m_password.isEmpty() && !m_hostname.isEmpty()) {
0089                         m_gpodder = new GPodder(m_username, m_password, m_hostname, m_provider, this);
0090                     }
0091                 }
0092 
0093                 m_syncEnabled = SettingsManager::self()->syncEnabled();
0094                 Q_EMIT syncEnabledChanged();
0095 
0096                 // Now that we have all credentials we can do the initial sync if
0097                 // it's enabled in the config.  If it's not enabled, then we handle
0098                 // the automatic refresh through Main.qml
0099                 if (NetworkConnectionManager::instance().feedUpdatesAllowed()) {
0100                     if (SettingsManager::self()->refreshOnStartup() && SettingsManager::self()->syncWhenUpdatingFeeds()) {
0101                         doRegularSync(true);
0102                     }
0103                 }
0104             } else {
0105                 // Ask for password and try to log in; if it succeeds, try
0106                 // again to save the password.
0107                 m_syncEnabled = false;
0108                 QTimer::singleShot(0, this, [this]() {
0109                     Q_EMIT passwordInputRequired();
0110                 });
0111             }
0112         });
0113         retrievePasswordFromKeyChain(m_username);
0114     }
0115 }
0116 
0117 bool Sync::syncEnabled() const
0118 {
0119     return m_syncEnabled;
0120 }
0121 
0122 QString Sync::username() const
0123 {
0124     return m_username;
0125 }
0126 
0127 QString Sync::password() const
0128 {
0129     return m_password;
0130 }
0131 
0132 QString Sync::device() const
0133 {
0134     return m_device;
0135 }
0136 
0137 QString Sync::deviceName() const
0138 {
0139     return m_deviceName;
0140 }
0141 
0142 QString Sync::hostname() const
0143 {
0144     return m_hostname;
0145 }
0146 
0147 Provider Sync::provider() const
0148 {
0149     return m_provider;
0150 }
0151 
0152 QVector<Device> Sync::deviceList() const
0153 {
0154     return m_deviceList;
0155 }
0156 
0157 QString Sync::lastSuccessfulSync(const QStringList &matchingLabels) const
0158 {
0159     qulonglong timestamp = 0;
0160     QSqlQuery query;
0161     query.prepare(QStringLiteral("SELECT * FROM SyncTimeStamps;"));
0162     Database::instance().execute(query);
0163     while (query.next()) {
0164         QString label = query.value(QStringLiteral("syncservice")).toString();
0165         bool match = matchingLabels.isEmpty() || matchingLabels.contains(label);
0166         if (match) {
0167             qulonglong timestampDB = query.value(QStringLiteral("timestamp")).toULongLong();
0168             if (timestampDB > timestamp) {
0169                 timestamp = timestampDB;
0170             }
0171         }
0172     }
0173 
0174     if (timestamp > 1) {
0175         QDateTime datetime = QDateTime::fromSecsSinceEpoch(timestamp);
0176         return m_kformat.formatRelativeDateTime(datetime, QLocale::ShortFormat);
0177     } else {
0178         return i18n("Never");
0179     }
0180 }
0181 
0182 QString Sync::lastSuccessfulDownloadSync() const
0183 {
0184     QStringList labels = {subscriptionTimestampLabel, episodeTimestampLabel};
0185     return lastSuccessfulSync(labels);
0186 }
0187 
0188 QString Sync::lastSuccessfulUploadSync() const
0189 {
0190     QStringList labels = {uploadSubscriptionTimestampLabel, uploadEpisodeTimestampLabel};
0191     return lastSuccessfulSync(labels);
0192 }
0193 
0194 QString Sync::suggestedDevice() const
0195 {
0196     return QStringLiteral("kasts-") + QSysInfo::machineHostName();
0197 }
0198 
0199 QString Sync::suggestedDeviceName() const
0200 {
0201     return i18nc("Suggested description for this device on gpodder sync service; argument is the hostname", "Kasts on %1", QSysInfo::machineHostName());
0202 }
0203 
0204 void Sync::setSyncEnabled(bool status)
0205 {
0206     m_syncEnabled = status;
0207     SettingsManager::self()->setSyncEnabled(m_syncEnabled);
0208     SettingsManager::self()->save();
0209     Q_EMIT syncEnabledChanged();
0210 }
0211 
0212 void Sync::setPassword(const QString &password)
0213 {
0214     // this method is used to set the password if the proper credentials could
0215     // not be retrieved from the keychain or file
0216     connect(this, &Sync::passwordSaveFinished, this, [=]() {
0217         disconnect(this, &Sync::passwordSaveFinished, this, nullptr);
0218         QTimer::singleShot(0, this, [this]() {
0219             retrieveCredentialsFromConfig();
0220         });
0221     });
0222     savePasswordToKeyChain(m_username, password);
0223 }
0224 
0225 void Sync::setDevice(const QString &device)
0226 {
0227     m_device = device;
0228     SettingsManager::self()->setSyncDevice(m_device);
0229     SettingsManager::self()->save();
0230     Q_EMIT deviceChanged();
0231 }
0232 
0233 void Sync::setDeviceName(const QString &deviceName)
0234 {
0235     m_deviceName = deviceName;
0236     SettingsManager::self()->setSyncDeviceName(m_deviceName);
0237     SettingsManager::self()->save();
0238     Q_EMIT deviceNameChanged();
0239 }
0240 
0241 void Sync::setHostname(const QString &hostname)
0242 {
0243     if (hostname.isEmpty()) {
0244         m_hostname.clear();
0245     } else {
0246         QString cleanedHostname = hostname;
0247         QUrl hostUrl = QUrl(hostname);
0248 
0249         if (hostUrl.scheme().isEmpty()) {
0250             hostUrl.setScheme(QStringLiteral("https"));
0251             if (hostUrl.authority().isEmpty() && !hostUrl.path().isEmpty()) {
0252                 hostUrl.setAuthority(hostUrl.path());
0253                 hostUrl.setPath(QStringLiteral(""));
0254             }
0255             cleanedHostname = hostUrl.toString();
0256         }
0257 
0258         m_hostname = cleanedHostname;
0259     }
0260 
0261     SettingsManager::self()->setSyncHostname(m_hostname);
0262     SettingsManager::self()->save();
0263     Q_EMIT hostnameChanged();
0264 }
0265 
0266 void Sync::setProvider(const Provider provider)
0267 {
0268     m_provider = provider;
0269     SettingsManager::self()->setSyncProvider(m_provider);
0270     SettingsManager::self()->save();
0271     Q_EMIT providerChanged();
0272 }
0273 
0274 void Sync::login(const QString &username, const QString &password)
0275 {
0276     if (m_gpodder) {
0277         delete m_gpodder;
0278         m_gpodder = nullptr;
0279     }
0280 
0281     m_deviceList.clear();
0282 
0283     if (m_provider == Provider::GPodderNextcloud) {
0284         m_gpodder = new GPodder(username, password, m_hostname, Provider::GPodderNextcloud, this);
0285 
0286         SubscriptionRequest *subRequest = m_gpodder->getSubscriptionChanges(0, QStringLiteral(""));
0287         connect(subRequest, &SubscriptionRequest::finished, this, [=]() {
0288             if (subRequest->error() || subRequest->aborted()) {
0289                 if (subRequest->error()) {
0290                     Q_EMIT error(Error::Type::SyncError,
0291                                  QStringLiteral(""),
0292                                  QStringLiteral(""),
0293                                  subRequest->error(),
0294                                  subRequest->errorString(),
0295                                  i18n("Could not log into GPodder-nextcloud server"));
0296                 }
0297                 if (m_syncEnabled) {
0298                     setSyncEnabled(false);
0299                 }
0300             } else {
0301                 connect(this, &Sync::passwordSaveFinished, this, [=](bool success) {
0302                     disconnect(this, &Sync::passwordSaveFinished, this, nullptr);
0303                     if (success) {
0304                         m_username = username;
0305                         m_password = password;
0306                         SettingsManager::self()->setSyncUsername(username);
0307                         SettingsManager::self()->save();
0308                         Q_EMIT credentialsChanged();
0309 
0310                         setSyncEnabled(true);
0311                         Q_EMIT loginSucceeded();
0312                     }
0313                 });
0314                 savePasswordToKeyChain(username, password);
0315             }
0316             subRequest->deleteLater();
0317         });
0318     } else {
0319         if (m_hostname.isEmpty()) { // official gpodder.net server
0320             m_gpodder = new GPodder(username, password, this);
0321         } else { // custom server
0322             m_gpodder = new GPodder(username, password, m_hostname, Provider::GPodderNet, this);
0323         }
0324 
0325         DeviceRequest *deviceRequest = m_gpodder->getDevices();
0326         connect(deviceRequest, &DeviceRequest::finished, this, [=]() {
0327             if (deviceRequest->error() || deviceRequest->aborted()) {
0328                 if (deviceRequest->error()) {
0329                     Q_EMIT error(Error::Type::SyncError,
0330                                  QStringLiteral(""),
0331                                  QStringLiteral(""),
0332                                  deviceRequest->error(),
0333                                  deviceRequest->errorString(),
0334                                  i18n("Could not log into GPodder server"));
0335                 }
0336                 m_gpodder->deleteLater();
0337                 m_gpodder = nullptr;
0338                 if (m_syncEnabled) {
0339                     setSyncEnabled(false);
0340                 }
0341             } else {
0342                 m_deviceList = deviceRequest->devices();
0343 
0344                 connect(this, &Sync::passwordSaveFinished, this, [=](bool success) {
0345                     disconnect(this, &Sync::passwordSaveFinished, this, nullptr);
0346                     if (success) {
0347                         m_username = username;
0348                         m_password = password;
0349                         SettingsManager::self()->setSyncUsername(username);
0350                         SettingsManager::self()->save();
0351                         Q_EMIT credentialsChanged();
0352 
0353                         Q_EMIT loginSucceeded();
0354                         Q_EMIT deviceListReceived(); // required in order to open follow-up device-pick dialog
0355                     }
0356                 });
0357                 savePasswordToKeyChain(username, password);
0358             }
0359             deviceRequest->deleteLater();
0360         });
0361     }
0362 }
0363 
0364 void Sync::logout()
0365 {
0366     if (m_provider == Provider::GPodderNextcloud) {
0367         clearSettings();
0368     } else {
0369         if (!m_gpodder) {
0370             clearSettings();
0371             return;
0372         }
0373         LogoutRequest *logoutRequest = m_gpodder->logout();
0374         connect(logoutRequest, &LogoutRequest::finished, this, [=]() {
0375             if (logoutRequest->error() || logoutRequest->aborted()) {
0376                 if (logoutRequest->error()) {
0377                     // Let's not report this error, since it doesn't matter anyway:
0378                     // 1) If we're not logged in, there's no problem
0379                     // 2) If we are logged in, but somehow cannot log out, then it
0380                     //    shouldn't matter either, since the session probably expired
0381                     /*
0382                     Q_EMIT error(Error::Type::SyncError,
0383                                 QStringLiteral(""),
0384                                 QStringLiteral(""),
0385                                 logoutRequest->error(),
0386                                 logoutRequest->errorString(),
0387                                 i18n("Could not log out of GPodder server"));
0388                     */
0389                 }
0390             }
0391             clearSettings();
0392         });
0393     }
0394 }
0395 
0396 void Sync::clearSettings()
0397 {
0398     if (m_gpodder) {
0399         m_gpodder->deleteLater();
0400         m_gpodder = nullptr;
0401     }
0402 
0403     QSqlQuery query;
0404     // Delete pending EpisodeActions
0405     query.prepare(QStringLiteral("DELETE FROM EpisodeActions;"));
0406     Database::instance().execute(query);
0407 
0408     // Delete pending FeedActions
0409     query.prepare(QStringLiteral("DELETE FROM FeedActions;"));
0410     Database::instance().execute(query);
0411 
0412     // Delete SyncTimestamps
0413     query.prepare(QStringLiteral("DELETE FROM SyncTimestamps;"));
0414     Database::instance().execute(query);
0415 
0416     setSyncEnabled(false);
0417 
0418     // Delete password from keychain and password file
0419     deletePasswordFromKeychain(m_username);
0420 
0421     m_username.clear();
0422     m_password.clear();
0423     m_device.clear();
0424     m_deviceName.clear();
0425     m_hostname.clear();
0426     m_provider = Provider::GPodderNet;
0427     SettingsManager::self()->setSyncUsername(m_username);
0428     SettingsManager::self()->setSyncDevice(m_device);
0429     SettingsManager::self()->setSyncDeviceName(m_deviceName);
0430     SettingsManager::self()->setSyncHostname(m_hostname);
0431     SettingsManager::self()->setSyncProvider(static_cast<int>(m_provider));
0432     SettingsManager::self()->save();
0433 
0434     Q_EMIT credentialsChanged();
0435     Q_EMIT hostnameChanged();
0436     Q_EMIT syncProgressChanged();
0437 }
0438 
0439 void Sync::onWritePasswordJobFinished(QKeychain::WritePasswordJob *job, const QString &username, const QString &password)
0440 {
0441     if (job->error()) {
0442         qCDebug(kastsSync) << "Could not save password to the keychain: " << qPrintable(job->errorString());
0443         // fall back to file
0444         savePasswordToFile(username, password);
0445     } else {
0446         qCDebug(kastsSync) << "Password saved to keychain";
0447         Q_EMIT passwordSaveFinished(true);
0448     }
0449     job->deleteLater();
0450 }
0451 
0452 void Sync::savePasswordToKeyChain(const QString &username, const QString &password)
0453 {
0454     qCDebug(kastsSync) << "Save the password to the keychain for" << username;
0455 
0456 #ifndef Q_OS_WINDOWS
0457     QKeychain::WritePasswordJob *job = new QKeychain::WritePasswordJob(qAppName(), this);
0458     job->setAutoDelete(false);
0459     job->setKey(username);
0460     job->setTextData(password);
0461 
0462     QKeychain::WritePasswordJob::connect(job, &QKeychain::Job::finished, this, [this, username, password, job]() {
0463         onWritePasswordJobFinished(job, username, password);
0464     });
0465     job->start();
0466 #endif
0467 }
0468 
0469 void Sync::savePasswordToFile(const QString &username, const QString &password)
0470 {
0471     qCDebug(kastsSync) << "Save the password to file for" << username;
0472 
0473     // NOTE: Store in the same location as database, which can be different from
0474     //       the storagePath
0475     QString filePath = StorageManager::instance().passwordFilePath(username);
0476 
0477     QFile passwordFile(filePath);
0478     passwordFile.remove();
0479 
0480     QDir fileDir = QFileInfo(passwordFile).dir();
0481     if (!((fileDir.exists() || fileDir.mkpath(QStringLiteral("."))) && passwordFile.open(QFile::WriteOnly))) {
0482         Q_EMIT error(Error::Type::SyncError,
0483                      passwordFile.fileName(),
0484                      QStringLiteral(""),
0485                      0,
0486                      i18n("I/O denied: Cannot save password."),
0487                      i18n("I/O denied: Cannot save password."));
0488         Q_EMIT passwordSaveFinished(false);
0489     } else {
0490         passwordFile.write(password.toUtf8());
0491         passwordFile.close();
0492         Q_EMIT passwordSaveFinished(true);
0493     }
0494 }
0495 
0496 void Sync::retrievePasswordFromKeyChain(const QString &username)
0497 {
0498     // Workaround: first try and store a dummy entry to the keychain to ensure
0499     // that the keychain is unlocked before we try to retrieve the real password
0500 
0501     QKeychain::WritePasswordJob *writeDummyJob = new QKeychain::WritePasswordJob(qAppName(), this);
0502     writeDummyJob->setAutoDelete(false);
0503     writeDummyJob->setKey(QStringLiteral("dummy"));
0504     writeDummyJob->setTextData(QStringLiteral("dummy"));
0505 
0506     QKeychain::WritePasswordJob::connect(writeDummyJob, &QKeychain::Job::finished, this, [=]() {
0507         if (writeDummyJob->error()) {
0508             qCDebug(kastsSync) << "Could not open keychain: " << qPrintable(writeDummyJob->errorString());
0509             // fall back to password from file
0510             Q_EMIT passwordRetrievalFinished(retrievePasswordFromFile(username));
0511         } else {
0512             // opening keychain succeeded, let's try to read the password
0513 
0514             QKeychain::ReadPasswordJob *readJob = new QKeychain::ReadPasswordJob(qAppName());
0515             readJob->setAutoDelete(false);
0516             readJob->setKey(username);
0517 
0518             connect(readJob, &QKeychain::Job::finished, this, [=]() {
0519                 if (readJob->error() == QKeychain::Error::NoError) {
0520                     Q_EMIT passwordRetrievalFinished(readJob->textData());
0521                     // if a password file is present, delete it
0522                     QFile(StorageManager::instance().passwordFilePath(username)).remove();
0523                 } else {
0524                     qCDebug(kastsSync) << "Could not read the access token from the keychain: " << qPrintable(readJob->errorString());
0525                     // no password from the keychain, try token file
0526                     QString password = retrievePasswordFromFile(username);
0527                     Q_EMIT passwordRetrievalFinished(password);
0528                     if (readJob->error() == QKeychain::Error::EntryNotFound) {
0529                         if (!password.isEmpty()) {
0530                             qCDebug(kastsSync) << "Migrating password from file to the keychain for " << username;
0531                             connect(this, &Sync::passwordSaveFinished, this, [=](bool saved) {
0532                                 disconnect(this, &Sync::passwordSaveFinished, this, nullptr);
0533                                 bool removed = false;
0534                                 if (saved) {
0535                                     QFile passwordFile(StorageManager::instance().passwordFilePath(username));
0536                                     removed = passwordFile.remove();
0537                                 }
0538                                 if (!(saved && removed)) {
0539                                     qCDebug(kastsSync) << "Migrating password from the file to the keychain failed";
0540                                 }
0541                             });
0542                             savePasswordToKeyChain(username, password);
0543                         }
0544                     }
0545                 }
0546                 readJob->deleteLater();
0547             });
0548             readJob->start();
0549         }
0550         writeDummyJob->deleteLater();
0551     });
0552     writeDummyJob->start();
0553 }
0554 
0555 QString Sync::retrievePasswordFromFile(const QString &username)
0556 {
0557     QFile passwordFile(StorageManager::instance().passwordFilePath(username));
0558 
0559     if (passwordFile.open(QFile::ReadOnly)) {
0560         qCDebug(kastsSync) << "Retrieved password from file for user" << username;
0561         return QString::fromUtf8(passwordFile.readAll());
0562     } else {
0563         Q_EMIT error(Error::Type::SyncError,
0564                      passwordFile.fileName(),
0565                      QStringLiteral(""),
0566                      0,
0567                      i18n("I/O denied: Cannot access password file."),
0568                      i18n("I/O denied: Cannot access password file."));
0569 
0570         return QStringLiteral("");
0571     }
0572 }
0573 
0574 void Sync::onDeleteJobFinished(QKeychain::DeletePasswordJob *deleteJob, const QString &username)
0575 {
0576     if (deleteJob->error() == QKeychain::Error::NoError) {
0577         qCDebug(kastsSync) << "Password for username" << username << "successfully deleted from keychain";
0578 
0579         // now also delete the dummy entry
0580         QKeychain::DeletePasswordJob *deleteDummyJob = new QKeychain::DeletePasswordJob(qAppName());
0581         deleteDummyJob->setAutoDelete(true);
0582         deleteDummyJob->setKey(QStringLiteral("dummy"));
0583 
0584         QKeychain::DeletePasswordJob::connect(deleteDummyJob, &QKeychain::Job::finished, this, [=]() {
0585             if (deleteDummyJob->error()) {
0586                 qCDebug(kastsSync) << "Deleting dummy from keychain unsuccessful";
0587             } else {
0588                 qCDebug(kastsSync) << "Deleting dummy from keychain successful";
0589             }
0590         });
0591         deleteDummyJob->start();
0592     } else if (deleteJob->error() == QKeychain::Error::EntryNotFound) {
0593         qCDebug(kastsSync) << "No password for username" << username << "found in keychain";
0594     } else {
0595         qCDebug(kastsSync) << "Could not access keychain to delete password for username" << username;
0596     }
0597 }
0598 
0599 void Sync::onWriteDummyJobFinished(QKeychain::WritePasswordJob *writeDummyJob, const QString &username)
0600 {
0601     if (writeDummyJob->error()) {
0602         qCDebug(kastsSync) << "Could not open keychain: " << qPrintable(writeDummyJob->errorString());
0603     } else {
0604         // opening keychain succeeded, let's try to delete the password
0605 
0606         QFile(StorageManager::instance().passwordFilePath(username)).remove();
0607 
0608         QKeychain::DeletePasswordJob *deleteJob = new QKeychain::DeletePasswordJob(qAppName());
0609         deleteJob->setAutoDelete(true);
0610         deleteJob->setKey(username);
0611 
0612         QKeychain::DeletePasswordJob::connect(deleteJob, &QKeychain::Job::finished, this, [this, deleteJob, username]() {
0613             onDeleteJobFinished(deleteJob, username);
0614         });
0615         deleteJob->start();
0616     }
0617     writeDummyJob->deleteLater();
0618 }
0619 
0620 void Sync::deletePasswordFromKeychain(const QString &username)
0621 {
0622     // Workaround: first try and store a dummy entry to the keychain to ensure
0623     // that the keychain is unlocked before we try to delete the real password
0624 
0625     QKeychain::WritePasswordJob *writeDummyJob = new QKeychain::WritePasswordJob(qAppName(), this);
0626     writeDummyJob->setAutoDelete(false);
0627     writeDummyJob->setKey(QStringLiteral("dummy"));
0628     writeDummyJob->setTextData(QStringLiteral("dummy"));
0629 
0630     QKeychain::WritePasswordJob::connect(writeDummyJob, &QKeychain::Job::finished, this, [this, writeDummyJob, username]() {
0631         onWriteDummyJobFinished(writeDummyJob, username);
0632     });
0633     writeDummyJob->start();
0634 }
0635 
0636 void Sync::registerNewDevice(const QString &id, const QString &caption, const QString &type)
0637 {
0638     if (!m_gpodder) {
0639         return;
0640     }
0641     UpdateDeviceRequest *updateDeviceRequest = m_gpodder->updateDevice(id, caption, type);
0642     connect(updateDeviceRequest, &UpdateDeviceRequest::finished, this, [=]() {
0643         if (updateDeviceRequest->error() || updateDeviceRequest->aborted()) {
0644             if (updateDeviceRequest->error()) {
0645                 Q_EMIT error(Error::Type::SyncError,
0646                              QStringLiteral(""),
0647                              QStringLiteral(""),
0648                              updateDeviceRequest->error(),
0649                              updateDeviceRequest->errorString(),
0650                              i18n("Could not create GPodder device"));
0651             }
0652         } else {
0653             setDevice(id);
0654             setDeviceName(caption);
0655             setSyncEnabled(true);
0656             Q_EMIT deviceCreated();
0657         }
0658         updateDeviceRequest->deleteLater();
0659     });
0660 }
0661 
0662 void Sync::linkUpAllDevices()
0663 {
0664     if (!m_gpodder) {
0665         return;
0666     }
0667     SyncRequest *syncRequest = m_gpodder->getSyncStatus();
0668     connect(syncRequest, &SyncRequest::finished, this, [=]() {
0669         if (syncRequest->error() || syncRequest->aborted()) {
0670             if (syncRequest->error()) {
0671                 Q_EMIT error(Error::Type::SyncError,
0672                              QStringLiteral(""),
0673                              QStringLiteral(""),
0674                              syncRequest->error(),
0675                              syncRequest->errorString(),
0676                              i18n("Could not retrieve synced device status"));
0677             }
0678             syncRequest->deleteLater();
0679             return;
0680         }
0681 
0682         QSet<QString> syncDevices;
0683         for (const QStringList &group : syncRequest->syncedDevices()) {
0684             syncDevices += QSet(group.begin(), group.end());
0685         }
0686         syncDevices += QSet(syncRequest->unsyncedDevices().begin(), syncRequest->unsyncedDevices().end());
0687 
0688         QVector<QStringList> syncDeviceGroups;
0689         syncDeviceGroups += QStringList(syncDevices.values());
0690         if (!m_gpodder) {
0691             return;
0692         }
0693         UpdateSyncRequest *upSyncRequest = m_gpodder->updateSyncStatus(syncDeviceGroups, QStringList());
0694         connect(upSyncRequest, &UpdateSyncRequest::finished, this, [=]() {
0695             // For some reason, the response is always "Internal Server Error"
0696             // even though the request is processed properly.  So we just
0697             // continue rather than abort...
0698             if (upSyncRequest->error() || upSyncRequest->aborted()) {
0699                 if (upSyncRequest->error()) {
0700                     // Q_EMIT error(Error::Type::SyncError,
0701                     //            QStringLiteral(""),
0702                     //            QStringLiteral(""),
0703                     //            upSyncRequest->error(),
0704                     //            upSyncRequest->errorString(),
0705                     //            i18n("Could not update synced device status"));
0706                 }
0707                 // upSyncRequest->deleteLater();
0708                 // return;
0709             }
0710 
0711             // Assemble a list of all subscriptions of all devices
0712             m_syncUpAllSubscriptions.clear();
0713             m_deviceResponses = 0;
0714             for (const QString &device : syncDevices) {
0715                 if (!m_gpodder) {
0716                     return;
0717                 }
0718                 SubscriptionRequest *subRequest = m_gpodder->getSubscriptionChanges(0, device);
0719                 connect(subRequest, &SubscriptionRequest::finished, this, [=]() {
0720                     if (subRequest->error() || subRequest->aborted()) {
0721                         if (subRequest->error()) {
0722                             Q_EMIT error(Error::Type::SyncError,
0723                                          QStringLiteral(""),
0724                                          QStringLiteral(""),
0725                                          subRequest->error(),
0726                                          subRequest->errorString(),
0727                                          i18n("Could not retrieve subscriptions for device %1", device));
0728                         }
0729                     } else {
0730                         m_syncUpAllSubscriptions += subRequest->addList();
0731                     }
0732                     if (syncDevices.count() == ++m_deviceResponses) {
0733                         // We have now received all responses for all devices
0734                         for (const QString &syncdevice : syncDevices) {
0735                             if (!m_gpodder) {
0736                                 return;
0737                             }
0738                             UploadSubscriptionRequest *upSubRequest = m_gpodder->uploadSubscriptionChanges(m_syncUpAllSubscriptions, QStringList(), syncdevice);
0739                             connect(upSubRequest, &UploadSubscriptionRequest::finished, this, [this, upSubRequest, syncdevice]() {
0740                                 if (upSubRequest->error()) {
0741                                     Q_EMIT error(Error::Type::SyncError,
0742                                                  QStringLiteral(""),
0743                                                  QStringLiteral(""),
0744                                                  upSubRequest->error(),
0745                                                  upSubRequest->errorString(),
0746                                                  i18n("Could not upload subscriptions for device %1", syncdevice));
0747                                 }
0748                                 upSubRequest->deleteLater();
0749                             });
0750                         }
0751                     }
0752                     subRequest->deleteLater();
0753                 });
0754             }
0755             upSyncRequest->deleteLater();
0756         });
0757         syncRequest->deleteLater();
0758     });
0759 }
0760 
0761 void Sync::doSync(SyncStatus status, bool forceFetchAll)
0762 {
0763     if (!m_syncEnabled || !m_gpodder || !(m_syncStatus == SyncStatus::NoSync || m_syncStatus == SyncStatus::UploadOnlySync)) {
0764         return;
0765     }
0766 
0767     if (m_provider == Provider::GPodderNet && (m_username.isEmpty() || m_device.isEmpty())) {
0768         return;
0769     }
0770 
0771     if (m_provider == Provider::GPodderNextcloud && (m_username.isEmpty() || m_hostname.isEmpty())) {
0772         return;
0773     }
0774 
0775     // If a quick upload-only sync is running, abort it
0776     if (m_syncStatus == SyncStatus::UploadOnlySync) {
0777         abortSync();
0778     }
0779 
0780     m_syncStatus = status;
0781 
0782     if (status == SyncUtils::SyncStatus::PushAllSync) {
0783         retrieveAllLocalEpisodeStates();
0784     }
0785 
0786     SyncJob *syncJob = new SyncJob(status, m_gpodder, m_device, forceFetchAll, this);
0787     connect(this, &Sync::abortSync, syncJob, &SyncJob::abort);
0788     connect(syncJob, &SyncJob::infoMessage, this, [this](KJob *job, const QString &message) {
0789         m_syncProgressTotal = job->totalAmount(KJob::Unit::Items);
0790         m_syncProgress = job->processedAmount(KJob::Unit::Items);
0791         m_syncProgressText = message;
0792         Q_EMIT syncProgressChanged();
0793     });
0794     connect(syncJob, &SyncJob::finished, this, [this](KJob *job) {
0795         if (job->error()) {
0796             Q_EMIT error(Error::Type::SyncError, QStringLiteral(""), QStringLiteral(""), job->error(), job->errorText(), job->errorString());
0797         }
0798         m_syncStatus = SyncStatus::NoSync;
0799         Q_EMIT syncProgressChanged();
0800     });
0801     syncJob->start();
0802 }
0803 
0804 void Sync::doRegularSync(bool forceFetchAll)
0805 {
0806     doSync(SyncStatus::RegularSync, forceFetchAll);
0807 }
0808 
0809 void Sync::doForceSync()
0810 {
0811     doSync(SyncStatus::ForceSync, true);
0812 }
0813 
0814 void Sync::doSyncPushAll()
0815 {
0816     doSync(SyncStatus::PushAllSync, false);
0817 }
0818 
0819 void Sync::doQuickSync()
0820 {
0821     if (!SettingsManager::self()->syncWhenPlayerstateChanges()) {
0822         return;
0823     }
0824 
0825     // since this method is supposed to be called automatically, we cannot check
0826     // the network state from the UI, so we have to do it here
0827     if (!NetworkConnectionManager::instance().feedUpdatesAllowed()) {
0828         qCDebug(kastsSync) << "Not uploading episode actions on metered connection due to settings";
0829         return;
0830     }
0831 
0832     if (!m_syncEnabled || !m_gpodder || m_syncStatus != SyncStatus::NoSync) {
0833         return;
0834     }
0835 
0836     if (m_provider == Provider::GPodderNet && (m_username.isEmpty() || m_device.isEmpty())) {
0837         return;
0838     }
0839 
0840     if (m_provider == Provider::GPodderNextcloud && (m_username.isEmpty() || m_hostname.isEmpty())) {
0841         return;
0842     }
0843 
0844     m_syncStatus = SyncStatus::UploadOnlySync;
0845 
0846     SyncJob *syncJob = new SyncJob(m_syncStatus, m_gpodder, m_device, false, this);
0847     connect(this, &Sync::abortSync, syncJob, &SyncJob::abort);
0848     connect(syncJob, &SyncJob::finished, this, [this]() {
0849         // don't do error reporting or status updates on quick upload-only syncs
0850         m_syncStatus = SyncStatus::NoSync;
0851     });
0852     syncJob->start();
0853 }
0854 
0855 void Sync::applySubscriptionChangesLocally(const QStringList &addList, const QStringList &removeList)
0856 {
0857     m_allowSyncActionLogging = false;
0858 
0859     // removals
0860     DataManager::instance().removeFeeds(removeList);
0861 
0862     // additions
0863     DataManager::instance().addFeeds(addList, false);
0864 
0865     m_allowSyncActionLogging = true;
0866 }
0867 
0868 void Sync::applyEpisodeActionsLocally(const QHash<QString, QHash<QString, EpisodeAction>> &episodeActionHash)
0869 {
0870     m_allowSyncActionLogging = false;
0871 
0872     for (const QHash<QString, EpisodeAction> &actions : episodeActionHash) {
0873         for (const EpisodeAction &action : actions) {
0874             if (action.action == QStringLiteral("play")) {
0875                 Entry *entry = DataManager::instance().getEntry(action.id);
0876                 if (entry && entry->hasEnclosure()) {
0877                     qCDebug(kastsSync) << action.position << action.total << static_cast<qint64>(action.position) << entry->enclosure()->duration()
0878                                        << SettingsManager::self()->markAsPlayedBeforeEnd();
0879                     if ((action.position >= action.total - SettingsManager::self()->markAsPlayedBeforeEnd()
0880                          || static_cast<qint64>(action.position) >= entry->enclosure()->duration() - SettingsManager::self()->markAsPlayedBeforeEnd())
0881                         && action.total > 0) {
0882                         // Episode has been played
0883                         qCDebug(kastsSync) << "mark as played:" << entry->title();
0884                         entry->setRead(true);
0885                     } else if (action.position > 0 && static_cast<qint64>(action.position) * 1000 >= entry->enclosure()->duration()) {
0886                         // Episode is being listened to
0887                         qCDebug(kastsSync) << "set play position and add to queue:" << entry->title();
0888                         entry->enclosure()->setPlayPosition(action.position * 1000);
0889                         entry->setQueueStatus(true);
0890                         if (AudioManager::instance().entry() == entry) {
0891                             AudioManager::instance().setPosition(action.position * 1000);
0892                         }
0893                     } else {
0894                         // Episode has not been listened to yet
0895                         qCDebug(kastsSync) << "reset play position:" << entry->title();
0896                         entry->enclosure()->setPlayPosition(0);
0897                     }
0898                 }
0899             }
0900 
0901             if (action.action == QStringLiteral("delete")) {
0902                 Entry *entry = DataManager::instance().getEntry(action.id);
0903                 if (entry && entry->hasEnclosure()) {
0904                     // "delete" means that at least the Episode has been played
0905                     qCDebug(kastsSync) << "mark as played:" << entry->title();
0906                     entry->setRead(true);
0907                 }
0908             }
0909 
0910             QCoreApplication::processEvents(); // keep the main thread semi-responsive
0911         }
0912     }
0913 
0914     m_allowSyncActionLogging = true;
0915 
0916     // Don't sync the download or delete status since it's broken in gpodder.net:
0917     // the service only allows to upload only one download or delete action per
0918     // episode; afterwards, it's not possible to override it with a similar action
0919     // with a newer timestamp.  Hence we consider this information not reliable.
0920 }
0921 
0922 void Sync::storeAddFeedAction(const QString &url)
0923 {
0924     if (syncEnabled() && m_allowSyncActionLogging) {
0925         QSqlQuery query;
0926         query.prepare(QStringLiteral("INSERT INTO FeedActions VALUES (:url, :action, :timestamp);"));
0927         query.bindValue(QStringLiteral(":url"), url);
0928         query.bindValue(QStringLiteral(":action"), QStringLiteral("add"));
0929         query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch());
0930         Database::instance().execute(query);
0931         qCDebug(kastsSync) << "Logged a feed add action for" << url;
0932     }
0933 }
0934 
0935 void Sync::storeRemoveFeedAction(const QString &url)
0936 {
0937     if (syncEnabled() && m_allowSyncActionLogging) {
0938         QSqlQuery query;
0939         query.prepare(QStringLiteral("INSERT INTO FeedActions VALUES (:url, :action, :timestamp);"));
0940         query.bindValue(QStringLiteral(":url"), url);
0941         query.bindValue(QStringLiteral(":action"), QStringLiteral("remove"));
0942         query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch());
0943         Database::instance().execute(query);
0944         qCDebug(kastsSync) << "Logged a feed remove action for" << url;
0945     }
0946 }
0947 
0948 void Sync::storePlayEpisodeAction(const QString &id, const qulonglong started, const qulonglong position)
0949 {
0950     if (syncEnabled() && m_allowSyncActionLogging) {
0951         Entry *entry = DataManager::instance().getEntry(id);
0952         if (entry && entry->hasEnclosure()) {
0953             const qulonglong started_sec = started / 1000; // convert to seconds
0954             const qulonglong position_sec = position / 1000; // convert to seconds
0955             const qulonglong total =
0956                 (entry->enclosure()->duration() > 0) ? entry->enclosure()->duration() : 1; // workaround for episodes with bad metadata on gpodder server
0957 
0958             QSqlQuery query;
0959             query.prepare(QStringLiteral("INSERT INTO EpisodeActions VALUES (:podcast, :url, :id, :action, :started, :position, :total, :timestamp);"));
0960             query.bindValue(QStringLiteral(":podcast"), entry->feed()->url());
0961             query.bindValue(QStringLiteral(":url"), entry->enclosure()->url());
0962             query.bindValue(QStringLiteral(":id"), entry->id());
0963             query.bindValue(QStringLiteral(":action"), QStringLiteral("play"));
0964             query.bindValue(QStringLiteral(":started"), started_sec);
0965             query.bindValue(QStringLiteral(":position"), position_sec);
0966             query.bindValue(QStringLiteral(":total"), total);
0967             query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch());
0968             Database::instance().execute(query);
0969 
0970             qCDebug(kastsSync) << "Logged an episode play action for" << entry->title() << "play position changed:" << started_sec << position_sec << total;
0971         }
0972     }
0973 }
0974 
0975 void Sync::storePlayedEpisodeAction(const QString &id)
0976 {
0977     if (syncEnabled() && m_allowSyncActionLogging) {
0978         if (DataManager::instance().getEntry(id)->hasEnclosure()) {
0979             Entry *entry = DataManager::instance().getEntry(id);
0980             const qulonglong duration =
0981                 (entry->enclosure()->duration() > 0) ? entry->enclosure()->duration() : 1; // crazy workaround for episodes with bad metadata
0982             storePlayEpisodeAction(id, duration * 1000, duration * 1000);
0983         }
0984     }
0985 }
0986 
0987 void Sync::retrieveAllLocalEpisodeStates()
0988 {
0989     QVector<SyncUtils::EpisodeAction> actions;
0990 
0991     QSqlQuery query;
0992     query.prepare(QStringLiteral("SELECT * FROM Enclosures INNER JOIN Entries ON Enclosures.id = Entries.id WHERE Entries.hasEnclosure = 1;"));
0993     Database::instance().execute(query);
0994     while (query.next()) {
0995         qulonglong position_sec = query.value(QStringLiteral("playposition")).toInt() / 1000;
0996         qulonglong duration = query.value(QStringLiteral("duration")).toInt();
0997         bool read = query.value(QStringLiteral("read")).toBool();
0998         if (read) {
0999             if (duration == 0)
1000                 duration = 1; // crazy workaround for episodes with bad metadata
1001             position_sec = duration;
1002         }
1003         if (position_sec > 0 && duration > 0) {
1004             SyncUtils::EpisodeAction action;
1005             action.podcast = query.value(QStringLiteral("feed")).toString();
1006             action.id = query.value(QStringLiteral("id")).toString();
1007             action.url = query.value(QStringLiteral("url")).toString();
1008             action.started = position_sec;
1009             action.position = position_sec;
1010             action.total = duration;
1011 
1012             actions << action;
1013 
1014             qCDebug(kastsSync) << "Logged an episode play action for" << action.id << "play position:" << position_sec << duration << read;
1015         }
1016     }
1017 
1018     QSqlQuery writeQuery;
1019     Database::instance().transaction();
1020     for (SyncUtils::EpisodeAction &action : actions) {
1021         writeQuery.prepare(QStringLiteral("INSERT INTO EpisodeActions VALUES (:podcast, :url, :id, :action, :started, :position, :total, :timestamp);"));
1022         writeQuery.bindValue(QStringLiteral(":podcast"), action.podcast);
1023         writeQuery.bindValue(QStringLiteral(":url"), action.url);
1024         writeQuery.bindValue(QStringLiteral(":id"), action.id);
1025         writeQuery.bindValue(QStringLiteral(":action"), QStringLiteral("play"));
1026         writeQuery.bindValue(QStringLiteral(":started"), action.started);
1027         writeQuery.bindValue(QStringLiteral(":position"), action.position);
1028         writeQuery.bindValue(QStringLiteral(":total"), action.total);
1029         writeQuery.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch());
1030         Database::instance().execute(writeQuery);
1031     }
1032     Database::instance().commit();
1033 }