1 /*
2 SPDX-FileCopyrightText: 2007 Paolo Capriotti <p.capriotti@gmail.com>
3
4 SPDX-License-Identifier: GPL-2.0-or-later
5 */
6
7 #ifndef BACKGROUNDLISTMODEL_CPP
8 #define BACKGROUNDLISTMODEL_CPP
9
10 #include "backgroundlistmodel.h"
11 #include "debug.h"
12
13 #include <QDir>
14 #include <QElapsedTimer>
15 #include <QFile>
16 #include <QFontMetrics>
17 #include <QGuiApplication>
18 #include <QImageReader>
19 #include <QMimeDatabase>
20 #include <QMimeType>
21 #include <QMutexLocker>
22 #include <QStandardPaths>
23 #include <QThreadPool>
24 #include <QUuid>
25
26 #include <KIO/PreviewJob>
27 #include <KLocalizedString>
28 #include <QDebug>
29 #include <kaboutdata.h>
30
31 #include <KPackage/Package>
32 #include <KPackage/PackageLoader>
33
34 #include <KIO/OpenFileManagerWindowJob>
35
36 QStringList BackgroundFinder::s_suffixes;
37 QMutex BackgroundFinder::s_suffixMutex;
38
ImageSizeFinder(const QString & path,QObject * parent)39 ImageSizeFinder::ImageSizeFinder(const QString &path, QObject *parent)
40 : QObject(parent)
41 , m_path(path)
42 {
43 }
44
run()45 void ImageSizeFinder::run()
46 {
47 QImageReader reader(m_path);
48 Q_EMIT sizeFound(m_path, reader.size());
49 }
50
BackgroundListModel(Image * wallpaper,QObject * parent)51 BackgroundListModel::BackgroundListModel(Image *wallpaper, QObject *parent)
52 : QAbstractListModel(parent)
53 , m_wallpaper(wallpaper)
54 {
55 m_imageCache.setMaxCost(10 * 1024 * 1024); // 10 MiB
56
57 connect(&m_dirwatch, &KDirWatch::deleted, this, &BackgroundListModel::removeBackground);
58
59 // TODO: on Qt 4.4 use the ui scale factor
60 QFontMetrics fm(QGuiApplication::font());
61 m_screenshotSize = fm.horizontalAdvance('M') * 15;
62 }
63
64 BackgroundListModel::~BackgroundListModel() = default;
65
roleNames() const66 QHash<int, QByteArray> BackgroundListModel::BackgroundListModel::roleNames() const
67 {
68 return {
69 {Qt::DisplayRole, "display"},
70 {Qt::DecorationRole, "decoration"},
71 {AuthorRole, "author"},
72 {ScreenshotRole, "screenshot"},
73 {ResolutionRole, "resolution"},
74 {PathRole, "path"},
75 {PackageNameRole, "packageName"},
76 {RemovableRole, "removable"},
77 {PendingDeletionRole, "pendingDeletion"},
78 };
79 }
80
removeBackground(const QString & path)81 void BackgroundListModel::removeBackground(const QString &path)
82 {
83 int index = -1;
84 while ((index = indexOf(path)) >= 0) {
85 beginRemoveRows(QModelIndex(), index, index);
86 m_packages.removeAt(index);
87 endRemoveRows();
88 emit countChanged();
89 }
90 }
91
reload()92 void BackgroundListModel::reload()
93 {
94 reload(QStringList());
95 }
96
reload(const QStringList & selected)97 void BackgroundListModel::reload(const QStringList &selected)
98 {
99 if (!m_wallpaper) {
100 beginRemoveRows(QModelIndex(), 0, m_packages.count() - 1);
101 m_packages.clear();
102 endRemoveRows();
103 emit countChanged();
104 return;
105 }
106
107 const QStringList dirs = QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("wallpapers/"), QStandardPaths::LocateDirectory);
108
109 BackgroundFinder *finder = new BackgroundFinder(m_wallpaper.data(), dirs);
110 const auto token = finder->token();
111 connect(finder, &BackgroundFinder::backgroundsFound, this, [this, selected, token](const QStringList &wallpapersFound) {
112 if (token != m_findToken || !m_wallpaper) {
113 return;
114 }
115
116 processPaths(selected + wallpapersFound);
117 m_removableWallpapers = QSet<QString>(selected.constBegin(), selected.constEnd());
118 });
119 m_findToken = token;
120 finder->start();
121 }
122
processPaths(const QStringList & paths)123 void BackgroundListModel::processPaths(const QStringList &paths)
124 {
125 beginResetModel();
126 m_packages.clear();
127
128 QList<KPackage::Package> newPackages;
129 newPackages.reserve(paths.count());
130 for (QString file : paths) {
131 // check if the path is a symlink and if it is,
132 // work with the target rather than the symlink
133 QFileInfo info(file);
134 if (info.isSymLink()) {
135 file = info.symLinkTarget();
136 }
137 // now check if the path contains "contents" part
138 // which could indicate that the file is part of some other
139 // package (could have been symlinked) and we should work
140 // with the package (which can already be present) rather
141 // than just one file from it
142 int contentsIndex = file.indexOf(QLatin1String("contents"));
143
144 // FIXME: additionally check for metadata.desktop being present
145 // which would confirm a package but might be slowing things
146 if (contentsIndex != -1) {
147 file.truncate(contentsIndex);
148 }
149
150 // so now we have a path to a package, check if we're not
151 // processing the same path twice (this is different from
152 // the "!contains(file)" call lower down, that one checks paths
153 // already in the model and does not include the paths
154 // that are being checked in here); we want to check for duplicates
155 // if and only if we actually changed the path (so the conditions from above
156 // are reused here as that means we did change the path)
157 if ((info.isSymLink() || contentsIndex != -1) && paths.contains(file)) {
158 continue;
159 }
160
161 if (!contains(file) && QFile::exists(file)) {
162 KPackage::Package package = KPackage::PackageLoader::self()->loadPackage(QStringLiteral("Wallpaper/Images"));
163 package.setPath(file);
164 if (package.isValid()) {
165 m_wallpaper->findPreferedImageInPackage(package);
166 newPackages << package;
167 }
168 }
169 }
170
171 // add new files to dirwatch
172 for (const KPackage::Package &b : qAsConst(newPackages)) {
173 if (!m_dirwatch.contains(b.path())) {
174 m_dirwatch.addDir(b.path());
175 }
176 }
177
178 if (!newPackages.isEmpty()) {
179 m_packages.append(newPackages);
180 }
181 endResetModel();
182 emit countChanged();
183 // qCDebug(IMAGEWALLPAPER) << t.elapsed();
184 }
185
addBackground(const QString & path)186 void BackgroundListModel::addBackground(const QString &path)
187 {
188 if (!m_wallpaper || !contains(path)) {
189 if (!m_dirwatch.contains(path)) {
190 m_dirwatch.addFile(path);
191 }
192 beginInsertRows(QModelIndex(), 0, 0);
193 KPackage::Package package = KPackage::PackageLoader::self()->loadPackage(QStringLiteral("Wallpaper/Images"));
194
195 m_removableWallpapers.insert(path);
196 package.setPath(path);
197 m_wallpaper->findPreferedImageInPackage(package);
198 qCDebug(IMAGEWALLPAPER) << "Background added " << path << package.isValid();
199 m_packages.prepend(package);
200 endInsertRows();
201 emit countChanged();
202 }
203 }
204
indexOf(const QString & path) const205 int BackgroundListModel::indexOf(const QString &path) const
206 {
207 for (int i = 0; i < m_packages.size(); i++) {
208 // packages will end with a '/', but the path passed in may not
209 QString package = m_packages[i].path();
210 if (package.endsWith(QChar::fromLatin1('/'))) {
211 package.chop(1);
212 }
213 // remove eventual file:///
214 const QString filteredPath = QUrl(path).path();
215
216 if (filteredPath.startsWith(package)) {
217 // FIXME: ugly hack to make a difference between local files in the same dir
218 // package->path does not contain the actual file name
219 qCDebug(IMAGEWALLPAPER) << "prefix" << m_packages[i].contentsPrefixPaths() << m_packages[i].filePath("preferred") << package << filteredPath;
220 QStringList ps = m_packages[i].contentsPrefixPaths();
221 bool prefixempty = ps.count() == 0;
222 if (!prefixempty) {
223 prefixempty = ps[0].isEmpty();
224 }
225
226 // For local files (user wallpapers) filteredPath == m_packages[i].filePath("preferred")
227 // E.X. filteredPath = "/home/kde/next.png"
228 // m_packages[i].filePath("preferred") = "/home/kde/next.png"
229 //
230 // But for the system wallpapers this is not the case. filteredPath != m_packages[i].filePath("preferred")
231 // E.X. filteredPath = /usr/share/wallpapers/Next/"
232 // m_packages[i].filePath("preferred") = "/usr/share/wallpapers/Next/contents/images/1920x1080.png"
233 if ((filteredPath == m_packages[i].filePath("preferred")) || m_packages[i].filePath("preferred").contains(filteredPath)) {
234 return i;
235 }
236 }
237 }
238 return -1;
239 }
240
contains(const QString & path) const241 bool BackgroundListModel::contains(const QString &path) const
242 {
243 // qCDebug(IMAGEWALLPAPER) << "WP contains: " << path << indexOf(path).isValid();
244 return indexOf(path) >= 0;
245 }
246
rowCount(const QModelIndex & parent) const247 int BackgroundListModel::rowCount(const QModelIndex &parent) const
248 {
249 return parent.isValid() ? 0 : m_packages.size();
250 }
251
bestSize(const KPackage::Package & package) const252 QSize BackgroundListModel::bestSize(const KPackage::Package &package) const
253 {
254 if (m_sizeCache.contains(package.path())) {
255 return m_sizeCache.value(package.path());
256 }
257
258 const QString image = package.filePath("preferred");
259 if (image.isEmpty()) {
260 return QSize();
261 }
262
263 ImageSizeFinder *finder = new ImageSizeFinder(image);
264 connect(finder, &ImageSizeFinder::sizeFound, this, &BackgroundListModel::sizeFound);
265 QThreadPool::globalInstance()->start(finder);
266
267 QSize size(-1, -1);
268 const_cast<BackgroundListModel *>(this)->m_sizeCache.insert(package.path(), size);
269 return size;
270 }
271
sizeFound(const QString & path,const QSize & s)272 void BackgroundListModel::sizeFound(const QString &path, const QSize &s)
273 {
274 if (!m_wallpaper) {
275 return;
276 }
277
278 int idx = indexOf(path);
279 if (idx >= 0) {
280 KPackage::Package package = m_packages.at(idx);
281 m_sizeCache.insert(package.path(), s);
282 emit dataChanged(index(idx, 0), index(idx, 0));
283 }
284 }
285
data(const QModelIndex & index,int role) const286 QVariant BackgroundListModel::data(const QModelIndex &index, int role) const
287 {
288 if (!index.isValid()) {
289 return QVariant();
290 }
291
292 if (index.row() >= m_packages.size()) {
293 return QVariant();
294 }
295
296 KPackage::Package b = package(index.row());
297 if (!b.isValid()) {
298 return QVariant();
299 }
300
301 switch (role) {
302 case Qt::DisplayRole: {
303 QString title = b.metadata().isValid() ? b.metadata().name() : QString();
304
305 if (title.isEmpty()) {
306 return QFileInfo(b.filePath("preferred")).completeBaseName();
307 }
308
309 return title;
310 }
311
312 case ScreenshotRole: {
313 const QString path = b.filePath("preferred");
314
315 QPixmap *cachedPreview = m_imageCache.object(path);
316 if (cachedPreview) {
317 return *cachedPreview;
318 }
319
320 const QUrl url = QUrl::fromLocalFile(path);
321 const QPersistentModelIndex persistentIndex(index);
322 if (!m_previewJobsUrls.contains(persistentIndex) && url.isValid()) {
323 KFileItemList list;
324 list.append(KFileItem(url, QString(), 0));
325 QStringList availablePlugins = KIO::PreviewJob::availablePlugins();
326 KIO::PreviewJob *job = KIO::filePreview(list, QSize(m_screenshotSize * 1.6, m_screenshotSize), &availablePlugins);
327 job->setIgnoreMaximumSize(true);
328 connect(job, &KIO::PreviewJob::gotPreview, this, &BackgroundListModel::showPreview);
329 connect(job, &KIO::PreviewJob::failed, this, &BackgroundListModel::previewFailed);
330 const_cast<BackgroundListModel *>(this)->m_previewJobsUrls.insert(persistentIndex, url);
331 }
332
333 return QVariant();
334 }
335
336 case AuthorRole:
337 if (b.metadata().isValid() && !b.metadata().authors().isEmpty()) {
338 return b.metadata().authors().first().name();
339 } else {
340 return QString();
341 }
342
343 case ResolutionRole: {
344 QSize size = bestSize(b);
345
346 if (size.isValid()) {
347 return QString::fromLatin1("%1x%2").arg(size.width()).arg(size.height());
348 }
349
350 return QString();
351 }
352
353 case PathRole:
354 return QUrl::fromLocalFile(b.filePath("preferred"));
355
356 case PackageNameRole:
357 return !b.metadata().isValid() ? b.filePath("preferred") : b.path();
358
359 case RemovableRole: {
360 QString localWallpapers = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + "/wallpapers/";
361 QString path = b.filePath("preferred");
362 return path.startsWith(localWallpapers) || m_removableWallpapers.contains(path);
363 }
364
365 case PendingDeletionRole: {
366 QUrl wallpaperUrl = QUrl::fromLocalFile(b.filePath("preferred"));
367 return m_pendingDeletion.contains(wallpaperUrl.toLocalFile()) ? m_pendingDeletion[wallpaperUrl.toLocalFile()] : false;
368 }
369
370 default:
371 return QVariant();
372 }
373
374 Q_UNREACHABLE();
375 }
376
setData(const QModelIndex & index,const QVariant & value,int role)377 bool BackgroundListModel::setData(const QModelIndex &index, const QVariant &value, int role)
378 {
379 if (!index.isValid()) {
380 return false;
381 }
382
383 if (role == PendingDeletionRole) {
384 KPackage::Package b = package(index.row());
385 if (!b.isValid()) {
386 return false;
387 }
388
389 const QUrl wallpaperUrl = QUrl::fromLocalFile(b.filePath("preferred"));
390 m_pendingDeletion[wallpaperUrl.toLocalFile()] = value.toBool();
391
392 emit dataChanged(index, index);
393 return true;
394 }
395
396 return false;
397 }
398
showPreview(const KFileItem & item,const QPixmap & preview)399 void BackgroundListModel::showPreview(const KFileItem &item, const QPixmap &preview)
400 {
401 if (!m_wallpaper) {
402 return;
403 }
404
405 QPersistentModelIndex index = m_previewJobsUrls.key(item.url());
406 m_previewJobsUrls.remove(index);
407
408 if (!index.isValid()) {
409 return;
410 }
411
412 KPackage::Package b = package(index.row());
413 if (!b.isValid()) {
414 return;
415 }
416
417 const int cost = preview.width() * preview.height() * preview.depth() / 8;
418 m_imageCache.insert(b.filePath("preferred"), new QPixmap(preview), cost);
419
420 // qCDebug(IMAGEWALLPAPER) << "WP preview size:" << preview.size();
421 emit dataChanged(index, index);
422 }
423
previewFailed(const KFileItem & item)424 void BackgroundListModel::previewFailed(const KFileItem &item)
425 {
426 m_previewJobsUrls.remove(m_previewJobsUrls.key(item.url()));
427 }
428
package(int index) const429 KPackage::Package BackgroundListModel::package(int index) const
430 {
431 return m_packages.at(index);
432 }
433
openContainingFolder(int rowIndex)434 void BackgroundListModel::openContainingFolder(int rowIndex)
435 {
436 KIO::highlightInFileManager({index(rowIndex, 0).data(PathRole).toUrl()});
437 }
438
setPendingDeletion(int rowIndex,bool pendingDeletion)439 void BackgroundListModel::setPendingDeletion(int rowIndex, bool pendingDeletion)
440 {
441 setData(index(rowIndex, 0), pendingDeletion, PendingDeletionRole);
442 }
443
wallpapersAwaitingDeletion()444 const QStringList BackgroundListModel::wallpapersAwaitingDeletion()
445 {
446 QStringList candidates;
447 for (const KPackage::Package &b : qAsConst(m_packages)) {
448 const QUrl wallpaperUrl = QUrl::fromLocalFile(b.filePath("preferred"));
449 if (m_pendingDeletion.contains(wallpaperUrl.toLocalFile()) && m_pendingDeletion[wallpaperUrl.toLocalFile()]) {
450 candidates << wallpaperUrl.toLocalFile();
451 }
452 }
453
454 return candidates;
455 }
456
BackgroundFinder(Image * wallpaper,const QStringList & paths)457 BackgroundFinder::BackgroundFinder(Image *wallpaper, const QStringList &paths)
458 : QThread(wallpaper)
459 , m_paths(paths)
460 , m_token(QUuid::createUuid().toString())
461 {
462 }
463
~BackgroundFinder()464 BackgroundFinder::~BackgroundFinder()
465 {
466 wait();
467 }
468
token() const469 QString BackgroundFinder::token() const
470 {
471 return m_token;
472 }
473
suffixes()474 QStringList BackgroundFinder::suffixes()
475 {
476 QMutexLocker lock(&s_suffixMutex);
477 if (s_suffixes.isEmpty()) {
478 QSet<QString> suffixes;
479
480 QMimeDatabase db;
481 const auto supportedMimeTypes = QImageReader::supportedMimeTypes();
482 for (const QByteArray &mimeType : supportedMimeTypes) {
483 QMimeType mime(db.mimeTypeForName(mimeType));
484 const QStringList globPatterns = mime.globPatterns();
485 for (const QString &pattern : globPatterns) {
486 suffixes.insert(pattern);
487 }
488 }
489
490 s_suffixes = suffixes.values();
491 }
492
493 return s_suffixes;
494 }
495
isAcceptableSuffix(const QString & suffix)496 bool BackgroundFinder::isAcceptableSuffix(const QString &suffix)
497 {
498 // Despite its name, suffixes() returns a list of glob patterns.
499 // Therefore the file suffix check needs to include the "*." prefix.
500 const QStringList &globPatterns = suffixes();
501 return globPatterns.contains(QLatin1String("*.") + suffix.toLower());
502 }
503
run()504 void BackgroundFinder::run()
505 {
506 QElapsedTimer t;
507 t.start();
508
509 QStringList papersFound;
510
511 QDir dir;
512 dir.setFilter(QDir::AllDirs | QDir::Files | QDir::Readable);
513 dir.setNameFilters(suffixes());
514 KPackage::Package package = KPackage::PackageLoader::self()->loadPackage(QStringLiteral("Wallpaper/Images"));
515
516 int i;
517 for (i = 0; i < m_paths.count(); ++i) {
518 const QString path = m_paths.at(i);
519 dir.setPath(path);
520 const QFileInfoList files = dir.entryInfoList();
521 for (const QFileInfo &wp : files) {
522 if (wp.isDir()) {
523 // qCDebug(IMAGEWALLPAPER) << "scanning directory" << wp.fileName();
524
525 const QString name = wp.fileName();
526 if (name == QString::fromLatin1(".") || name == QString::fromLatin1("..")) {
527 // do nothing
528 continue;
529 }
530
531 const QString filePath = wp.filePath();
532 if (QFile::exists(filePath + QString::fromLatin1("/metadata.desktop")) || QFile::exists(filePath + QString::fromLatin1("/metadata.json"))) {
533 package.setPath(filePath);
534 if (package.isValid()) {
535 if (!package.filePath("images").isEmpty()) {
536 papersFound << package.path();
537 }
538 // qCDebug(IMAGEWALLPAPER) << "adding package" << wp.filePath();
539 continue;
540 }
541 }
542
543 // add this to the directories we should be looking at
544 m_paths.append(filePath);
545 } else {
546 // qCDebug(IMAGEWALLPAPER) << "adding image file" << wp.filePath();
547 papersFound << wp.filePath();
548 }
549 }
550 }
551
552 // qCDebug(IMAGEWALLPAPER) << "WP background found!" << papersFound.size() << "in" << i << "dirs, taking" << t.elapsed() << "ms";
553 Q_EMIT backgroundsFound(papersFound, m_token);
554 deleteLater();
555 }
556
557 #endif // BACKGROUNDLISTMODEL_CPP
558