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