File indexing completed on 2024-04-28 16:54:34

0001 /*
0002     SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@privat.broulik.de>
0003 
0004     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
0005 */
0006 
0007 #include "job_p.h"
0008 
0009 #include "debug.h"
0010 
0011 #include <QDBusConnection>
0012 #include <QDebug>
0013 
0014 #include <KFilePlacesModel>
0015 #include <KLocalizedString>
0016 #include <KShell>
0017 
0018 #include <kio/global.h>
0019 
0020 #include "jobviewv2adaptor.h"
0021 #include "jobviewv3adaptor.h"
0022 
0023 using namespace NotificationManager;
0024 
0025 JobPrivate::JobPrivate(uint id, QObject *parent)
0026     : QObject(parent)
0027     , m_id(id)
0028 {
0029     m_showTimer.setSingleShot(true);
0030     connect(&m_showTimer, &QTimer::timeout, this, &JobPrivate::requestShow);
0031 
0032     m_objectPath.setPath(QStringLiteral("/org/kde/notificationmanager/jobs/JobView_%1").arg(id));
0033 
0034     // TODO also v1? it's identical to V2 except it doesn't have setError method so supporting it should be easy
0035     new JobViewV2Adaptor(this);
0036     new JobViewV3Adaptor(this);
0037 
0038     QDBusConnection::sessionBus().registerObject(m_objectPath.path(), this);
0039 }
0040 
0041 JobPrivate::~JobPrivate() = default;
0042 
0043 void JobPrivate::requestShow()
0044 {
0045     if (!m_showRequested) {
0046         m_showRequested = true;
0047         Q_EMIT showRequested();
0048     }
0049 }
0050 
0051 QDBusObjectPath JobPrivate::objectPath() const
0052 {
0053     return m_objectPath;
0054 }
0055 
0056 std::shared_ptr<KFilePlacesModel> JobPrivate::createPlacesModel()
0057 {
0058     static std::shared_ptr<KFilePlacesModel> s_instance;
0059     if (!s_instance) {
0060         s_instance = std::make_shared<KFilePlacesModel>();
0061     }
0062     return s_instance;
0063 }
0064 
0065 QUrl JobPrivate::localFileOrUrl(const QString &urlString)
0066 {
0067     QUrl url(urlString);
0068     if (url.scheme().isEmpty()) {
0069         url = QUrl::fromLocalFile(urlString);
0070     }
0071     return url;
0072 }
0073 
0074 QString JobPrivate::linkify(const QUrl &url, const QString &caption)
0075 {
0076     return QStringLiteral("<a href=\"%1\">%2</a>").arg(url.toString(QUrl::PrettyDecoded), caption.toHtmlEscaped());
0077 }
0078 
0079 QUrl JobPrivate::destUrl() const
0080 {
0081     QUrl url = m_destUrl;
0082     // In case of a single file and no destUrl, try using the second label (most likely "Destination")...
0083     if (!url.isValid() && m_totalFiles == 1) {
0084         url = localFileOrUrl(m_descriptionValue2).adjusted(QUrl::RemoveFilename | QUrl::StripTrailingSlash);
0085     }
0086     return url;
0087 }
0088 
0089 QString JobPrivate::prettyUrl(const QUrl &_url) const
0090 {
0091     QUrl url(_url);
0092 
0093     if (!url.isValid()) {
0094         return QString();
0095     }
0096 
0097     if (url.path().endsWith(QLatin1String("/."))) {
0098         url.setPath(url.path().chopped(2));
0099     }
0100 
0101     if (!m_placesModel) {
0102         m_placesModel = createPlacesModel();
0103     }
0104 
0105     // Mimic KUrlNavigator and show a pretty place name,
0106     // for example Documents/foo/bar rather than /home/user/Documents/foo/bar
0107     const QModelIndex closestIdx = m_placesModel->closestItem(url);
0108     if (closestIdx.isValid()) {
0109         const QUrl placeUrl = m_placesModel->url(closestIdx);
0110 
0111         QString text = m_placesModel->text(closestIdx);
0112 
0113         QString pathInsidePlace = url.path().mid(placeUrl.path().length());
0114 
0115         if (!pathInsidePlace.startsWith(QLatin1Char('/'))) {
0116             pathInsidePlace.prepend(QLatin1Char('/'));
0117         }
0118 
0119         if (pathInsidePlace != QLatin1Char('/')) {
0120             // Avoid "500 GiB Internal Hard Drive/foo/bar" when path originates directly from root.
0121             const bool isRoot = placeUrl.isLocalFile() && placeUrl.path() == QLatin1Char('/');
0122             if (isRoot) {
0123                 text = pathInsidePlace;
0124             } else {
0125                 text.append(pathInsidePlace);
0126             }
0127         }
0128 
0129         return text;
0130     }
0131 
0132     if (url.isLocalFile()) {
0133         return KShell::tildeCollapse(url.toLocalFile());
0134     }
0135 
0136     return url.toDisplayString(QUrl::RemoveUserInfo);
0137 }
0138 
0139 void JobPrivate::updateHasDetails()
0140 {
0141     // clang-format off
0142     const bool hasDetails = m_totalBytes > 0
0143         || m_totalFiles > 0
0144         || m_totalDirectories > 0
0145         || m_totalItems > 0
0146         || m_processedBytes > 0
0147         || m_processedFiles > 0
0148         || m_processedDirectories > 0
0149         || m_processedItems > 0
0150         || !m_descriptionValue1.isEmpty()
0151         || !m_descriptionValue2.isEmpty()
0152         || m_speed > 0;
0153     // clang-format on
0154 
0155     if (m_hasDetails != hasDetails) {
0156         m_hasDetails = hasDetails;
0157         Q_EMIT static_cast<Job *>(parent())->hasDetailsChanged();
0158     }
0159 }
0160 
0161 QString JobPrivate::text() const
0162 {
0163     if (!m_errorText.isEmpty()) {
0164         return m_errorText;
0165     }
0166 
0167     if (!m_infoMessage.isEmpty()) {
0168         return m_infoMessage;
0169     }
0170 
0171     const QUrl destUrl = this->destUrl();
0172     const QString prettyDestUrl = prettyUrl(destUrl);
0173 
0174     QString destUrlString;
0175     if (!prettyDestUrl.isEmpty()) {
0176         // Turn destination into a clickable hyperlink
0177         destUrlString = linkify(destUrl, prettyDestUrl);
0178     }
0179 
0180     if (m_totalFiles > 1) {
0181         if (!destUrlString.isEmpty()) {
0182             if (m_processedFiles > 0 && m_processedFiles <= m_totalFiles) {
0183                 return i18ncp("Copying n of m files to locaton", "%2 of %1 file to %3", "%2 of %1 files to %3", m_totalFiles, m_processedFiles, destUrlString);
0184             }
0185             return i18ncp("Copying n files to location",
0186                           "%1 file to %2",
0187                           "%1 files to %2",
0188                           m_processedFiles > 0 ? m_processedFiles : m_totalFiles,
0189                           destUrlString);
0190         }
0191 
0192         if (m_processedFiles > 0 && m_processedFiles <= m_totalFiles) {
0193             return i18ncp("Copying n of m files", "%2 of %1 file", "%2 of %1 files", m_totalFiles, m_processedFiles);
0194         }
0195 
0196         return i18ncp("Copying n files", "%1 file", "%1 files", m_processedFiles > 0 ? m_processedFiles : m_totalFiles);
0197     } else if (m_totalItems > 1) {
0198         // TODO support destUrl text as well (once someone actually uses that)
0199 
0200         if (m_processedItems <= m_totalItems) {
0201             return i18ncp("Copying n of m items", "%2 of %1 item", "%2 of %1 items", m_totalItems, m_processedItems);
0202         }
0203 
0204         return i18ncp("Copying n items", "%1 item", "%1 items", m_processedItems > 0 ? m_processedItems : m_totalItems);
0205     } else if (m_totalFiles == 1 || m_totalItems == 1) {
0206         const QUrl url = descriptionUrl();
0207 
0208         if (!destUrlString.isEmpty()) {
0209             const QString currentFileName = url.fileName().toHtmlEscaped();
0210             if (!currentFileName.isEmpty()) {
0211                 return i18nc("Copying file to location", "%1 to %2", currentFileName, destUrlString);
0212             } else {
0213                 if (m_totalItems) {
0214                     return i18ncp("Copying n items to location", "%1 file to %2", "%1 items to %2", m_totalItems, destUrlString);
0215                 } else {
0216                     return i18ncp("Copying n files to location", "%1 file to %2", "%1 files to %2", m_totalFiles, destUrlString);
0217                 }
0218             }
0219         } else if (url.isValid()) {
0220             // If no destination, show full URL instead of just the file name
0221             return linkify(url, prettyUrl(url));
0222         } else {
0223             if (m_totalItems) {
0224                 return i18ncp("Copying n items", "%1 item", "%1 items", m_totalItems);
0225             } else {
0226                 return i18ncp("Copying n files", "%1 file", "%1 files", m_totalFiles);
0227             }
0228         }
0229     } else if (m_totalFiles == 0) {
0230         if (!destUrlString.isEmpty()) {
0231             if (m_processedFiles > 0) {
0232                 return i18ncp("Copying n files to location", "%1 file to %2", "%1 files to %2", m_processedFiles, destUrlString);
0233             }
0234             return i18nc("Copying unknown amount of files to location", "to %1", destUrlString);
0235         } else if (m_processedFiles > 0) {
0236             return i18ncp("Copying n files", "%1 file", "%1 files", m_processedFiles);
0237         }
0238     }
0239 
0240     qCInfo(NOTIFICATIONMANAGER) << "Failed to generate job text for job with following properties:";
0241     qCInfo(NOTIFICATIONMANAGER).nospace() << "  processedFiles = " << m_processedFiles << ", totalFiles = " << m_totalFiles;
0242     qCInfo(NOTIFICATIONMANAGER).nospace() << "  processedItems = " << m_processedItems << ", totalItems = " << m_totalItems;
0243     qCInfo(NOTIFICATIONMANAGER).nospace() << "  current file name = " << descriptionUrl().fileName();
0244     qCInfo(NOTIFICATIONMANAGER).nospace() << "  destination url = " << destUrl;
0245     qCInfo(NOTIFICATIONMANAGER).nospace() << "  label1 = " << m_descriptionLabel1 << ", value1 = " << m_descriptionValue1;
0246     qCInfo(NOTIFICATIONMANAGER).nospace() << "  label2 = " << m_descriptionLabel2 << ", value2 = " << m_descriptionValue2;
0247 
0248     return QString();
0249 }
0250 
0251 void JobPrivate::delayedShow(std::chrono::milliseconds delay, ShowConditions showConditions)
0252 {
0253     m_showConditions = showConditions;
0254 
0255     if (showConditions.testFlag(ShowCondition::OnTimeout)) {
0256         m_showTimer.start(delay);
0257     }
0258 }
0259 
0260 void JobPrivate::kill()
0261 {
0262     Q_EMIT cancelRequested();
0263 
0264     // In case the application doesn't respond, remove the job
0265     if (!m_killTimer) {
0266         m_killTimer = new QTimer(this);
0267         m_killTimer->setSingleShot(true);
0268         connect(m_killTimer, &QTimer::timeout, this, [this] {
0269             qCWarning(NOTIFICATIONMANAGER) << "Application" << m_applicationName << "failed to respond to a cancel request in time";
0270             Job *job = static_cast<Job *>(parent());
0271             job->setError(KIO::ERR_USER_CANCELED);
0272             job->setState(Notifications::JobStateStopped);
0273             finish();
0274         });
0275     }
0276 
0277     if (!m_killTimer->isActive()) {
0278         m_killTimer->start(2000);
0279     }
0280 }
0281 
0282 QUrl JobPrivate::descriptionUrl() const
0283 {
0284     QUrl url = localFileOrUrl(m_descriptionValue2);
0285     if (!url.isValid()) {
0286         url = localFileOrUrl(m_descriptionValue1);
0287     }
0288     return url;
0289 }
0290 
0291 void JobPrivate::finish()
0292 {
0293     // Unregister the dbus service since the client is done with it
0294     QDBusConnection::sessionBus().unregisterObject(m_objectPath.path());
0295 
0296     // When user canceled job or a transient job finished successfully, remove it without notice
0297     if (m_error == KIO::ERR_USER_CANCELED || (!m_error && m_transient)) {
0298         Q_EMIT closed();
0299         return;
0300     }
0301 
0302     if (m_killTimer) {
0303         m_killTimer->stop();
0304     }
0305 
0306     Job *job = static_cast<Job *>(parent());
0307     // update timestamp
0308     job->resetUpdated();
0309     // when it was hidden in history, bring it up again
0310     job->setDismissed(false);
0311 }
0312 
0313 // JobViewV2
0314 void JobPrivate::terminate(const QString &errorMessage)
0315 {
0316     Job *job = static_cast<Job *>(parent());
0317     // forward to JobViewV3. In V2 we get a setError before a terminate
0318     // so we want to forward the current error to the V3 call.
0319     terminate(job->error(), errorMessage, {});
0320 }
0321 
0322 void JobPrivate::setSuspended(bool suspended)
0323 {
0324     Job *job = static_cast<Job *>(parent());
0325     if (suspended) {
0326         job->setState(Notifications::JobStateSuspended);
0327     } else {
0328         job->setState(Notifications::JobStateRunning);
0329     }
0330 }
0331 
0332 void JobPrivate::setTotalAmount(quint64 amount, const QString &unit)
0333 {
0334     if (unit == QLatin1String("bytes")) {
0335         updateField(amount, m_totalBytes, &Job::totalBytesChanged);
0336     } else if (unit == QLatin1String("files")) {
0337         updateField(amount, m_totalFiles, &Job::totalFilesChanged);
0338     } else if (unit == QLatin1String("dirs")) {
0339         updateField(amount, m_totalDirectories, &Job::totalDirectoriesChanged);
0340     } else if (unit == QLatin1String("items")) {
0341         updateField(amount, m_totalItems, &Job::totalItemsChanged);
0342     }
0343     updateHasDetails();
0344 }
0345 
0346 void JobPrivate::setProcessedAmount(quint64 amount, const QString &unit)
0347 {
0348     if (unit == QLatin1String("bytes")) {
0349         updateField(amount, m_processedBytes, &Job::processedBytesChanged);
0350     } else if (unit == QLatin1String("files")) {
0351         updateField(amount, m_processedFiles, &Job::processedFilesChanged);
0352     } else if (unit == QLatin1String("dirs")) {
0353         updateField(amount, m_processedDirectories, &Job::processedDirectoriesChanged);
0354     } else if (unit == QLatin1String("items")) {
0355         updateField(amount, m_processedItems, &Job::processedItemsChanged);
0356     }
0357     updateHasDetails();
0358 }
0359 
0360 void JobPrivate::setPercent(uint percent)
0361 {
0362     const int percentage = static_cast<int>(percent);
0363     if (m_percentage != percentage) {
0364         m_percentage = percentage;
0365         Q_EMIT static_cast<Job *>(parent())->percentageChanged(percentage);
0366     }
0367 }
0368 
0369 void JobPrivate::setSpeed(quint64 bytesPerSecond)
0370 {
0371     updateField(bytesPerSecond, m_speed, &Job::speedChanged);
0372     updateHasDetails();
0373 }
0374 
0375 // NOTE infoMessage isn't supposed to be the "Copying..." heading but e.g. a "Connecting to server..." status message
0376 // JobViewV1/V2 got that wrong but JobView3 uses "title" and "infoMessage" correctly respectively.
0377 void JobPrivate::setInfoMessage(const QString &infoMessage)
0378 {
0379     updateField(infoMessage, m_summary, &Job::summaryChanged);
0380 }
0381 
0382 bool JobPrivate::setDescriptionField(uint number, const QString &name, const QString &value)
0383 {
0384     bool dirty = false;
0385     if (number == 0) {
0386         dirty |= updateField(name, m_descriptionLabel1, &Job::descriptionLabel1Changed);
0387         dirty |= updateField(value, m_descriptionValue1, &Job::descriptionValue1Changed);
0388     } else if (number == 1) {
0389         dirty |= updateField(name, m_descriptionLabel2, &Job::descriptionLabel2Changed);
0390         dirty |= updateField(value, m_descriptionValue2, &Job::descriptionValue2Changed);
0391     }
0392     if (dirty) {
0393         Q_EMIT static_cast<Job *>(parent())->descriptionUrlChanged();
0394         updateHasDetails();
0395     }
0396 
0397     return false;
0398 }
0399 
0400 void JobPrivate::clearDescriptionField(uint number)
0401 {
0402     setDescriptionField(number, QString(), QString());
0403 }
0404 
0405 void JobPrivate::setDestUrl(const QDBusVariant &urlVariant)
0406 {
0407     QUrl destUrl = QUrl(urlVariant.variant().toUrl().adjusted(QUrl::StripTrailingSlash)); // urgh
0408     if (destUrl.scheme().isEmpty()) {
0409         qCInfo(NOTIFICATIONMANAGER) << "Job from" << m_applicationName << "set a destUrl" << destUrl
0410                                     << "without a scheme (assuming 'file'), this is an application bug!";
0411         destUrl.setScheme(QStringLiteral("file"));
0412     }
0413 
0414     updateField(destUrl, m_destUrl, &Job::destUrlChanged);
0415 }
0416 
0417 void JobPrivate::setError(uint errorCode)
0418 {
0419     static_cast<Job *>(parent())->setError(errorCode);
0420 }
0421 
0422 // JobViewV3
0423 void JobPrivate::terminate(uint errorCode, const QString &errorMessage, const QVariantMap &hints)
0424 {
0425     Q_UNUSED(hints) // reserved for future extension
0426 
0427     Job *job = static_cast<Job *>(parent());
0428     job->setError(errorCode);
0429     job->setErrorText(errorMessage);
0430 
0431     // Request show just before changing state to stopped, so we're not discarded
0432     if (m_showConditions.testFlag(ShowCondition::OnTermination)) {
0433         requestShow();
0434     }
0435 
0436     job->setState(Notifications::JobStateStopped);
0437     finish();
0438 }
0439 
0440 void JobPrivate::update(const QVariantMap &properties)
0441 {
0442     auto end = properties.end();
0443 
0444     auto it = properties.find(QStringLiteral("title"));
0445     if (it != end) {
0446         updateField(it->toString(), m_summary, &Job::summaryChanged);
0447     }
0448 
0449     it = properties.find(QStringLiteral("infoMessage"));
0450     if (it != end) {
0451         // InfoMessage is exposed via text()/BodyRole, not via public API, hence no public signal
0452         const QString infoMessage = it->toString();
0453         if (m_infoMessage != infoMessage) {
0454             m_infoMessage = it->toString();
0455             Q_EMIT infoMessageChanged();
0456         }
0457     }
0458 
0459     it = properties.find(QStringLiteral("percent"));
0460     if (it != end) {
0461         setPercent(it->toUInt());
0462     }
0463 
0464     it = properties.find(QStringLiteral("destUrl"));
0465     if (it != end) {
0466         const QUrl destUrl = QUrl(it->toUrl().adjusted(QUrl::StripTrailingSlash)); // urgh
0467         updateField(destUrl, m_destUrl, &Job::destUrlChanged);
0468     }
0469 
0470     it = properties.find(QStringLiteral("speed"));
0471     if (it != end) {
0472         setSpeed(it->value<qulonglong>());
0473     }
0474 
0475     updateFieldFromProperties(properties, QStringLiteral("processedFiles"), m_processedFiles, &Job::processedFilesChanged);
0476     updateFieldFromProperties(properties, QStringLiteral("processedBytes"), m_processedBytes, &Job::processedBytesChanged);
0477     updateFieldFromProperties(properties, QStringLiteral("processedDirectories"), m_processedDirectories, &Job::processedDirectoriesChanged);
0478     updateFieldFromProperties(properties, QStringLiteral("processedItems"), m_processedItems, &Job::processedItemsChanged);
0479 
0480     updateFieldFromProperties(properties, QStringLiteral("totalFiles"), m_totalFiles, &Job::totalFilesChanged);
0481     updateFieldFromProperties(properties, QStringLiteral("totalBytes"), m_totalBytes, &Job::totalBytesChanged);
0482     updateFieldFromProperties(properties, QStringLiteral("totalDirectories"), m_totalDirectories, &Job::totalDirectoriesChanged);
0483     updateFieldFromProperties(properties, QStringLiteral("totalItems"), m_totalItems, &Job::totalItemsChanged);
0484 
0485     updateFieldFromProperties(properties, QStringLiteral("descriptionLabel1"), m_descriptionLabel1, &Job::descriptionLabel1Changed);
0486     updateFieldFromProperties(properties, QStringLiteral("descriptionValue1"), m_descriptionValue1, &Job::descriptionValue1Changed);
0487     updateFieldFromProperties(properties, QStringLiteral("descriptionLabel2"), m_descriptionLabel2, &Job::descriptionLabel2Changed);
0488     updateFieldFromProperties(properties, QStringLiteral("descriptionValue2"), m_descriptionValue2, &Job::descriptionValue2Changed);
0489 
0490     it = properties.find(QStringLiteral("suspended"));
0491     if (it != end) {
0492         setSuspended(it->toBool());
0493     }
0494 
0495     updateHasDetails();
0496 
0497     if (!m_summary.isEmpty() && m_showConditions.testFlag(ShowCondition::OnSummary)) {
0498         requestShow();
0499     }
0500 }