1 /*
2     SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@privat.broulik.de>
3 
4     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
5 */
6 
7 #include "job_p.h"
8 
9 #include "debug.h"
10 
11 #include <QDBusConnection>
12 #include <QDebug>
13 #include <QTimer>
14 
15 #include <KFilePlacesModel>
16 #include <KLocalizedString>
17 #include <KShell>
18 
19 #include <kio/global.h>
20 
21 #include "jobviewv2adaptor.h"
22 #include "jobviewv3adaptor.h"
23 
24 using namespace NotificationManager;
25 
JobPrivate(uint id,QObject * parent)26 JobPrivate::JobPrivate(uint id, QObject *parent)
27     : QObject(parent)
28     , m_id(id)
29 {
30     m_showTimer.setSingleShot(true);
31     connect(&m_showTimer, &QTimer::timeout, this, &JobPrivate::requestShow);
32 
33     m_objectPath.setPath(QStringLiteral("/org/kde/notificationmanager/jobs/JobView_%1").arg(id));
34 
35     // TODO also v1? it's identical to V2 except it doesn't have setError method so supporting it should be easy
36     new JobViewV2Adaptor(this);
37     new JobViewV3Adaptor(this);
38 
39     QDBusConnection::sessionBus().registerObject(m_objectPath.path(), this);
40 }
41 
42 JobPrivate::~JobPrivate() = default;
43 
requestShow()44 void JobPrivate::requestShow()
45 {
46     if (!m_showRequested) {
47         m_showRequested = true;
48         Q_EMIT showRequested();
49     }
50 }
51 
objectPath() const52 QDBusObjectPath JobPrivate::objectPath() const
53 {
54     return m_objectPath;
55 }
56 
createPlacesModel()57 QSharedPointer<KFilePlacesModel> JobPrivate::createPlacesModel()
58 {
59     static QWeakPointer<KFilePlacesModel> s_instance;
60     if (!s_instance) {
61         QSharedPointer<KFilePlacesModel> ptr(new KFilePlacesModel());
62         s_instance = ptr.toWeakRef();
63         return ptr;
64     }
65     return s_instance.toStrongRef();
66 }
67 
localFileOrUrl(const QString & urlString)68 QUrl JobPrivate::localFileOrUrl(const QString &urlString)
69 {
70     QUrl url(urlString);
71     if (url.scheme().isEmpty()) {
72         url = QUrl::fromLocalFile(urlString);
73     }
74     return url;
75 }
76 
destUrl() const77 QUrl JobPrivate::destUrl() const
78 {
79     QUrl url = m_destUrl;
80     // In case of a single file and no destUrl, try using the second label (most likely "Destination")...
81     if (!url.isValid() && m_totalFiles == 1) {
82         url = localFileOrUrl(m_descriptionValue2).adjusted(QUrl::RemoveFilename | QUrl::StripTrailingSlash);
83     }
84     return url;
85 }
86 
prettyUrl(const QUrl & _url) const87 QString JobPrivate::prettyUrl(const QUrl &_url) const
88 {
89     QUrl url(_url);
90 
91     if (!url.isValid()) {
92         return QString();
93     }
94 
95     if (url.path().endsWith(QLatin1String("/."))) {
96         url.setPath(url.path().chopped(2));
97     }
98 
99     if (!m_placesModel) {
100         m_placesModel = createPlacesModel();
101     }
102 
103     // Mimic KUrlNavigator and show a pretty place name,
104     // for example Documents/foo/bar rather than /home/user/Documents/foo/bar
105     const QModelIndex closestIdx = m_placesModel->closestItem(url);
106     if (closestIdx.isValid()) {
107         const QUrl placeUrl = m_placesModel->url(closestIdx);
108 
109         QString text = m_placesModel->text(closestIdx);
110 
111         QString pathInsidePlace = url.path().mid(placeUrl.path().length());
112 
113         if (!pathInsidePlace.isEmpty() && !pathInsidePlace.startsWith(QLatin1Char('/'))) {
114             pathInsidePlace.prepend(QLatin1Char('/'));
115         }
116 
117         if (pathInsidePlace != QLatin1Char('/')) {
118             text.append(pathInsidePlace);
119         }
120 
121         return text;
122     }
123 
124     if (url.isLocalFile()) {
125         return KShell::tildeCollapse(url.toLocalFile());
126     }
127 
128     return url.toDisplayString(QUrl::RemoveUserInfo);
129 }
130 
updateHasDetails()131 void JobPrivate::updateHasDetails()
132 {
133     // clang-format off
134     const bool hasDetails = m_totalBytes > 0
135         || m_totalFiles > 0
136         || m_totalDirectories > 0
137         || m_totalItems > 0
138         || m_processedBytes > 0
139         || m_processedFiles > 0
140         || m_processedDirectories > 0
141         || m_processedItems > 0
142         || !m_descriptionValue1.isEmpty()
143         || !m_descriptionValue2.isEmpty()
144         || m_speed > 0;
145     // clang-format on
146 
147     if (m_hasDetails != hasDetails) {
148         m_hasDetails = hasDetails;
149         emit static_cast<Job *>(parent())->hasDetailsChanged();
150     }
151 }
152 
text() const153 QString JobPrivate::text() const
154 {
155     if (!m_errorText.isEmpty()) {
156         return m_errorText;
157     }
158 
159     if (!m_infoMessage.isEmpty()) {
160         return m_infoMessage;
161     }
162 
163     const QUrl destUrl = this->destUrl();
164     const QString prettyDestUrl = prettyUrl(destUrl);
165 
166     QString destUrlString;
167     if (!prettyDestUrl.isEmpty()) {
168         // Turn destination into a clickable hyperlink
169         destUrlString = QStringLiteral("<a href=\"%1\">%2</a>").arg(destUrl.toString(QUrl::PrettyDecoded), prettyDestUrl.toHtmlEscaped());
170     }
171 
172     if (m_totalFiles == 0) {
173         if (!destUrlString.isEmpty()) {
174             if (m_processedFiles > 0) {
175                 return i18ncp("Copying n files to location", "%1 file to %2", "%1 files to %2", m_processedFiles, destUrlString);
176             }
177             return i18nc("Copying unknown amount of files to location", "to %1", destUrlString);
178         } else if (m_processedFiles > 0) {
179             return i18ncp("Copying n files", "%1 file", "%1 files", m_processedFiles);
180         }
181     } else if (m_totalFiles == 1) {
182         const QString currentFileName = descriptionUrl().fileName().toHtmlEscaped();
183         if (!destUrlString.isEmpty()) {
184             if (!currentFileName.isEmpty()) {
185                 return i18nc("Copying file to location", "%1 to %2", currentFileName, destUrlString);
186             } else {
187                 return i18ncp("Copying n files to location", "%1 file to %2", "%1 files to %2", m_totalFiles, destUrlString);
188             }
189         } else if (!currentFileName.isEmpty()) {
190             return currentFileName;
191         } else {
192             return i18ncp("Copying n files", "%1 file", "%1 files", m_totalFiles);
193         }
194     } else if (m_totalFiles > 1) {
195         if (!destUrlString.isEmpty()) {
196             if (m_processedFiles > 0 && m_processedFiles <= m_totalFiles) {
197                 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);
198             }
199             return i18ncp("Copying n files to location",
200                           "%1 file to %2",
201                           "%1 files to %2",
202                           m_processedFiles > 0 ? m_processedFiles : m_totalFiles,
203                           destUrlString);
204         }
205 
206         if (m_processedFiles > 0 && m_processedFiles <= m_totalFiles) {
207             return i18ncp("Copying n of m files", "%2 of %1 file", "%2 of %1 files", m_totalFiles, m_processedFiles);
208         }
209 
210         return i18ncp("Copying n files", "%1 file", "%1 files", m_processedFiles > 0 ? m_processedFiles : m_totalFiles);
211     }
212 
213     qCInfo(NOTIFICATIONMANAGER) << "Failed to generate job text for job with following properties:";
214     qCInfo(NOTIFICATIONMANAGER) << "  processedFiles =" << m_processedFiles << ", totalFiles =" << m_totalFiles
215                                 << ", current file name =" << descriptionUrl().fileName() << ", destination url string =" << this->destUrl();
216     qCInfo(NOTIFICATIONMANAGER) << "label1 =" << m_descriptionLabel1 << ", value1 =" << m_descriptionValue1 << ", label2 =" << m_descriptionLabel2
217                                 << ", value2 =" << m_descriptionValue2;
218 
219     return QString();
220 }
221 
delayedShow(std::chrono::milliseconds delay,ShowConditions showConditions)222 void JobPrivate::delayedShow(std::chrono::milliseconds delay, ShowConditions showConditions)
223 {
224     m_showConditions = showConditions;
225 
226     if (showConditions.testFlag(ShowCondition::OnTimeout)) {
227         m_showTimer.start(delay);
228     }
229 }
230 
kill()231 void JobPrivate::kill()
232 {
233     emit cancelRequested();
234 
235     // In case the application doesn't respond, remove the job
236     if (!m_killTimer) {
237         m_killTimer = new QTimer(this);
238         m_killTimer->setSingleShot(true);
239         connect(m_killTimer, &QTimer::timeout, this, [this] {
240             qCWarning(NOTIFICATIONMANAGER) << "Application" << m_applicationName << "failed to respond to a cancel request in time";
241             Job *job = static_cast<Job *>(parent());
242             job->setError(KIO::ERR_USER_CANCELED);
243             job->setState(Notifications::JobStateStopped);
244             finish();
245         });
246     }
247 
248     if (!m_killTimer->isActive()) {
249         m_killTimer->start(2000);
250     }
251 }
252 
descriptionUrl() const253 QUrl JobPrivate::descriptionUrl() const
254 {
255     QUrl url = localFileOrUrl(m_descriptionValue2);
256     if (!url.isValid()) {
257         url = localFileOrUrl(m_descriptionValue1);
258     }
259     return url;
260 }
261 
finish()262 void JobPrivate::finish()
263 {
264     // Unregister the dbus service since the client is done with it
265     QDBusConnection::sessionBus().unregisterObject(m_objectPath.path());
266 
267     // When user canceled transfer, remove it without notice
268     if (m_error == KIO::ERR_USER_CANCELED) {
269         emit closed();
270         return;
271     }
272 
273     if (m_killTimer) {
274         m_killTimer->stop();
275     }
276 
277     Job *job = static_cast<Job *>(parent());
278     // update timestamp
279     job->resetUpdated();
280     // when it was hidden in history, bring it up again
281     job->setDismissed(false);
282 }
283 
284 // JobViewV2
terminate(const QString & errorMessage)285 void JobPrivate::terminate(const QString &errorMessage)
286 {
287     Job *job = static_cast<Job *>(parent());
288     // forward to JobViewV3. In V2 we get a setError before a terminate
289     // so we want to forward the current error to the V3 call.
290     terminate(job->error(), errorMessage, {});
291 }
292 
setSuspended(bool suspended)293 void JobPrivate::setSuspended(bool suspended)
294 {
295     Job *job = static_cast<Job *>(parent());
296     if (suspended) {
297         job->setState(Notifications::JobStateSuspended);
298     } else {
299         job->setState(Notifications::JobStateRunning);
300     }
301 }
302 
setTotalAmount(quint64 amount,const QString & unit)303 void JobPrivate::setTotalAmount(quint64 amount, const QString &unit)
304 {
305     if (unit == QLatin1String("bytes")) {
306         updateField(amount, m_totalBytes, &Job::totalBytesChanged);
307     } else if (unit == QLatin1String("files")) {
308         updateField(amount, m_totalFiles, &Job::totalFilesChanged);
309     } else if (unit == QLatin1String("dirs")) {
310         updateField(amount, m_totalDirectories, &Job::totalDirectoriesChanged);
311     } else if (unit == QLatin1String("items")) {
312         updateField(amount, m_totalItems, &Job::totalItemsChanged);
313     }
314     updateHasDetails();
315 }
316 
setProcessedAmount(quint64 amount,const QString & unit)317 void JobPrivate::setProcessedAmount(quint64 amount, const QString &unit)
318 {
319     if (unit == QLatin1String("bytes")) {
320         updateField(amount, m_processedBytes, &Job::processedBytesChanged);
321     } else if (unit == QLatin1String("files")) {
322         updateField(amount, m_processedFiles, &Job::processedFilesChanged);
323     } else if (unit == QLatin1String("dirs")) {
324         updateField(amount, m_processedDirectories, &Job::processedDirectoriesChanged);
325     } else if (unit == QLatin1String("items")) {
326         updateField(amount, m_processedItems, &Job::processedItemsChanged);
327     }
328     updateHasDetails();
329 }
330 
setPercent(uint percent)331 void JobPrivate::setPercent(uint percent)
332 {
333     const int percentage = static_cast<int>(percent);
334     if (m_percentage != percentage) {
335         m_percentage = percentage;
336         emit static_cast<Job *>(parent())->percentageChanged(percentage);
337     }
338 }
339 
setSpeed(quint64 bytesPerSecond)340 void JobPrivate::setSpeed(quint64 bytesPerSecond)
341 {
342     updateField(bytesPerSecond, m_speed, &Job::speedChanged);
343     updateHasDetails();
344 }
345 
346 // NOTE infoMessage isn't supposed to be the "Copying..." heading but e.g. a "Connecting to server..." status message
347 // JobViewV1/V2 got that wrong but JobView3 uses "title" and "infoMessage" correctly respectively.
setInfoMessage(const QString & infoMessage)348 void JobPrivate::setInfoMessage(const QString &infoMessage)
349 {
350     updateField(infoMessage, m_summary, &Job::summaryChanged);
351 }
352 
setDescriptionField(uint number,const QString & name,const QString & value)353 bool JobPrivate::setDescriptionField(uint number, const QString &name, const QString &value)
354 {
355     bool dirty = false;
356     if (number == 0) {
357         dirty |= updateField(name, m_descriptionLabel1, &Job::descriptionLabel1Changed);
358         dirty |= updateField(value, m_descriptionValue1, &Job::descriptionValue1Changed);
359     } else if (number == 1) {
360         dirty |= updateField(name, m_descriptionLabel2, &Job::descriptionLabel2Changed);
361         dirty |= updateField(value, m_descriptionValue2, &Job::descriptionValue2Changed);
362     }
363     if (dirty) {
364         emit static_cast<Job *>(parent())->descriptionUrlChanged();
365         updateHasDetails();
366     }
367 
368     return false;
369 }
370 
clearDescriptionField(uint number)371 void JobPrivate::clearDescriptionField(uint number)
372 {
373     setDescriptionField(number, QString(), QString());
374 }
375 
setDestUrl(const QDBusVariant & urlVariant)376 void JobPrivate::setDestUrl(const QDBusVariant &urlVariant)
377 {
378     QUrl destUrl = QUrl(urlVariant.variant().toUrl().adjusted(QUrl::StripTrailingSlash)); // urgh
379     if (destUrl.scheme().isEmpty()) {
380         qCInfo(NOTIFICATIONMANAGER) << "Job from" << m_applicationName << "set a destUrl" << destUrl
381                                     << "without a scheme (assuming 'file'), this is an application bug!";
382         destUrl.setScheme(QStringLiteral("file"));
383     }
384 
385     updateField(destUrl, m_destUrl, &Job::destUrlChanged);
386 }
387 
setError(uint errorCode)388 void JobPrivate::setError(uint errorCode)
389 {
390     static_cast<Job *>(parent())->setError(errorCode);
391 }
392 
393 // JobViewV3
terminate(uint errorCode,const QString & errorMessage,const QVariantMap & hints)394 void JobPrivate::terminate(uint errorCode, const QString &errorMessage, const QVariantMap &hints)
395 {
396     Q_UNUSED(hints) // reserved for future extension
397 
398     Job *job = static_cast<Job *>(parent());
399     job->setError(errorCode);
400     job->setErrorText(errorMessage);
401 
402     // Request show just before changing state to stopped, so we're not discarded
403     if (m_showConditions.testFlag(ShowCondition::OnTermination)) {
404         requestShow();
405     }
406 
407     job->setState(Notifications::JobStateStopped);
408     finish();
409 }
410 
update(const QVariantMap & properties)411 void JobPrivate::update(const QVariantMap &properties)
412 {
413     auto end = properties.end();
414 
415     auto it = properties.find(QStringLiteral("title"));
416     if (it != end) {
417         updateField(it->toString(), m_summary, &Job::summaryChanged);
418     }
419 
420     it = properties.find(QStringLiteral("infoMessage"));
421     if (it != end) {
422         // InfoMessage is exposed via text()/BodyRole, not via public API, hence no public signal
423         const QString infoMessage = it->toString();
424         if (m_infoMessage != infoMessage) {
425             m_infoMessage = it->toString();
426             emit infoMessageChanged();
427         }
428     }
429 
430     it = properties.find(QStringLiteral("percent"));
431     if (it != end) {
432         setPercent(it->toUInt());
433     }
434 
435     it = properties.find(QStringLiteral("destUrl"));
436     if (it != end) {
437         const QUrl destUrl = QUrl(it->toUrl().adjusted(QUrl::StripTrailingSlash)); // urgh
438         updateField(destUrl, m_destUrl, &Job::destUrlChanged);
439     }
440 
441     it = properties.find(QStringLiteral("speed"));
442     if (it != end) {
443         setSpeed(it->value<qulonglong>());
444     }
445 
446     updateFieldFromProperties(properties, QStringLiteral("processedFiles"), m_processedFiles, &Job::processedFilesChanged);
447     updateFieldFromProperties(properties, QStringLiteral("processedBytes"), m_processedBytes, &Job::processedBytesChanged);
448     updateFieldFromProperties(properties, QStringLiteral("processedDirectories"), m_processedDirectories, &Job::processedDirectoriesChanged);
449     updateFieldFromProperties(properties, QStringLiteral("processedItems"), m_processedItems, &Job::processedItemsChanged);
450 
451     updateFieldFromProperties(properties, QStringLiteral("totalFiles"), m_totalFiles, &Job::totalFilesChanged);
452     updateFieldFromProperties(properties, QStringLiteral("totalBytes"), m_totalBytes, &Job::totalBytesChanged);
453     updateFieldFromProperties(properties, QStringLiteral("totalDirectories"), m_totalDirectories, &Job::totalDirectoriesChanged);
454     updateFieldFromProperties(properties, QStringLiteral("totalItems"), m_totalItems, &Job::totalItemsChanged);
455 
456     updateFieldFromProperties(properties, QStringLiteral("descriptionLabel1"), m_descriptionLabel1, &Job::descriptionLabel1Changed);
457     updateFieldFromProperties(properties, QStringLiteral("descriptionValue1"), m_descriptionValue1, &Job::descriptionValue1Changed);
458     updateFieldFromProperties(properties, QStringLiteral("descriptionLabel2"), m_descriptionLabel2, &Job::descriptionLabel2Changed);
459     updateFieldFromProperties(properties, QStringLiteral("descriptionValue2"), m_descriptionValue2, &Job::descriptionValue2Changed);
460 
461     it = properties.find(QStringLiteral("suspended"));
462     if (it != end) {
463         setSuspended(it->toBool());
464     }
465 
466     updateHasDetails();
467 
468     if (!m_summary.isEmpty() && m_showConditions.testFlag(ShowCondition::OnSummary)) {
469         requestShow();
470     }
471 }
472