1// SPDX-FileCopyrightText: 2017 Kitsune Ral <kitsune-ral@users.sf.net>
2// SPDX-License-Identifier: LGPL-2.1-or-later
3
4#include "avatar.h"
5
6#include "connection.h"
7#include "logging_categories_p.h"
8
9#include "events/eventcontent.h"
10#include "jobs/mediathumbnailjob.h"
11
12#include <QtCore/QDir>
13#include <QtCore/QPointer>
14#include <QtCore/QStandardPaths>
15#include <QtCore/QStringBuilder>
16#include <QtGui/QPainter>
17
18using namespace Quotient;
19using std::move;
20
21class Q_DECL_HIDDEN Avatar::Private {
22public:
23 explicit Private(QUrl url = {}) : _url(std::move(url)) {}
24 ~Private()
25 {
26 if (isJobPending(job: _thumbnailRequest))
27 _thumbnailRequest->abandon();
28 if (isJobPending(job: _uploadRequest))
29 _uploadRequest->abandon();
30 }
31
32 QImage get(Connection* connection, QSize size,
33 get_callback_t callback) const;
34 bool upload(UploadContentJob* job, upload_callback_t&& callback);
35
36 bool checkUrl(const QUrl& url) const;
37 QString localFile() const;
38
39 QUrl _url;
40
41 // The below are related to image caching, hence mutable
42 mutable QImage _originalImage;
43 mutable std::vector<std::pair<QSize, QImage>> _scaledImages;
44 mutable QSize _requestedSize;
45 mutable enum { Unknown, Cache, Network, Banned } _imageSource = Unknown;
46 mutable QPointer<MediaThumbnailJob> _thumbnailRequest = nullptr;
47 mutable QPointer<BaseJob> _uploadRequest = nullptr;
48 mutable std::vector<get_callback_t> callbacks;
49};
50
51Avatar::Avatar()
52 : d(makeImpl<Private>())
53{}
54
55Avatar::Avatar(QUrl url) : d(makeImpl<Private>(args: std::move(url))) {}
56
57QImage Avatar::get(Connection* connection, int dimension,
58 get_callback_t callback) const
59{
60 return d->get(connection, size: { dimension, dimension }, callback: std::move(callback));
61}
62
63QImage Avatar::get(Connection* connection, int width, int height,
64 get_callback_t callback) const
65{
66 return d->get(connection, size: { width, height }, callback: std::move(callback));
67}
68
69bool Avatar::upload(Connection* connection, const QString& fileName,
70 upload_callback_t callback) const
71{
72 if (isJobPending(job: d->_uploadRequest))
73 return false;
74 return d->upload(job: connection->uploadFile(fileName), callback: std::move(callback));
75}
76
77bool Avatar::upload(Connection* connection, QIODevice* source,
78 upload_callback_t callback) const
79{
80 if (isJobPending(job: d->_uploadRequest) || !source->isReadable())
81 return false;
82 return d->upload(job: connection->uploadContent(contentSource: source), callback: std::move(callback));
83}
84
85QString Avatar::mediaId() const { return d->_url.authority() + d->_url.path(); }
86
87QImage Avatar::Private::get(Connection* connection, QSize size,
88 get_callback_t callback) const
89{
90 if (!callback) {
91 qCCritical(MAIN) << "Null callbacks are not allowed in Avatar::get";
92 Q_ASSERT(false);
93 }
94
95 if (_imageSource == Unknown && _originalImage.load(fileName: localFile())) {
96 _imageSource = Cache;
97 _requestedSize = _originalImage.size();
98 }
99
100 // Alternating between longer-width and longer-height requests is a sure way
101 // to trick the below code into constantly getting another image from
102 // the server because the existing one is alleged unsatisfactory.
103 // Client authors can only blame themselves if they do so.
104 if (((_imageSource == Unknown && !_thumbnailRequest)
105 || size.width() > _requestedSize.width()
106 || size.height() > _requestedSize.height())
107 && checkUrl(url: _url)) {
108 qCDebug(MAIN) << "Getting avatar from" << _url.toString();
109 _requestedSize = size;
110 if (isJobPending(job: _thumbnailRequest))
111 _thumbnailRequest->abandon();
112 if (callback)
113 callbacks.emplace_back(args: std::move(callback));
114 _thumbnailRequest = connection->getThumbnail(url: _url, requestedSize: size);
115 QObject::connect(sender: _thumbnailRequest, signal: &MediaThumbnailJob::success,
116 context: _thumbnailRequest, slot: [this] {
117 _imageSource = Network;
118 _originalImage = _thumbnailRequest->scaledThumbnail(
119 toSize: _requestedSize);
120 _originalImage.save(fileName: localFile());
121 _scaledImages.clear();
122 for (const auto& n : callbacks)
123 n();
124 callbacks.clear();
125 });
126 }
127
128 for (const auto& [scaledSize, scaledImage] : _scaledImages)
129 if (scaledSize == size)
130 return scaledImage;
131 auto result = _originalImage.isNull()
132 ? QImage()
133 : _originalImage.scaled(s: size, aspectMode: Qt::KeepAspectRatio,
134 mode: Qt::SmoothTransformation);
135 _scaledImages.emplace_back(args&: size, args&: result);
136 return result;
137}
138
139bool Avatar::Private::upload(UploadContentJob* job, upload_callback_t &&callback)
140{
141 _uploadRequest = job;
142 if (!isJobPending(job: _uploadRequest))
143 return false;
144 _uploadRequest->connect(sender: _uploadRequest, signal: &BaseJob::success, context: _uploadRequest,
145 slot: [job, callback] { callback(job->contentUri()); });
146 return true;
147}
148
149bool Avatar::Private::checkUrl(const QUrl& url) const
150{
151 if (_imageSource == Banned || url.isEmpty())
152 return false;
153
154 // FIXME: Make "mxc" a library-wide constant and maybe even make
155 // the URL checker a Connection(?) method.
156 if (!url.isValid() || url.scheme() != "mxc"_ls || url.path().count(c: u'/') != 1) {
157 qCWarning(MAIN) << "Avatar URL is invalid or not mxc-based:"
158 << url.toDisplayString();
159 _imageSource = Banned;
160 }
161 return _imageSource != Banned;
162}
163
164QString Avatar::Private::localFile() const
165{
166 static const auto cachePath = cacheLocation(QStringLiteral("avatars"));
167 return cachePath % _url.authority() % u'_' % _url.fileName() % ".png"_ls;
168}
169
170QUrl Avatar::url() const { return d->_url; }
171
172bool Avatar::updateUrl(const QUrl& newUrl)
173{
174 if (newUrl == d->_url)
175 return false;
176
177 d->_url = newUrl;
178 d->_imageSource = Private::Unknown;
179 if (isJobPending(job: d->_thumbnailRequest))
180 d->_thumbnailRequest->abandon();
181 return true;
182}
183