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