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 }