QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
QGCFileDownload.cc
Go to the documentation of this file.
1#include "QGCFileDownload.h"
2#include "QGCCompression.h"
3#include "QGCCompressionJob.h"
4#include "QGCFileHelper.h"
6
7#include <QtCore/QFile>
8#include <QtCore/QFileInfo>
9#include <QtCore/QStandardPaths>
10#include <QtNetwork/QNetworkAccessManager>
11
12QGC_LOGGING_CATEGORY(QGCFileDownloadLog, "Utilities.QGCFileDownload")
13
14QGCFileDownload::QGCFileDownload(QObject *parent)
15 : QObject(parent)
16 , _networkManager(QGCNetworkHelper::createNetworkManager(this))
17{
18 qCDebug(QGCFileDownloadLog) << "Created" << this;
19}
20
21QGCFileDownload::~QGCFileDownload()
22{
23 qCDebug(QGCFileDownloadLog) << "Destroying" << this;
24 cancel();
25}
26
27// ============================================================================
28// Property Setters
29// ============================================================================
30
31void QGCFileDownload::setAutoDecompress(bool enabled)
32{
33 if (_autoDecompress != enabled) {
34 _autoDecompress = enabled;
35 emit autoDecompressChanged(enabled);
36 }
37}
38
39void QGCFileDownload::setOutputPath(const QString &path)
40{
41 if (_outputPath != path) {
42 _outputPath = path;
43 emit outputPathChanged(path);
44 }
45}
46
47void QGCFileDownload::setExpectedHash(const QString &hash)
48{
49 if (_expectedHash != hash) {
50 _expectedHash = hash;
51 emit expectedHashChanged(hash);
52 }
53}
54
55void QGCFileDownload::setCache(QAbstractNetworkCache *cache)
56{
57 _networkManager->setCache(cache);
58}
59
60void QGCFileDownload::setTimeout(int timeoutMs)
61{
62 _timeoutMs = timeoutMs;
63}
64
65// ============================================================================
66// Public Slots
67// ============================================================================
68
69bool QGCFileDownload::start(const QString &remoteUrl)
70{
72 config.timeoutMs = _timeoutMs;
73 config.allowRedirects = true;
74 return start(remoteUrl, config);
75}
76
77bool QGCFileDownload::start(const QString &remoteUrl, const QGCNetworkHelper::RequestConfig &config)
78{
79 if (isRunning()) {
80 qCWarning(QGCFileDownloadLog) << "Download already in progress";
81 return false;
82 }
83
84 if (remoteUrl.isEmpty()) {
85 qCWarning(QGCFileDownloadLog) << "Empty URL provided";
86 _setErrorString(tr("Empty URL"));
87 return false;
88 }
89
90 // Parse URL
91 QUrl url;
92 if (QGCFileHelper::isLocalPath(remoteUrl)) {
93 url = QUrl::fromLocalFile(QGCFileHelper::toLocalPath(remoteUrl));
94 } else if (remoteUrl.startsWith(QLatin1String("http:")) || remoteUrl.startsWith(QLatin1String("https:"))) {
95 url.setUrl(remoteUrl);
96 } else {
97 // Assume it's a local file path
98 url = QUrl::fromLocalFile(remoteUrl);
99 }
100
101 if (!url.isValid()) {
102 qCWarning(QGCFileDownloadLog) << "Invalid URL:" << remoteUrl;
103 _setErrorString(tr("Invalid URL: %1").arg(remoteUrl));
104 return false;
105 }
106
107 // Reset state
108 _cleanup();
109 _url = url;
110 emit urlChanged(_url);
111 _bytesReceived = 0;
112 emit bytesReceivedChanged(0);
113 _totalBytes = -1;
114 emit totalBytesChanged(-1);
115 _setProgress(0.0);
116 _setErrorString(QString());
117 _finishEmitted = false;
118 _lastResultFromCache = false;
119
120 // Determine output path
121 _localPath = _generateOutputPath(remoteUrl);
122 if (_localPath.isEmpty()) {
123 _setErrorString(tr("Unable to determine output path"));
124 return false;
125 }
126 emit localPathChanged(_localPath);
127
128 // Ensure parent directory exists
129 if (!QGCFileHelper::ensureParentExists(_localPath)) {
130 _setErrorString(tr("Cannot create output directory"));
131 return false;
132 }
133
134 // Open output file for streaming write
135 _outputFile = new QFile(_localPath, this);
136 if (!_outputFile->open(QIODevice::WriteOnly | QIODevice::Truncate)) {
137 _setErrorString(tr("Cannot open output file: %1").arg(_outputFile->errorString()));
138 delete _outputFile;
139 _outputFile = nullptr;
140 return false;
141 }
142
143 // Create request with configuration
144 QNetworkRequest request = QGCNetworkHelper::createRequest(url, config);
145
146 qCDebug(QGCFileDownloadLog) << "Starting download:" << url.toString() << "to" << _localPath;
147
148 // Start download
149 _currentReply = _networkManager->get(request);
150 if (_currentReply == nullptr) {
151 qCWarning(QGCFileDownloadLog) << "QNetworkAccessManager::get failed";
152 _setErrorString(tr("Failed to start download"));
153 _outputFile->close();
154 delete _outputFile;
155 _outputFile = nullptr;
156 return false;
157 }
158
160
161 // Connect signals for streaming download
162 connect(_currentReply, &QNetworkReply::downloadProgress,
163 this, &QGCFileDownload::_onDownloadProgress);
164 connect(_currentReply, &QNetworkReply::readyRead,
165 this, &QGCFileDownload::_onReadyRead);
166 connect(_currentReply, &QNetworkReply::finished,
167 this, &QGCFileDownload::_onDownloadFinished);
168 connect(_currentReply, &QNetworkReply::errorOccurred,
169 this, &QGCFileDownload::_onDownloadError);
170
171 _setState(State::Downloading);
172 return true;
173}
174
176{
177 const bool shouldEmitCancel = (_state != State::Idle && _state != State::Completed
178 && _state != State::Failed && _state != State::Cancelled);
179
180 if (shouldEmitCancel) {
181 _setState(State::Cancelled);
182 _setErrorString(tr("Download cancelled"));
183 _emitFinished(false, QString(), _errorString);
184 }
185
186 if (_currentReply != nullptr) {
187 qCDebug(QGCFileDownloadLog) << "Cancelling download";
188 _currentReply->abort();
189 }
190
191 if (_decompressionJob != nullptr && _decompressionJob->isRunning()) {
192 _decompressionJob->cancel();
193 }
194
195 _cleanup();
196}
197
198// ============================================================================
199// Private Slots
200// ============================================================================
201
202void QGCFileDownload::_onDownloadProgress(qint64 bytesReceived, qint64 totalBytes)
203{
204 _bytesReceived = bytesReceived;
205 emit bytesReceivedChanged(bytesReceived);
206
207 if (totalBytes != _totalBytes) {
208 _totalBytes = totalBytes;
209 emit totalBytesChanged(totalBytes);
210 }
211
212 if (totalBytes > 0) {
213 _setProgress(static_cast<qreal>(bytesReceived) / static_cast<qreal>(totalBytes));
214 }
215
216 emit downloadProgress(bytesReceived, totalBytes);
217}
218
219void QGCFileDownload::_onReadyRead()
220{
221 if (_currentReply == nullptr || _outputFile == nullptr) {
222 return;
223 }
224
225 // Stream data directly to file (memory-efficient)
226 const QByteArray data = _currentReply->readAll();
227 if (!_writeReplyData(data)) {
228 _failForWriteError(QStringLiteral("readyRead"));
229 }
230}
231
232void QGCFileDownload::_onDownloadFinished()
233{
234 QNetworkReply *reply = _currentReply;
235 _currentReply = nullptr;
236
237 if (reply == nullptr) {
238 return;
239 }
240 reply->deleteLater();
241
242 if (_state == State::Cancelled) {
243 _cleanup();
244 return;
245 }
246
247 _lastResultFromCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool();
248
249 // Close output file
250 if (_outputFile != nullptr) {
251 // Write any remaining data
252 const QByteArray remaining = reply->readAll();
253 if (!_writeReplyData(remaining)) {
254 _outputFile->close();
255 delete _outputFile;
256 _outputFile = nullptr;
257 _failForWriteError(QStringLiteral("finished"));
258 return;
259 }
260 _outputFile->close();
261 delete _outputFile;
262 _outputFile = nullptr;
263 }
264
265 // Check for errors
266 if (reply->error() != QNetworkReply::NoError) {
267 // Error already handled in _onDownloadError
268 if (_state == State::Downloading || _state == State::Verifying) {
269 _setState(State::Failed);
270 _emitFinished(false, QString(), QGCNetworkHelper::errorMessage(reply));
271 }
272 return;
273 }
274
275 // Check HTTP status for non-local files
276 if (!reply->url().isLocalFile()) {
277 const int statusCode = QGCNetworkHelper::httpStatusCode(reply);
278 if (!QGCNetworkHelper::isHttpSuccess(statusCode)) {
279 const QString error = tr("HTTP error %1: %2")
280 .arg(statusCode)
281 .arg(QGCNetworkHelper::httpStatusText(statusCode));
282 _setErrorString(error);
283 _setState(State::Failed);
284 _emitFinished(false, QString(), error);
285 return;
286 }
287 }
288
289 qCDebug(QGCFileDownloadLog) << "Download finished:" << _localPath
290 << "size:" << QFileInfo(_localPath).size();
291
292 // Verify hash if expected
293 if (!_expectedHash.isEmpty()) {
294 _setState(State::Verifying);
295 if (!_verifyHash()) {
296 _setState(State::Failed);
297 _emitFinished(false, QString(), _errorString);
298 return;
299 }
300 }
301
302 // Auto-decompress if enabled and file is compressed
303 if (_autoDecompress && QGCCompression::isCompressedFile(_localPath)) {
304 _startDecompression();
305 return;
306 }
307
308 // Success!
309 _setState(State::Completed);
310 _emitFinished(true, _localPath, QString());
311}
312
313void QGCFileDownload::_onDownloadError(QNetworkReply::NetworkError code)
314{
315 if (_state == State::Cancelled) {
316 return;
317 }
318
319 QString errorMsg;
320
321 switch (code) {
322 case QNetworkReply::OperationCanceledError:
323 errorMsg = tr("Download cancelled");
324 break;
325 case QNetworkReply::ContentNotFoundError:
326 errorMsg = tr("File not found (404)");
327 break;
328 case QNetworkReply::TimeoutError:
329 errorMsg = tr("Connection timed out");
330 break;
331 case QNetworkReply::HostNotFoundError:
332 errorMsg = tr("Host not found");
333 break;
334 case QNetworkReply::ConnectionRefusedError:
335 errorMsg = tr("Connection refused");
336 break;
337 case QNetworkReply::SslHandshakeFailedError:
338 errorMsg = tr("SSL handshake failed");
339 break;
340 default:
341 if (_currentReply != nullptr) {
342 errorMsg = QGCNetworkHelper::errorMessage(_currentReply);
343 } else {
344 errorMsg = tr("Network error: %1").arg(code);
345 }
346 break;
347 }
348
349 qCWarning(QGCFileDownloadLog) << "Download error:" << errorMsg;
350 _setErrorString(errorMsg);
351}
352
353void QGCFileDownload::_onDecompressionFinished(bool success)
354{
355 if (_state == State::Cancelled) {
356 _compressedFilePath.clear();
357 return;
358 }
359
360 if (success) {
361 const QString decompressedPath = _decompressionJob->outputPath();
362 qCDebug(QGCFileDownloadLog) << "Decompression completed:" << decompressedPath;
363
364 // Remove compressed file if different from output
365 if (_compressedFilePath != decompressedPath && QFile::exists(_compressedFilePath)) {
366 QFile::remove(_compressedFilePath);
367 }
368
369 _localPath = decompressedPath;
370 emit localPathChanged(_localPath);
371
372 _setState(State::Completed);
373 _emitFinished(true, _localPath, QString());
374 } else {
375 const QString error = tr("Decompression failed: %1").arg(_decompressionJob->errorString());
376 qCWarning(QGCFileDownloadLog) << error;
377 _setErrorString(error);
378 _setState(State::Failed);
379
380 // Return compressed file path on decompression failure
381 _emitFinished(false, _compressedFilePath, error);
382 }
383
384 _compressedFilePath.clear();
385}
386
387// ============================================================================
388// Private Methods
389// ============================================================================
390
391void QGCFileDownload::_setState(State newState)
392{
393 if (_state != newState) {
394 const bool wasRunning = isRunning();
395 _state = newState;
396 emit stateChanged(newState);
397
398 if (wasRunning != isRunning()) {
399 emit runningChanged(isRunning());
400 }
401 }
402}
403
404void QGCFileDownload::_setProgress(qreal progress)
405{
406 if (!qFuzzyCompare(_progress, progress)) {
407 _progress = progress;
408 emit progressChanged(progress);
409 }
410}
411
412void QGCFileDownload::_setErrorString(const QString &error)
413{
414 if (_errorString != error) {
415 _errorString = error;
417 }
418}
419
420void QGCFileDownload::_cleanup()
421{
422 if (_currentReply != nullptr) {
423 _currentReply->disconnect(this);
424 _currentReply->deleteLater();
425 _currentReply = nullptr;
426 }
427
428 if (_outputFile != nullptr) {
429 if (_outputFile->isOpen()) {
430 _outputFile->close();
431 }
432 delete _outputFile;
433 _outputFile = nullptr;
434 }
435}
436
437void QGCFileDownload::_emitFinished(bool success, const QString &localPath, const QString &errorMessage)
438{
439 if (_finishEmitted) {
440 return;
441 }
442 _finishEmitted = true;
443 emit finished(success, localPath, errorMessage);
444}
445
446bool QGCFileDownload::_writeReplyData(const QByteArray &data)
447{
448 if (data.isEmpty()) {
449 return true;
450 }
451
452 if (_outputFile == nullptr) {
453 return false;
454 }
455
456 return _outputFile->write(data) == data.size();
457}
458
459bool QGCFileDownload::_failForWriteError(const QString &context)
460{
461 const QString error = tr("Failed to write downloaded file (%1): %2")
462 .arg(context, _outputFile != nullptr ? _outputFile->errorString() : QString());
463 qCWarning(QGCFileDownloadLog) << error;
464 _setErrorString(error);
465 _setState(State::Failed);
466
467 if (_currentReply != nullptr) {
468 _currentReply->disconnect(this);
469 _currentReply->abort();
470 }
471
472 _cleanup();
473 _emitFinished(false, QString(), error);
474 return false;
475}
476
477QString QGCFileDownload::_generateOutputPath(const QString &remoteUrl) const
478{
479 // Use custom output path if set
480 if (!_outputPath.isEmpty()) {
481 return _outputPath;
482 }
483
484 // Extract filename from URL
485 QString fileName = QGCNetworkHelper::urlFileName(QUrl(remoteUrl));
486 if (fileName.isEmpty()) {
487 fileName = QStringLiteral("DownloadedFile");
488 }
489
490 // Strip query parameters
491 const int queryIndex = fileName.indexOf(QLatin1Char('?'));
492 if (queryIndex != -1) {
493 fileName = fileName.left(queryIndex);
494 }
495
496 // Find writable directory
497 QString downloadDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
498 if (downloadDir.isEmpty()) {
499 downloadDir = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
500 }
501
502 if (downloadDir.isEmpty()) {
503 qCWarning(QGCFileDownloadLog) << "No writable download location found";
504 return QString();
505 }
506
507 return QGCFileHelper::joinPath(downloadDir, fileName);
508}
509
510bool QGCFileDownload::_verifyHash()
511{
512 qCDebug(QGCFileDownloadLog) << "Verifying hash for:" << _localPath;
513
514 const QString actualHash = QGCFileHelper::computeFileHash(_localPath);
515 if (actualHash.isEmpty()) {
516 _setErrorString(tr("Failed to compute file hash"));
517 return false;
518 }
519
520 if (actualHash.compare(_expectedHash, Qt::CaseInsensitive) != 0) {
521 _setErrorString(tr("Hash verification failed. Expected: %1, Got: %2")
522 .arg(_expectedHash, actualHash));
523 return false;
524 }
525
526 qCDebug(QGCFileDownloadLog) << "Hash verified successfully";
527 return true;
528}
529
530void QGCFileDownload::_startDecompression()
531{
532 _compressedFilePath = _localPath;
533 const QString decompressedPath = QGCCompression::strippedPath(_localPath);
534
535 qCDebug(QGCFileDownloadLog) << "Starting decompression:" << _localPath << "->" << decompressedPath;
536
537 _setState(State::Decompressing);
538
539 if (_decompressionJob == nullptr) {
540 _decompressionJob = new QGCCompressionJob(this);
541 connect(_decompressionJob, &QGCCompressionJob::progressChanged,
543 connect(_decompressionJob, &QGCCompressionJob::finished,
544 this, &QGCFileDownload::_onDecompressionFinished);
545 }
546
547 _decompressionJob->decompressFile(_localPath, decompressedPath);
548}
QObject wrapper for async compression operations using QtConcurrent/QPromise.
Error error
Unified file download utility with decompression, verification, and QML support.
#define QGC_LOGGING_CATEGORY(name, categoryStr)
void progressChanged(qreal progress)
Emitted when progress changes (0.0 to 1.0)
void cancel()
Cancel current operation.
void finished(bool success)
void decompressFile(const QString &inputPath, const QString &outputPath=QString(), qint64 maxBytes=0)
void finished(bool success, const QString &localPath, const QString &errorMessage)
void localPathChanged(const QString &localPath)
Emitted when local path changes.
void runningChanged(bool running)
Emitted when running state changes.
void totalBytesChanged(qint64 totalBytes)
Emitted when total bytes changes.
void stateChanged(State state)
Emitted when state changes.
void autoDecompressChanged(bool autoDecompress)
Emitted when auto-decompress setting changes.
bool start(const QString &remoteUrl)
void bytesReceivedChanged(qint64 bytesReceived)
Emitted when bytes received changes.
void errorStringChanged(const QString &errorString)
Emitted when error string changes.
void cancel()
Cancel current download.
void progressChanged(qreal progress)
Emitted when download progress changes.
void expectedHashChanged(const QString &expectedHash)
Emitted when expected hash setting changes.
void downloadProgress(qint64 bytesReceived, qint64 totalBytes)
Emitted during download with byte counts.
void outputPathChanged(const QString &outputPath)
Emitted when output path setting changes.
void urlChanged(const QUrl &url)
Emitted when URL changes.
void decompressionProgress(qreal progress)
Emitted during decompression (0.0 to 1.0)
QString strippedPath(const QString &filePath)
bool isCompressedFile(const QString &filePath)
Check if file path indicates a compressed file (.gz, .xz, .zst)
bool isLocalPath(const QString &urlOrPath)
bool ensureParentExists(const QString &filePath)
QString toLocalPath(const QString &urlOrPath)
QString joinPath(const QString &dir, const QString &name)
QString computeFileHash(const QString &filePath, QCryptographicHash::Algorithm algorithm)
QNetworkRequest createRequest(const QUrl &url, const RequestConfig &config)
QString urlFileName(const QUrl &url)
Extract filename from URL path (last path segment)
void ignoreSslErrorsIfNeeded(QNetworkReply *reply)
int httpStatusCode(const QNetworkReply *reply)
QString httpStatusText(HttpStatusCode statusCode)
bool isHttpSuccess(int statusCode)
Check if HTTP status indicates success (2xx)
QString errorMessage(const QNetworkReply *reply)
Common request configuration options.