From 2bc5021c57916fe092ce8be17f1be1d7dd69af7b Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 05:22:15 +0000 Subject: [PATCH 01/19] fix(VideoManager): redact stream URLs in diagnostics --- src/Settings/VideoSettings.cc | 8 +- src/Utilities/Network/QGCNetworkHelper.cc | 25 +++++ src/Utilities/Network/QGCNetworkHelper.h | 5 + src/VideoManager/VideoManager.cc | 13 ++- .../GStreamer/GstSourceFactory.cc | 16 +++- .../GStreamer/GstVideoReceiver.cc | 93 +++++++++++-------- .../GStreamer/GstVideoReceiver.h | 1 + .../Utilities/Network/QGCNetworkHelperTest.cc | 19 ++++ test/Utilities/Network/QGCNetworkHelperTest.h | 1 + 9 files changed, 127 insertions(+), 54 deletions(-) diff --git a/src/Settings/VideoSettings.cc b/src/Settings/VideoSettings.cc index ae43d03c6c1a..c4f799e613af 100644 --- a/src/Settings/VideoSettings.cc +++ b/src/Settings/VideoSettings.cc @@ -243,22 +243,22 @@ bool VideoSettings::streamConfigured(void) } //-- If UDP, check for URL if(vSource == videoSourceUDPH264 || vSource == videoSourceUDPH265) { - qCDebug(VideoSettingsLog) << "Testing configuration for UDP Stream:" << udpUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for UDP Stream"; return !udpUrl()->rawValue().toString().isEmpty(); } //-- If RTSP, check for URL if(vSource == videoSourceRTSP) { - qCDebug(VideoSettingsLog) << "Testing configuration for RTSP Stream:" << rtspUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for RTSP Stream"; return !rtspUrl()->rawValue().toString().isEmpty(); } //-- If TCP, check for URL if(vSource == videoSourceTCP) { - qCDebug(VideoSettingsLog) << "Testing configuration for TCP Stream:" << tcpUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for TCP Stream"; return !tcpUrl()->rawValue().toString().isEmpty(); } //-- If MPEG-TS, check for URL if(vSource == videoSourceMPEGTS) { - qCDebug(VideoSettingsLog) << "Testing configuration for MPEG-TS Stream:" << udpUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for MPEG-TS Stream"; return !udpUrl()->rawValue().toString().isEmpty(); } //-- If Herelink Air unit, good to go diff --git a/src/Utilities/Network/QGCNetworkHelper.cc b/src/Utilities/Network/QGCNetworkHelper.cc index 5545ee2cc389..17046bd960cc 100644 --- a/src/Utilities/Network/QGCNetworkHelper.cc +++ b/src/Utilities/Network/QGCNetworkHelper.cc @@ -342,6 +342,31 @@ QUrl urlWithoutQuery(const QUrl& url) return url.adjusted(QUrl::RemoveQuery | QUrl::RemoveFragment); } +QString redactedUrlForLogging(const QUrl& url) +{ + if (!url.isValid() || url.scheme().isEmpty()) { + return QStringLiteral(""); + } + + QUrl redactedUrl(url); + const bool hadPath = !redactedUrl.path().isEmpty() && (redactedUrl.path() != QLatin1String("/")); + redactedUrl.setUserInfo(QString()); + redactedUrl.setPath(hadPath ? QString() : redactedUrl.path()); + redactedUrl.setQuery(QString()); + redactedUrl.setFragment(QString()); + + QString displayUrl = redactedUrl.toDisplayString(QUrl::FullyEncoded); + if (hadPath) { + displayUrl += QStringLiteral("/"); + } + return displayUrl; +} + +QString redactedUrlForLogging(const QString& url) +{ + return redactedUrlForLogging(QUrl(url)); +} + // ============================================================================ // Request Configuration // ============================================================================ diff --git a/src/Utilities/Network/QGCNetworkHelper.h b/src/Utilities/Network/QGCNetworkHelper.h index cb92f2d03a3d..1d3faf7aeaf2 100644 --- a/src/Utilities/Network/QGCNetworkHelper.h +++ b/src/Utilities/Network/QGCNetworkHelper.h @@ -139,6 +139,11 @@ QUrl buildUrl(const QString& baseUrl, const QList>& para /// Get URL without query string and fragment QUrl urlWithoutQuery(const QUrl& url); +/// Return a URL suitable for diagnostics without user info, path, query, or fragment secrets. +/// Invalid and relative input is not echoed. +QString redactedUrlForLogging(const QUrl& url); +QString redactedUrlForLogging(const QString& url); + // ============================================================================ // Request Configuration // ============================================================================ diff --git a/src/VideoManager/VideoManager.cc b/src/VideoManager/VideoManager.cc index 72d47e207f7d..624923aae7b2 100644 --- a/src/VideoManager/VideoManager.cc +++ b/src/VideoManager/VideoManager.cc @@ -7,6 +7,7 @@ #include "QGCCameraManager.h" #include "QGCCorePlugin.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" #include "QGCVideoStreamInfo.h" #include "SettingsManager.h" #include "SubtitleWriter.h" @@ -593,7 +594,8 @@ bool VideoManager::_updateAutoStream(VideoReceiver *receiver) return false; } - qCDebug(VideoManagerLog) << QString("Configure stream (%1):").arg(receiver->name()) << pInfo->uri(); + qCDebug(VideoManagerLog) << QString("Configure stream (%1):").arg(receiver->name()) + << QGCNetworkHelper::redactedUrlForLogging(pInfo->uri()); QString source, url; switch (pInfo->type()) { @@ -651,7 +653,7 @@ bool VideoManager::_updateVideoUri(VideoReceiver *receiver, const QString &uri) return false; } - qCDebug(VideoManagerLog) << "New Video URI" << uri; + qCDebug(VideoManagerLog) << "New Video URI" << QGCNetworkHelper::redactedUrlForLogging(uri); receiver->setUri(uri); @@ -899,13 +901,16 @@ void VideoManager::_initVideoReceiver(VideoReceiver *receiver, QQuickWindow *win }); (void) connect(receiver, &VideoReceiver::onStopComplete, this, [this, receiver](VideoReceiver::STATUS status) { - qCDebug(VideoManagerLog) << "Stop complete" << receiver->name() << receiver->uri() << ", status:" << status; + qCDebug(VideoManagerLog) << "Stop complete" << receiver->name() + << QGCNetworkHelper::redactedUrlForLogging(receiver->uri()) + << ", status:" << status; receiver->setStarted(false); if (status == VideoReceiver::STATUS_INVALID_URL) { qCDebug(VideoManagerLog) << "Invalid video URL. Not restarting"; } else { QTimer::singleShot(1000, receiver, [this, receiver]() { - qCDebug(VideoManagerLog) << "Restarting video receiver" << receiver->name() << receiver->uri(); + qCDebug(VideoManagerLog) << "Restarting video receiver" << receiver->name() + << QGCNetworkHelper::redactedUrlForLogging(receiver->uri()); _startReceiver(receiver); }); } diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 9a61cdacb3f1..3dacaa422363 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -7,6 +7,7 @@ #include "GStreamerHelpers.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" QGC_LOGGING_CATEGORY(GstSourceFactoryLog, "Video.GStreamer.GstSourceFactory") @@ -273,7 +274,8 @@ void linkPad(GstElement* element, GstPad* pad, gpointer data) GstElement* buildRtspSource(const QString& uri, const QUrl& sourceUrl, const Config& config, guint latencyMs) { if (!GStreamer::isValidRtspUri(uri.toUtf8().constData())) { - qCCritical(GstSourceFactoryLog) << "Invalid RTSP URI:" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCCritical(GstSourceFactoryLog) << "Invalid RTSP URI:" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } @@ -313,12 +315,14 @@ GstElement* buildTcpSource(const QUrl& sourceUrl) { const int port = sourceUrl.port(); if (!validPort(port)) { - qCCritical(GstSourceFactoryLog) << "Invalid TCP port" << port << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCCritical(GstSourceFactoryLog) << "Invalid TCP port" << port << "in" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } const QString host = sourceUrl.host(); if (host.isEmpty()) { - qCCritical(GstSourceFactoryLog) << "Missing host in TCP URI" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCCritical(GstSourceFactoryLog) << "Missing host in TCP URI" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } @@ -336,7 +340,8 @@ GstElement* buildUdpSource(const QUrl& sourceUrl, bool isUdpH264, bool isUdpH265 { const int port = sourceUrl.port(); if (!validPort(port)) { - qCCritical(GstSourceFactoryLog) << "Invalid UDP port" << port << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCCritical(GstSourceFactoryLog) << "Invalid UDP port" << port << "in" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } @@ -505,7 +510,8 @@ GstElement* create(const QString& uri, const Config& config) const bool isTcpMPEGTS = (scheme == QLatin1String("tcp")); if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS) { - qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" << sourceUrl.toDisplayString(QUrl::RemoveUserInfo); + qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc index 2b376b42e4ee..49ea210e418b 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc @@ -15,6 +15,7 @@ #include "GStreamerHelpers.h" #include "GstSourceFactory.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" #include "QGCQVideoSinkController.h" #include @@ -77,6 +78,11 @@ GstVideoReceiver::~GstVideoReceiver() qCDebug(GstVideoReceiverLog) << this; } +QString GstVideoReceiver::_redactedUri() const +{ + return QGCNetworkHelper::redactedUrlForLogging(_uri); +} + void GstVideoReceiver::start(uint32_t timeout) { if (_needDispatch()) { @@ -85,7 +91,7 @@ void GstVideoReceiver::start(uint32_t timeout) } if (_pipeline) { - qCDebug(GstVideoReceiverLog) << "Already running!" << _uri; + qCDebug(GstVideoReceiverLog) << "Already running!" << _redactedUri(); emit onStartComplete(STATUS_INVALID_STATE); return; } @@ -99,7 +105,8 @@ void GstVideoReceiver::start(uint32_t timeout) _timeout = timeout; _buffer = lowLatency() ? -1 : 0; - qCDebug(GstVideoReceiverLog) << "Starting" << _uri << ", lowLatency" << lowLatency() << ", timeout" << _timeout; + qCDebug(GstVideoReceiverLog) << "Starting" << _redactedUri() << ", lowLatency" << lowLatency() + << ", timeout" << _timeout; // GST_DEBUG_BIN_TO_DOT_FILE is a no-op unless GST_DEBUG_DUMP_DOT_DIR is set; surface that // once per process so field debugging doesn't require re-reading the source. @@ -270,7 +277,7 @@ void GstVideoReceiver::start(uint32_t timeout) emit onStartComplete(STATUS_FAIL); } else { GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-started"); - qCDebug(GstVideoReceiverLog) << "Started" << _uri; + qCDebug(GstVideoReceiverLog) << "Started" << _redactedUri(); // _watchdogTimer lives on `this` (GUI thread); the emit runs synchronously on the // worker thread, so the timer start has to be queued separately or QObject warns. @@ -291,7 +298,7 @@ void GstVideoReceiver::stop() return; } - qCDebug(GstVideoReceiverLog) << "Stopping" << _uri; + qCDebug(GstVideoReceiverLog) << "Stopping" << _redactedUri(); // Bump the epoch synchronously (atomic — no GUI thread needed) so any in-flight reconnect lambda // is superseded before this stop() returns; cross-callsite QueuedConnection FIFO is not guaranteed. @@ -406,18 +413,18 @@ void GstVideoReceiver::stop() if (_streaming) { _streaming = false; - qCDebug(GstVideoReceiverLog) << "Streaming stopped" << _uri; + qCDebug(GstVideoReceiverLog) << "Streaming stopped" << _redactedUri(); emit streamingChanged(_streaming); } else { - qCDebug(GstVideoReceiverLog) << "Streaming did not start" << _uri; + qCDebug(GstVideoReceiverLog) << "Streaming did not start" << _redactedUri(); } } - qCDebug(GstVideoReceiverLog) << "Stopped" << _uri; + qCDebug(GstVideoReceiverLog) << "Stopped" << _redactedUri(); if (const HwBuffers::PathStats hwStats = HwBuffers::formatPathStats(true); hwStats.totalDelivered > 0) { qCInfo(GstVideoReceiverLog).noquote() - << "HW path stats" << _uri << hwStats.line + HwBuffers::takeExtraPathStats(); + << "HW path stats" << _redactedUri() << hwStats.line + HwBuffers::takeExtraPathStats(); } emit onStopComplete(STATUS_OK); @@ -426,7 +433,7 @@ void GstVideoReceiver::stop() void GstVideoReceiver::startDecoding(void *sink) { if (!sink) { - qCCritical(GstVideoReceiverLog) << "VideoSink is NULL" << _uri; + qCCritical(GstVideoReceiverLog) << "VideoSink is NULL" << _redactedUri(); return; } @@ -435,10 +442,10 @@ void GstVideoReceiver::startDecoding(void *sink) return; } - qCDebug(GstVideoReceiverLog) << "Starting decoding" << _uri; + qCDebug(GstVideoReceiverLog) << "Starting decoding" << _redactedUri(); if (!_widget) { - qCDebug(GstVideoReceiverLog) << "Video Widget is NULL" << _uri; + qCDebug(GstVideoReceiverLog) << "Video Widget is NULL" << _redactedUri(); emit onStartDecodingComplete(STATUS_FAIL); return; } @@ -448,7 +455,7 @@ void GstVideoReceiver::startDecoding(void *sink) } if (_videoSink || _decoding) { - qCDebug(GstVideoReceiverLog) << "Already decoding!" << _uri; + qCDebug(GstVideoReceiverLog) << "Already decoding!" << _redactedUri(); emit onStartDecodingComplete(STATUS_INVALID_STATE); return; } @@ -456,7 +463,7 @@ void GstVideoReceiver::startDecoding(void *sink) GstElement *videoSink = GST_ELEMENT(sink); GstPad *pad = gst_element_get_static_pad(videoSink, "sink"); if (!pad) { - qCCritical(GstVideoReceiverLog) << "Unable to find sink pad of video sink" << _uri; + qCCritical(GstVideoReceiverLog) << "Unable to find sink pad of video sink" << _redactedUri(); emit onStartDecodingComplete(STATUS_FAIL); return; } @@ -480,7 +487,7 @@ void GstVideoReceiver::startDecoding(void *sink) _ensureVideoSinkInPipeline(); if (!_addDecoder(_decoderValve)) { - qCCritical(GstVideoReceiverLog) << "_addDecoder() failed" << _uri; + qCCritical(GstVideoReceiverLog) << "_addDecoder() failed" << _redactedUri(); _shutdownDecodingBranch(); emit onStartDecodingComplete(STATUS_FAIL); return; @@ -490,7 +497,7 @@ void GstVideoReceiver::startDecoding(void *sink) "drop", FALSE, nullptr); - qCDebug(GstVideoReceiverLog) << "Decoding started" << _uri; + qCDebug(GstVideoReceiverLog) << "Decoding started" << _redactedUri(); emit onStartDecodingComplete(STATUS_OK); } @@ -502,14 +509,14 @@ void GstVideoReceiver::stopDecoding() return; } - qCDebug(GstVideoReceiverLog) << "Stopping decoding" << _uri; + qCDebug(GstVideoReceiverLog) << "Stopping decoding" << _redactedUri(); // Gate on _videoSink (set by startDecoding) instead of _decoding (which only flips on // first sink-buffer probe). Without this, stopDecoding() called between // onStartDecodingComplete(OK) and the first frame returns STATUS_INVALID_STATE and // leaves the decoder/sink branch live. if (!_pipeline || !_videoSink) { - qCDebug(GstVideoReceiverLog) << "Not decoding!" << _uri; + qCDebug(GstVideoReceiverLog) << "Not decoding!" << _redactedUri(); emit onStopDecodingComplete(STATUS_INVALID_STATE); return; } @@ -535,25 +542,25 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form return; } - qCDebug(GstVideoReceiverLog) << "Starting recording" << _uri; + qCDebug(GstVideoReceiverLog) << "Starting recording" << _redactedUri(); if (!_pipeline) { - qCDebug(GstVideoReceiverLog) << "Streaming is not active!" << _uri; + qCDebug(GstVideoReceiverLog) << "Streaming is not active!" << _redactedUri(); emit onStartRecordingComplete(STATUS_INVALID_STATE); return; } if (_recording) { - qCDebug(GstVideoReceiverLog) << "Already recording!" << _uri; + qCDebug(GstVideoReceiverLog) << "Already recording!" << _redactedUri(); emit onStartRecordingComplete(STATUS_INVALID_STATE); return; } - qCDebug(GstVideoReceiverLog) << "New video file:" << videoFile << _uri; + qCDebug(GstVideoReceiverLog) << "New video file:" << videoFile << _redactedUri(); _fileSink = _makeFileSink(videoFile, format); if (!_fileSink) { - qCCritical(GstVideoReceiverLog) << "_makeFileSink() failed" << _uri; + qCCritical(GstVideoReceiverLog) << "_makeFileSink() failed" << _redactedUri(); emit onStartRecordingComplete(STATUS_FAIL); return; } @@ -565,7 +572,7 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form gst_bin_add(GST_BIN(_pipeline), _fileSink); if (!gst_element_link(_recorderValve, _fileSink)) { - qCCritical(GstVideoReceiverLog) << "Failed to link valve and file sink" << _uri; + qCCritical(GstVideoReceiverLog) << "Failed to link valve and file sink" << _redactedUri(); emit onStartRecordingComplete(STATUS_FAIL); return; } @@ -579,7 +586,7 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form // This will ensure the first frame is a keyframe at t=0, and decoding can begin immediately on playback GstPad *probepad = gst_element_get_static_pad(_recorderValve, "src"); if (!probepad) { - qCCritical(GstVideoReceiverLog) << "gst_element_get_static_pad() failed" << _uri; + qCCritical(GstVideoReceiverLog) << "gst_element_get_static_pad() failed" << _redactedUri(); emit onStartRecordingComplete(STATUS_FAIL); return; } @@ -593,7 +600,7 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form _recordingOutput = videoFile; _recording = true; - qCDebug(GstVideoReceiverLog) << "Recording started" << _uri; + qCDebug(GstVideoReceiverLog) << "Recording started" << _redactedUri(); emit onStartRecordingComplete(STATUS_OK); emit recordingChanged(_recording); } @@ -605,10 +612,10 @@ void GstVideoReceiver::stopRecording() return; } - qCDebug(GstVideoReceiverLog) << "Stopping recording" << _uri; + qCDebug(GstVideoReceiverLog) << "Stopping recording" << _redactedUri(); if (!_pipeline || !_recording) { - qCDebug(GstVideoReceiverLog) << "Not recording!" << _uri; + qCDebug(GstVideoReceiverLog) << "Not recording!" << _redactedUri(); emit onStopRecordingComplete(STATUS_INVALID_STATE); return; } @@ -638,7 +645,7 @@ void GstVideoReceiver::takeScreenshot(const QString &imageFile) return; } - qCDebug(GstVideoReceiverLog) << "taking screenshot" << _uri; + qCDebug(GstVideoReceiverLog) << "taking screenshot" << _redactedUri(); // FIXME: record screenshot here emit onTakeScreenshotComplete(STATUS_NOT_IMPLEMENTED); @@ -661,7 +668,7 @@ void GstVideoReceiver::_watchdog() if (++_statsTickCounter >= 10) { _statsTickCounter = 0; if (const HwBuffers::PathStats hwStats = HwBuffers::formatPathStats(false); hwStats.totalDelivered > 0) { - qCDebug(GstVideoReceiverLog).noquote() << "HW path live" << _uri << hwStats.line; + qCDebug(GstVideoReceiverLog).noquote() << "HW path live" << _redactedUri() << hwStats.line; } } @@ -672,7 +679,7 @@ void GstVideoReceiver::_watchdog() qint64 elapsed = now - lastSourceFrameTime; if (elapsed > _timeout) { - qCDebug(GstVideoReceiverLog) << "Stream timeout, no frames for" << elapsed << _uri; + qCDebug(GstVideoReceiverLog) << "Stream timeout, no frames for" << elapsed << _redactedUri(); GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-watchdog-timeout"); emit timeout(); _scheduleReconnect("source watchdog"); @@ -688,7 +695,7 @@ void GstVideoReceiver::_watchdog() elapsed = now - lastVideoFrameTime; if (elapsed > (_timeout * 2)) { - qCDebug(GstVideoReceiverLog) << "Video decoder timeout, no frames for" << elapsed << _uri; + qCDebug(GstVideoReceiverLog) << "Video decoder timeout, no frames for" << elapsed << _redactedUri(); GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-watchdog-timeout"); emit timeout(); _scheduleReconnect("decoder watchdog"); @@ -729,7 +736,8 @@ void GstVideoReceiver::_scheduleReconnect(const char *reason) const quint64 epoch = _reconnectEpoch.load(std::memory_order_relaxed); const int attempts = next; qCInfo(GstVideoReceiverLog) << "Scheduling reconnect #" << attempts - << "in" << delaySec << "s after" << reason << uri; + << "in" << delaySec << "s after" << reason + << QGCNetworkHelper::redactedUrlForLogging(uri); QTimer::singleShot(delaySec * 1000, this, [this, epoch, attempts, reconnectTimeout, uri]() { if (epoch != _reconnectEpoch.load(std::memory_order_relaxed)) return; // superseded by stop() // _pipeline is mutated by the worker under _pipelineMutex; a bare deref here (GUI @@ -738,7 +746,8 @@ void GstVideoReceiver::_scheduleReconnect(const char *reason) const bool pipelineUp = (livePipeline != nullptr); if (livePipeline) gst_object_unref(livePipeline); if (uri.isEmpty() || pipelineUp) return; // pipeline already came back - qCInfo(GstVideoReceiverLog) << "Reconnecting (attempt" << attempts << ")" << uri; + qCInfo(GstVideoReceiverLog) << "Reconnecting (attempt" << attempts << ")" + << QGCNetworkHelper::redactedUrlForLogging(uri); start(reconnectTimeout); }); }, Qt::QueuedConnection); @@ -894,7 +903,7 @@ void GstVideoReceiver::_onNewSourcePad(GstPad *pad) if (!_streaming) { _streaming = true; - qCDebug(GstVideoReceiverLog) << "Streaming started" << _uri; + qCDebug(GstVideoReceiverLog) << "Streaming started" << _redactedUri(); emit streamingChanged(_streaming); } @@ -921,7 +930,7 @@ void GstVideoReceiver::_onNewSourcePad(GstPad *pad) "drop", FALSE, nullptr); - qCDebug(GstVideoReceiverLog) << "Decoding started" << _uri; + qCDebug(GstVideoReceiverLog) << "Decoding started" << _redactedUri(); } void GstVideoReceiver::_logDecodebin3SelectedCodec(GstElement *decodebin3) @@ -981,7 +990,7 @@ void GstVideoReceiver::_logDecodebin3SelectedCodec(GstElement *decodebin3) void GstVideoReceiver::_onNewDecoderPad(GstPad *pad) { - qCDebug(GstVideoReceiverLog) << "_onNewDecoderPad" << _uri; + qCDebug(GstVideoReceiverLog) << "_onNewDecoderPad" << _redactedUri(); GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-new-decoder-pad"); @@ -1087,7 +1096,8 @@ bool GstVideoReceiver::_addVideoSink(GstPad *pad) QSize videoSize; do { if (!_decoderValve) { - qCCritical(GstVideoReceiverLog) << "Unable to determine video size - _decoderValve is NULL" << _uri; + qCCritical(GstVideoReceiverLog) << "Unable to determine video size - _decoderValve is NULL" + << _redactedUri(); break; } @@ -1106,7 +1116,8 @@ bool GstVideoReceiver::_addVideoSink(GstPad *pad) const GstStructure *structure = gst_caps_get_structure(valveSrcPadCaps, 0); if (!structure) { - qCCritical(GstVideoReceiverLog) << "Unable to determine video size - structure is NULL" << _uri; + qCCritical(GstVideoReceiverLog) << "Unable to determine video size - structure is NULL" + << _redactedUri(); gst_clear_object(&valveSrcPad); break; } @@ -1152,10 +1163,10 @@ void GstVideoReceiver::_noteTeeFrame() } const quint64 sourceFrames = _sourceFrameCount.fetch_add(1, std::memory_order_relaxed) + 1; if (sourceFrames == 1) { - qCInfo(GstVideoReceiverLog).noquote() << "Source receiving frames (tee):" << _uri; + qCInfo(GstVideoReceiverLog).noquote() << "Source receiving frames (tee):" << _redactedUri(); } else if ((sourceFrames % 300) == 0) { qCDebug(GstVideoReceiverLog).noquote() - << "Source flow: teeFrames=" << sourceFrames << "decoding=" << _decoding << _uri; + << "Source flow: teeFrames=" << sourceFrames << "decoding=" << _decoding << _redactedUri(); } } @@ -1494,7 +1505,7 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp gst_query_unref(q); const QString decName = pThis->decoderName(); qCDebug(GstVideoReceiverLog).noquote() - << "Pipeline PLAYING:" << pThis->_uri + << "Pipeline PLAYING:" << pThis->_redactedUri() << "decoder:" << (decName.isEmpty() ? QStringLiteral("(pending)") : decName) << "min-latency:" << (min / 1000000) << "ms" << "max-latency:" << (max / 1000000) << "ms"; diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h index 834b31a3bd3f..e4b17f6bf274 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.h @@ -85,6 +85,7 @@ private slots: void _handleEOS(); private: + QString _redactedUri() const; GstElement *_makeDecoder(); GstElement *_makeFileSink(const QString &videoFile, FILE_FORMAT format); diff --git a/test/Utilities/Network/QGCNetworkHelperTest.cc b/test/Utilities/Network/QGCNetworkHelperTest.cc index 589b8fb8cc24..95f5659572ef 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.cc +++ b/test/Utilities/Network/QGCNetworkHelperTest.cc @@ -343,6 +343,25 @@ void QGCNetworkHelperTest::_testUrlWithoutQuery() QVERIFY(result.fragment().isEmpty()); } +void QGCNetworkHelperTest::_testRedactedUrlForLogging() +{ + const QString sensitiveUrl = + QStringLiteral("https://pilot:secret@example.com:8443/video%20feed?token=abc123#session"); + const QString redacted = QGCNetworkHelper::redactedUrlForLogging(sensitiveUrl); + + QCOMPARE(redacted, QStringLiteral("https://example.com:8443/")); + QVERIFY(!redacted.contains(QStringLiteral("pilot"))); + QVERIFY(!redacted.contains(QStringLiteral("secret"))); + QVERIFY(!redacted.contains(QStringLiteral("abc123"))); + QVERIFY(!redacted.contains(QStringLiteral("video"))); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("udp://0.0.0.0:5600")), + QStringLiteral("udp://0.0.0.0:5600")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://example.com/?token=abc123")), + QStringLiteral("https://example.com/")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("token-only-value")), + QStringLiteral("")); +} + // ============================================================================ // Request Configuration Tests // ============================================================================ diff --git a/test/Utilities/Network/QGCNetworkHelperTest.h b/test/Utilities/Network/QGCNetworkHelperTest.h index 4b14265c6178..c61eb1e1a8f2 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.h +++ b/test/Utilities/Network/QGCNetworkHelperTest.h @@ -38,6 +38,7 @@ private slots: void _testBuildUrlFromMap(); void _testBuildUrlFromList(); void _testUrlWithoutQuery(); + void _testRedactedUrlForLogging(); // Request configuration tests void _testDefaultUserAgent(); From 934e8bb6ba8c670395bbed278255e79538c57b59 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 06:09:54 +0000 Subject: [PATCH 02/19] test(Utilities): cover redacted URL edge cases --- test/Utilities/Network/QGCNetworkHelperTest.cc | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/Utilities/Network/QGCNetworkHelperTest.cc b/test/Utilities/Network/QGCNetworkHelperTest.cc index 95f5659572ef..d9be2807cd77 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.cc +++ b/test/Utilities/Network/QGCNetworkHelperTest.cc @@ -321,7 +321,8 @@ void QGCNetworkHelperTest::_testBuildUrlFromMap() void QGCNetworkHelperTest::_testBuildUrlFromList() { QList> params = { - {"key1", "value1"}, {"key1", "value2"}, // Duplicate key allowed with list + {"key1", "value1"}, + {"key1", "value2"}, // Duplicate key allowed with list }; QUrl url = QGCNetworkHelper::buildUrl("http://example.com/api", params); QVERIFY(url.isValid()); @@ -358,6 +359,14 @@ void QGCNetworkHelperTest::_testRedactedUrlForLogging() QStringLiteral("udp://0.0.0.0:5600")); QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://example.com/?token=abc123")), QStringLiteral("https://example.com/")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://[2001:db8::1]:8443/video?token=abc123")), + QStringLiteral("https://[2001:db8::1]:8443/")); + + const QString encodedUserInfo = QGCNetworkHelper::redactedUrlForLogging( + QStringLiteral("https://pilot%40ops:secret%2Fvalue@example.com/video?token=abc123")); + QCOMPARE(encodedUserInfo, QStringLiteral("https://example.com/")); + QVERIFY(!encodedUserInfo.contains(QStringLiteral("pilot"))); + QVERIFY(!encodedUserInfo.contains(QStringLiteral("secret"))); QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("token-only-value")), QStringLiteral("")); } From 9a94cd8c75fc2977fcdf9e076dcbb70c2d9a383e Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 09:38:34 +0000 Subject: [PATCH 03/19] test(Utilities): run URL redaction checks in CI --- test/Utilities/Network/CMakeLists.txt | 3 ++ .../Utilities/Network/QGCNetworkHelperTest.cc | 27 ----------------- test/Utilities/Network/QGCNetworkHelperTest.h | 1 - .../Network/QGCNetworkRedactionTest.cc | 30 +++++++++++++++++++ .../Network/QGCNetworkRedactionTest.h | 11 +++++++ 5 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 test/Utilities/Network/QGCNetworkRedactionTest.cc create mode 100644 test/Utilities/Network/QGCNetworkRedactionTest.h diff --git a/test/Utilities/Network/CMakeLists.txt b/test/Utilities/Network/CMakeLists.txt index af4c50eba070..ce0c1a5d072e 100644 --- a/test/Utilities/Network/CMakeLists.txt +++ b/test/Utilities/Network/CMakeLists.txt @@ -7,8 +7,11 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE QGCNetworkHelperTest.cc QGCNetworkHelperTest.h + QGCNetworkRedactionTest.cc + QGCNetworkRedactionTest.h ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_qgc_test(QGCNetworkHelperTest LABELS Unit Utilities Network) +add_qgc_test(QGCNetworkRedactionTest LABELS Unit Utilities) diff --git a/test/Utilities/Network/QGCNetworkHelperTest.cc b/test/Utilities/Network/QGCNetworkHelperTest.cc index d9be2807cd77..481d627928d5 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.cc +++ b/test/Utilities/Network/QGCNetworkHelperTest.cc @@ -344,33 +344,6 @@ void QGCNetworkHelperTest::_testUrlWithoutQuery() QVERIFY(result.fragment().isEmpty()); } -void QGCNetworkHelperTest::_testRedactedUrlForLogging() -{ - const QString sensitiveUrl = - QStringLiteral("https://pilot:secret@example.com:8443/video%20feed?token=abc123#session"); - const QString redacted = QGCNetworkHelper::redactedUrlForLogging(sensitiveUrl); - - QCOMPARE(redacted, QStringLiteral("https://example.com:8443/")); - QVERIFY(!redacted.contains(QStringLiteral("pilot"))); - QVERIFY(!redacted.contains(QStringLiteral("secret"))); - QVERIFY(!redacted.contains(QStringLiteral("abc123"))); - QVERIFY(!redacted.contains(QStringLiteral("video"))); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("udp://0.0.0.0:5600")), - QStringLiteral("udp://0.0.0.0:5600")); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://example.com/?token=abc123")), - QStringLiteral("https://example.com/")); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://[2001:db8::1]:8443/video?token=abc123")), - QStringLiteral("https://[2001:db8::1]:8443/")); - - const QString encodedUserInfo = QGCNetworkHelper::redactedUrlForLogging( - QStringLiteral("https://pilot%40ops:secret%2Fvalue@example.com/video?token=abc123")); - QCOMPARE(encodedUserInfo, QStringLiteral("https://example.com/")); - QVERIFY(!encodedUserInfo.contains(QStringLiteral("pilot"))); - QVERIFY(!encodedUserInfo.contains(QStringLiteral("secret"))); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("token-only-value")), - QStringLiteral("")); -} - // ============================================================================ // Request Configuration Tests // ============================================================================ diff --git a/test/Utilities/Network/QGCNetworkHelperTest.h b/test/Utilities/Network/QGCNetworkHelperTest.h index c61eb1e1a8f2..4b14265c6178 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.h +++ b/test/Utilities/Network/QGCNetworkHelperTest.h @@ -38,7 +38,6 @@ private slots: void _testBuildUrlFromMap(); void _testBuildUrlFromList(); void _testUrlWithoutQuery(); - void _testRedactedUrlForLogging(); // Request configuration tests void _testDefaultUserAgent(); diff --git a/test/Utilities/Network/QGCNetworkRedactionTest.cc b/test/Utilities/Network/QGCNetworkRedactionTest.cc new file mode 100644 index 000000000000..5c63b0ee483b --- /dev/null +++ b/test/Utilities/Network/QGCNetworkRedactionTest.cc @@ -0,0 +1,30 @@ +#include "QGCNetworkRedactionTest.h" + +#include "QGCNetworkHelper.h" + +void QGCNetworkRedactionTest::_testRedactedUrlForLogging() +{ + const QString sensitiveUrl = + QStringLiteral("https://pilot:secret@example.com:8443/video%20feed?token=abc123#session"); + const QString redacted = QGCNetworkHelper::redactedUrlForLogging(sensitiveUrl); + + QCOMPARE(redacted, QStringLiteral("https://example.com:8443/")); + QVERIFY(!redacted.contains(QStringLiteral("pilot"))); + QVERIFY(!redacted.contains(QStringLiteral("secret"))); + QVERIFY(!redacted.contains(QStringLiteral("abc123"))); + QVERIFY(!redacted.contains(QStringLiteral("video"))); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("udp://0.0.0.0:5600")), + QStringLiteral("udp://0.0.0.0:5600")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://example.com/?token=abc123")), + QStringLiteral("https://example.com/")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://[2001:db8::1]:8443/video?token=abc123")), + QStringLiteral("https://[2001:db8::1]:8443/")); + + const QString encodedUserInfo = QGCNetworkHelper::redactedUrlForLogging( + QStringLiteral("https://pilot%40ops:secret%2Fvalue@example.com/video?token=abc123")); + QCOMPARE(encodedUserInfo, QStringLiteral("https://example.com/")); + QVERIFY(!encodedUserInfo.contains(QStringLiteral("pilot"))); + QVERIFY(!encodedUserInfo.contains(QStringLiteral("secret"))); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("token-only-value")), + QStringLiteral("")); +} diff --git a/test/Utilities/Network/QGCNetworkRedactionTest.h b/test/Utilities/Network/QGCNetworkRedactionTest.h new file mode 100644 index 000000000000..05a206d83514 --- /dev/null +++ b/test/Utilities/Network/QGCNetworkRedactionTest.h @@ -0,0 +1,11 @@ +#pragma once + +#include "UnitTest.h" + +class QGCNetworkRedactionTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _testRedactedUrlForLogging(); +}; From 5444d65ccf281391c65ee91be15100b4308bf517 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 09:41:56 +0000 Subject: [PATCH 04/19] style(Utilities): keep redaction diff focused --- test/Utilities/Network/QGCNetworkHelperTest.cc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/Utilities/Network/QGCNetworkHelperTest.cc b/test/Utilities/Network/QGCNetworkHelperTest.cc index 481d627928d5..589b8fb8cc24 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.cc +++ b/test/Utilities/Network/QGCNetworkHelperTest.cc @@ -321,8 +321,7 @@ void QGCNetworkHelperTest::_testBuildUrlFromMap() void QGCNetworkHelperTest::_testBuildUrlFromList() { QList> params = { - {"key1", "value1"}, - {"key1", "value2"}, // Duplicate key allowed with list + {"key1", "value1"}, {"key1", "value2"}, // Duplicate key allowed with list }; QUrl url = QGCNetworkHelper::buildUrl("http://example.com/api", params); QVERIFY(url.isValid()); From 6f7cabd76dda8b4cedc363aba21e93cd9a00cba4 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 10:11:32 +0000 Subject: [PATCH 05/19] test(Utilities): register URL redaction test --- test/Utilities/Network/QGCNetworkRedactionTest.cc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/Utilities/Network/QGCNetworkRedactionTest.cc b/test/Utilities/Network/QGCNetworkRedactionTest.cc index 5c63b0ee483b..79886bfd48ef 100644 --- a/test/Utilities/Network/QGCNetworkRedactionTest.cc +++ b/test/Utilities/Network/QGCNetworkRedactionTest.cc @@ -28,3 +28,5 @@ void QGCNetworkRedactionTest::_testRedactedUrlForLogging() QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("token-only-value")), QStringLiteral("")); } + +UT_REGISTER_TEST(QGCNetworkRedactionTest, TestLabel::Unit, TestLabel::Utilities) From 8d1d634c90f17396c43dbb27e650ff1eb1ff33d0 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Fri, 31 Jul 2026 01:15:43 +0000 Subject: [PATCH 06/19] fix(VideoManager): preserve useful stream diagnostics --- src/Camera/VehicleCameraControl.cc | 6 +- src/Settings/VideoSettings.cc | 25 ++++++--- src/Utilities/Network/QGCNetworkHelper.cc | 33 +++++++---- src/Utilities/Network/QGCNetworkHelper.h | 4 +- .../GStreamer/GStreamerHelpers.cc | 2 +- .../GStreamer/GStreamerHelpers.h | 4 ++ .../GStreamer/GstSourceFactory.cc | 9 ++- .../GStreamer/GstVideoReceiver.cc | 29 +++++----- .../VideoReceiver/GStreamer/README.md | 2 + test/Utilities/Network/CMakeLists.txt | 3 - .../Utilities/Network/QGCNetworkHelperTest.cc | 56 +++++++++++++++++++ test/Utilities/Network/QGCNetworkHelperTest.h | 5 ++ .../Network/QGCNetworkRedactionTest.cc | 32 ----------- .../Network/QGCNetworkRedactionTest.h | 11 ---- test/VideoManager/GStreamer/GStreamerTest.cc | 28 ++++++++++ test/VideoManager/GStreamer/GStreamerTest.h | 1 + 16 files changed, 160 insertions(+), 90 deletions(-) delete mode 100644 test/Utilities/Network/QGCNetworkRedactionTest.cc delete mode 100644 test/Utilities/Network/QGCNetworkRedactionTest.h diff --git a/src/Camera/VehicleCameraControl.cc b/src/Camera/VehicleCameraControl.cc index ede5743095ab..907a9efc94dd 100644 --- a/src/Camera/VehicleCameraControl.cc +++ b/src/Camera/VehicleCameraControl.cc @@ -1817,7 +1817,8 @@ void VehicleCameraControl::setCurrentStream(int stream) if (stream != _currentStream && stream >= 0 && stream < _streamLabels.count()) { QGCVideoStreamInfo* pInfo = currentStreamInstance(); if(pInfo) { - qCDebug(VehicleCameraControlLog) << "Stopping stream:" << pInfo->uri(); + qCDebug(VehicleCameraControlLog) + << "Stopping stream:" << QGCNetworkHelper::redactedUrlForLogging(pInfo->uri()); //-- Stop current stream _vehicle->sendMavCommand( _compID, // Target component @@ -1829,7 +1830,8 @@ void VehicleCameraControl::setCurrentStream(int stream) pInfo = currentStreamInstance(); if(pInfo) { //-- Start new stream - qCDebug(VehicleCameraControlLog) << "Starting stream:" << pInfo->uri(); + qCDebug(VehicleCameraControlLog) + << "Starting stream:" << QGCNetworkHelper::redactedUrlForLogging(pInfo->uri()); _vehicle->sendMavCommand( _compID, // Target component MAV_CMD_VIDEO_START_STREAMING, // Command id diff --git a/src/Settings/VideoSettings.cc b/src/Settings/VideoSettings.cc index c4f799e613af..ffe9767c42c3 100644 --- a/src/Settings/VideoSettings.cc +++ b/src/Settings/VideoSettings.cc @@ -2,6 +2,7 @@ #include "VideoManager.h" #include "QGCLoggingCategory.h" +#include "QGCNetworkHelper.h" #include #include @@ -243,23 +244,31 @@ bool VideoSettings::streamConfigured(void) } //-- If UDP, check for URL if(vSource == videoSourceUDPH264 || vSource == videoSourceUDPH265) { - qCDebug(VideoSettingsLog) << "Testing configuration for UDP Stream"; - return !udpUrl()->rawValue().toString().isEmpty(); + const QString url = udpUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for UDP Stream:" + << QGCNetworkHelper::redactedUrlForLogging(url); + return !url.isEmpty(); } //-- If RTSP, check for URL if(vSource == videoSourceRTSP) { - qCDebug(VideoSettingsLog) << "Testing configuration for RTSP Stream"; - return !rtspUrl()->rawValue().toString().isEmpty(); + const QString url = rtspUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for RTSP Stream:" + << QGCNetworkHelper::redactedUrlForLogging(url); + return !url.isEmpty(); } //-- If TCP, check for URL if(vSource == videoSourceTCP) { - qCDebug(VideoSettingsLog) << "Testing configuration for TCP Stream"; - return !tcpUrl()->rawValue().toString().isEmpty(); + const QString url = tcpUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for TCP Stream:" + << QGCNetworkHelper::redactedUrlForLogging(url); + return !url.isEmpty(); } //-- If MPEG-TS, check for URL if(vSource == videoSourceMPEGTS) { - qCDebug(VideoSettingsLog) << "Testing configuration for MPEG-TS Stream"; - return !udpUrl()->rawValue().toString().isEmpty(); + const QString url = udpUrl()->rawValue().toString(); + qCDebug(VideoSettingsLog) << "Testing configuration for MPEG-TS Stream:" + << QGCNetworkHelper::redactedUrlForLogging(url); + return !url.isEmpty(); } //-- If Herelink Air unit, good to go if(vSource == videoSourceHerelinkAirUnit) { diff --git a/src/Utilities/Network/QGCNetworkHelper.cc b/src/Utilities/Network/QGCNetworkHelper.cc index 17046bd960cc..0478068fdbb0 100644 --- a/src/Utilities/Network/QGCNetworkHelper.cc +++ b/src/Utilities/Network/QGCNetworkHelper.cc @@ -344,22 +344,31 @@ QUrl urlWithoutQuery(const QUrl& url) QString redactedUrlForLogging(const QUrl& url) { - if (!url.isValid() || url.scheme().isEmpty()) { + if (url.isEmpty()) { + return QStringLiteral(""); + } + if (!url.isValid()) { return QStringLiteral(""); } - QUrl redactedUrl(url); - const bool hadPath = !redactedUrl.path().isEmpty() && (redactedUrl.path() != QLatin1String("/")); - redactedUrl.setUserInfo(QString()); - redactedUrl.setPath(hadPath ? QString() : redactedUrl.path()); - redactedUrl.setQuery(QString()); - redactedUrl.setFragment(QString()); - - QString displayUrl = redactedUrl.toDisplayString(QUrl::FullyEncoded); - if (hadPath) { - displayUrl += QStringLiteral("/"); + QUrl redactedUrl = url.adjusted(QUrl::RemoveUserInfo); + if (redactedUrl.hasQuery()) { + const auto queryItems = QUrlQuery(redactedUrl).queryItems(QUrl::FullyDecoded); + QUrlQuery redactedQuery; + for (const auto& queryItem : queryItems) { + redactedQuery.addQueryItem(queryItem.first, QStringLiteral("REDACTED")); + } + if (queryItems.isEmpty()) { + redactedUrl.setQuery(QStringLiteral("REDACTED")); + } else { + redactedUrl.setQuery(redactedQuery); + } + } + if (redactedUrl.hasFragment()) { + redactedUrl.setFragment(QStringLiteral("REDACTED")); } - return displayUrl; + + return redactedUrl.toDisplayString(QUrl::FullyEncoded); } QString redactedUrlForLogging(const QString& url) diff --git a/src/Utilities/Network/QGCNetworkHelper.h b/src/Utilities/Network/QGCNetworkHelper.h index 1d3faf7aeaf2..e83571e20c74 100644 --- a/src/Utilities/Network/QGCNetworkHelper.h +++ b/src/Utilities/Network/QGCNetworkHelper.h @@ -139,8 +139,8 @@ QUrl buildUrl(const QString& baseUrl, const QList>& para /// Get URL without query string and fragment QUrl urlWithoutQuery(const QUrl& url); -/// Return a URL suitable for diagnostics without user info, path, query, or fragment secrets. -/// Invalid and relative input is not echoed. +/// Return a URL suitable for diagnostics. Stream identity is preserved while user info, +/// query values, and fragment content are redacted. QString redactedUrlForLogging(const QUrl& url); QString redactedUrlForLogging(const QString& url); diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc index 6176704bfd69..c20378ece58a 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.cc @@ -82,7 +82,7 @@ QString writePipelineDot(GstElement* pipeline, const char* tag) QFile::remove(existing.takeFirst().absoluteFilePath()); } - gchar* data = gst_debug_bin_to_dot_data(GST_BIN(pipeline), GST_DEBUG_GRAPH_SHOW_ALL); + gchar* data = gst_debug_bin_to_dot_data(GST_BIN(pipeline), kDiagnosticDotGraphDetails); if (!data) return {}; const QString fileName = QStringLiteral("%1-%2.dot") diff --git a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h index 568884a98985..2bc6d87a93ca 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GStreamerHelpers.h @@ -9,6 +9,10 @@ #include "GStreamer.h" // VideoDecoderOptions namespace GStreamer { +/// Diagnostic graphs omit element properties because source properties can contain credentials. +inline constexpr GstDebugGraphDetails kDiagnosticDotGraphDetails = static_cast( + GST_DEBUG_GRAPH_SHOW_MEDIA_TYPE | GST_DEBUG_GRAPH_SHOW_CAPS_DETAILS | GST_DEBUG_GRAPH_SHOW_STATES); + bool isValidRtspUri(const gchar* uri_str); /// Dump @p pipeline's graph as a rotating .dot under CacheLocation/qgc-pipeline-dot/ for field reports. diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 3dacaa422363..281cb98afdb8 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -274,8 +274,7 @@ void linkPad(GstElement* element, GstPad* pad, gpointer data) GstElement* buildRtspSource(const QString& uri, const QUrl& sourceUrl, const Config& config, guint latencyMs) { if (!GStreamer::isValidRtspUri(uri.toUtf8().constData())) { - qCCritical(GstSourceFactoryLog) << "Invalid RTSP URI:" - << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + qCWarning(GstSourceFactoryLog) << "Invalid RTSP URI:" << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } @@ -315,13 +314,13 @@ GstElement* buildTcpSource(const QUrl& sourceUrl) { const int port = sourceUrl.port(); if (!validPort(port)) { - qCCritical(GstSourceFactoryLog) << "Invalid TCP port" << port << "in" + qCWarning(GstSourceFactoryLog) << "Invalid TCP port" << port << "in" << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } const QString host = sourceUrl.host(); if (host.isEmpty()) { - qCCritical(GstSourceFactoryLog) << "Missing host in TCP URI" + qCWarning(GstSourceFactoryLog) << "Missing host in TCP URI" << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } @@ -340,7 +339,7 @@ GstElement* buildUdpSource(const QUrl& sourceUrl, bool isUdpH264, bool isUdpH265 { const int port = sourceUrl.port(); if (!validPort(port)) { - qCCritical(GstSourceFactoryLog) << "Invalid UDP port" << port << "in" + qCWarning(GstSourceFactoryLog) << "Invalid UDP port" << port << "in" << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc index 49ea210e418b..7a373dd635a8 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc @@ -252,7 +252,7 @@ void GstVideoReceiver::start(uint32_t timeout) gst_clear_object(&bus); } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-initial"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-initial"); running = (gst_element_set_state(_pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE); } while(0); @@ -276,7 +276,7 @@ void GstVideoReceiver::start(uint32_t timeout) emit onStartComplete(STATUS_FAIL); } else { - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-started"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-started"); qCDebug(GstVideoReceiverLog) << "Started" << _redactedUri(); // _watchdogTimer lives on `this` (GUI thread); the emit runs synchronously on the @@ -394,7 +394,7 @@ void GstVideoReceiver::stop() _shutdownDecodingBranch(); } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-stopped"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-stopped"); // Lock before nulling so an in-flight _onBusMessage on the streaming thread cannot read // a half-destroyed _pipeline. _acquirePipelineRef takes its own ref under the same lock. @@ -579,7 +579,7 @@ void GstVideoReceiver::startRecording(const QString &videoFile, FILE_FORMAT form (void) gst_element_sync_state_with_parent(_fileSink); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-filesink"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-with-filesink"); // Install a probe on the recording branch to drop buffers until we hit our first keyframe // When we hit our first keyframe, we can offset the timestamps appropriately according to the first keyframe time @@ -680,7 +680,7 @@ void GstVideoReceiver::_watchdog() qint64 elapsed = now - lastSourceFrameTime; if (elapsed > _timeout) { qCDebug(GstVideoReceiverLog) << "Stream timeout, no frames for" << elapsed << _redactedUri(); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-watchdog-timeout"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-watchdog-timeout"); emit timeout(); _scheduleReconnect("source watchdog"); return; @@ -696,7 +696,8 @@ void GstVideoReceiver::_watchdog() elapsed = now - lastVideoFrameTime; if (elapsed > (_timeout * 2)) { qCDebug(GstVideoReceiverLog) << "Video decoder timeout, no frames for" << elapsed << _redactedUri(); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-watchdog-timeout"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, + "pipeline-watchdog-timeout"); emit timeout(); _scheduleReconnect("decoder watchdog"); } @@ -762,7 +763,7 @@ void GstVideoReceiver::dumpPipelineGraph(const QString &tag) return; } const QByteArray tagUtf8 = tag.toUtf8(); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GST_DEBUG_GRAPH_SHOW_ALL, tagUtf8.constData()); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GStreamer::kDiagnosticDotGraphDetails, tagUtf8.constData()); const QString dotPath = GStreamer::writePipelineDot(pipelineRef, tagUtf8.constData()); if (!dotPath.isEmpty()) { qCInfo(GstVideoReceiverLog) << "Pipeline graph saved to" << dotPath; @@ -916,7 +917,7 @@ void GstVideoReceiver::_onNewSourcePad(GstPad *pad) return; } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-new-source-pad"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-with-new-source-pad"); _ensureVideoSinkInPipeline(); @@ -992,7 +993,7 @@ void GstVideoReceiver::_onNewDecoderPad(GstPad *pad) { qCDebug(GstVideoReceiverLog) << "_onNewDecoderPad" << _redactedUri(); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-new-decoder-pad"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-with-new-decoder-pad"); // We should now know what codec decodebin3 selected. _logDecodebin3SelectedCodec(_decoder); @@ -1015,7 +1016,7 @@ bool GstVideoReceiver::_addDecoder(GstElement *src) (void) gst_bin_add(GST_BIN(_pipeline), _decoder); (void) gst_element_sync_state_with_parent(_decoder); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-decoder"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-with-decoder"); if (!gst_element_link(src, _decoder)) { qCCritical(GstVideoReceiverLog) << "Unable to link decoder"; @@ -1090,7 +1091,7 @@ bool GstVideoReceiver::_addVideoSink(GstPad *pad) (void) gst_element_sync_state_with_parent(_videoSink); - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-with-videosink"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-with-videosink"); // Determine video size. Errors here are non-fatal. QSize videoSize; @@ -1275,7 +1276,7 @@ void GstVideoReceiver::_shutdownDecodingBranch() emit decodingChanged(_decoding); } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-decoding-stopped"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-decoding-stopped"); } void GstVideoReceiver::_shutdownRecordingBranch() @@ -1307,7 +1308,7 @@ void GstVideoReceiver::_shutdownRecordingBranch() emit onStopRecordingComplete(STATUS_OK); } - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-recording-stopped"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(_pipeline), GStreamer::kDiagnosticDotGraphDetails, "pipeline-recording-stopped"); } bool GstVideoReceiver::_needDispatch() @@ -1366,7 +1367,7 @@ gboolean GstVideoReceiver::_onBusMessage(GstBus * /* bus */, GstMessage *msg, gp if (GstElement *pipelineRef = pThis->_acquirePipelineRef()) { // Native dump path (no-op without GST_DEBUG_DUMP_DOT_DIR) plus an unconditional // CacheLocation fallback so field-bug-report bundles include pipeline topology. - GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GST_DEBUG_GRAPH_SHOW_ALL, "pipeline-error"); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipelineRef), GStreamer::kDiagnosticDotGraphDetails, "pipeline-error"); const QString dotPath = GStreamer::writePipelineDot(pipelineRef, "pipeline-error"); if (!dotPath.isEmpty()) { qCInfo(GstVideoReceiverLog) << "Pipeline graph saved to" << dotPath; diff --git a/src/VideoManager/VideoReceiver/GStreamer/README.md b/src/VideoManager/VideoReceiver/GStreamer/README.md index 5ffa3eacce36..f7be8459b617 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/README.md +++ b/src/VideoManager/VideoReceiver/GStreamer/README.md @@ -132,6 +132,8 @@ dot -Tpng /tmp/qgc-pipeline-dots/0.00.00.*-pipeline-started.dot -o pipeline.png When the env var is **unset**, QGC still writes a rotating snapshot (≤10 files) to `/qgc-pipeline-dot/-.dot` on `ERROR` and on watchdog timeout, so field-bug-report bundles include the topology automatically. The `GstVideoReceiver::dumpPipelineGraph(tag)` slot (callable from QML) writes a snapshot on demand for use from a debug menu. +QGC graph dumps include topology, caps, media types, and states. Element property values are omitted because source properties can contain stream credentials. + ### Latency tracer Per-element latency from source to sink: diff --git a/test/Utilities/Network/CMakeLists.txt b/test/Utilities/Network/CMakeLists.txt index ce0c1a5d072e..af4c50eba070 100644 --- a/test/Utilities/Network/CMakeLists.txt +++ b/test/Utilities/Network/CMakeLists.txt @@ -7,11 +7,8 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE QGCNetworkHelperTest.cc QGCNetworkHelperTest.h - QGCNetworkRedactionTest.cc - QGCNetworkRedactionTest.h ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_qgc_test(QGCNetworkHelperTest LABELS Unit Utilities Network) -add_qgc_test(QGCNetworkRedactionTest LABELS Unit Utilities) diff --git a/test/Utilities/Network/QGCNetworkHelperTest.cc b/test/Utilities/Network/QGCNetworkHelperTest.cc index 589b8fb8cc24..78e830e54170 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.cc +++ b/test/Utilities/Network/QGCNetworkHelperTest.cc @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -343,6 +344,61 @@ void QGCNetworkHelperTest::_testUrlWithoutQuery() QVERIFY(result.fragment().isEmpty()); } +void QGCNetworkHelperTest::_testRedactedUrlPreservesStreamIdentity() +{ + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("rtsp://camera.example:554/axis-media/media.amp")), + QStringLiteral("rtsp://camera.example:554/axis-media/media.amp")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("udp://0.0.0.0:5600")), + QStringLiteral("udp://0.0.0.0:5600")); +} + +void QGCNetworkHelperTest::_testRedactedUrlRemovesUserInfo() +{ + const QString result = QGCNetworkHelper::redactedUrlForLogging( + QStringLiteral("https://pilot%40ops:secret%2Fvalue@example.com:8443/video%20feed")); + + QCOMPARE(result, QStringLiteral("https://example.com:8443/video%20feed")); + QVERIFY(!result.contains(QStringLiteral("pilot"))); + QVERIFY(!result.contains(QStringLiteral("secret"))); +} + +void QGCNetworkHelperTest::_testRedactedUrlRedactsQueryValues() +{ + const QString result = QGCNetworkHelper::redactedUrlForLogging( + QStringLiteral("https://example.com/video?token=abc123&mode=low-latency#session")); + const QUrl resultUrl(result); + const QUrlQuery resultQuery(resultUrl); + + QCOMPARE(resultUrl.path(), QStringLiteral("/video")); + QCOMPARE(resultQuery.queryItemValue(QStringLiteral("token")), QStringLiteral("REDACTED")); + QCOMPARE(resultQuery.queryItemValue(QStringLiteral("mode")), QStringLiteral("REDACTED")); + QCOMPARE(resultUrl.fragment(), QStringLiteral("REDACTED")); + QVERIFY(!result.contains(QStringLiteral("abc123"))); + QVERIFY(!result.contains(QStringLiteral("low-latency"))); + QVERIFY(!result.contains(QStringLiteral("session"))); +} + +void QGCNetworkHelperTest::_testRedactedUrlHandlesRelativeAndInvalidInput() +{ + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("5600")), QStringLiteral("5600")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("camera.local:5600")), + QStringLiteral("camera.local:5600")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QString()), QStringLiteral("")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("http://[invalid")), + QStringLiteral("")); +} + +void QGCNetworkHelperTest::_testRedactedUrlQUrlOverload() +{ + const QUrl sourceUrl(QStringLiteral("rtsp://pilot:secret@camera.example:8554/live?token=abc123")); + const QString result = QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + + QCOMPARE(QUrl(result).path(), QStringLiteral("/live")); + QVERIFY(!result.contains(QStringLiteral("pilot"))); + QVERIFY(!result.contains(QStringLiteral("secret"))); + QVERIFY(!result.contains(QStringLiteral("abc123"))); +} + // ============================================================================ // Request Configuration Tests // ============================================================================ diff --git a/test/Utilities/Network/QGCNetworkHelperTest.h b/test/Utilities/Network/QGCNetworkHelperTest.h index 4b14265c6178..bef94e99522f 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.h +++ b/test/Utilities/Network/QGCNetworkHelperTest.h @@ -38,6 +38,11 @@ private slots: void _testBuildUrlFromMap(); void _testBuildUrlFromList(); void _testUrlWithoutQuery(); + void _testRedactedUrlPreservesStreamIdentity(); + void _testRedactedUrlRemovesUserInfo(); + void _testRedactedUrlRedactsQueryValues(); + void _testRedactedUrlHandlesRelativeAndInvalidInput(); + void _testRedactedUrlQUrlOverload(); // Request configuration tests void _testDefaultUserAgent(); diff --git a/test/Utilities/Network/QGCNetworkRedactionTest.cc b/test/Utilities/Network/QGCNetworkRedactionTest.cc deleted file mode 100644 index 79886bfd48ef..000000000000 --- a/test/Utilities/Network/QGCNetworkRedactionTest.cc +++ /dev/null @@ -1,32 +0,0 @@ -#include "QGCNetworkRedactionTest.h" - -#include "QGCNetworkHelper.h" - -void QGCNetworkRedactionTest::_testRedactedUrlForLogging() -{ - const QString sensitiveUrl = - QStringLiteral("https://pilot:secret@example.com:8443/video%20feed?token=abc123#session"); - const QString redacted = QGCNetworkHelper::redactedUrlForLogging(sensitiveUrl); - - QCOMPARE(redacted, QStringLiteral("https://example.com:8443/")); - QVERIFY(!redacted.contains(QStringLiteral("pilot"))); - QVERIFY(!redacted.contains(QStringLiteral("secret"))); - QVERIFY(!redacted.contains(QStringLiteral("abc123"))); - QVERIFY(!redacted.contains(QStringLiteral("video"))); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("udp://0.0.0.0:5600")), - QStringLiteral("udp://0.0.0.0:5600")); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://example.com/?token=abc123")), - QStringLiteral("https://example.com/")); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("https://[2001:db8::1]:8443/video?token=abc123")), - QStringLiteral("https://[2001:db8::1]:8443/")); - - const QString encodedUserInfo = QGCNetworkHelper::redactedUrlForLogging( - QStringLiteral("https://pilot%40ops:secret%2Fvalue@example.com/video?token=abc123")); - QCOMPARE(encodedUserInfo, QStringLiteral("https://example.com/")); - QVERIFY(!encodedUserInfo.contains(QStringLiteral("pilot"))); - QVERIFY(!encodedUserInfo.contains(QStringLiteral("secret"))); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("token-only-value")), - QStringLiteral("")); -} - -UT_REGISTER_TEST(QGCNetworkRedactionTest, TestLabel::Unit, TestLabel::Utilities) diff --git a/test/Utilities/Network/QGCNetworkRedactionTest.h b/test/Utilities/Network/QGCNetworkRedactionTest.h deleted file mode 100644 index 05a206d83514..000000000000 --- a/test/Utilities/Network/QGCNetworkRedactionTest.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once - -#include "UnitTest.h" - -class QGCNetworkRedactionTest : public UnitTest -{ - Q_OBJECT - -private slots: - void _testRedactedUrlForLogging(); -}; diff --git a/test/VideoManager/GStreamer/GStreamerTest.cc b/test/VideoManager/GStreamer/GStreamerTest.cc index 20a570c4215e..c38c5389d08b 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.cc +++ b/test/VideoManager/GStreamer/GStreamerTest.cc @@ -431,6 +431,33 @@ void GStreamerTest::_testWritePipelineDotReturnsEmptyOnWriteFailure() QVERIFY2(path.isEmpty(), qPrintable(QStringLiteral("Expected empty path for failed dot write, got %1").arg(path))); } +void GStreamerTest::_testPipelineDotOmitsElementProperties() +{ + GstElement* pipeline = gst_pipeline_new("safe-dot-test"); + QVERIFY(pipeline); + const auto pipelineCleanup = qScopeGuard([&] { gst_object_unref(pipeline); }); + + GstElement* source = gst_element_factory_make("filesrc", "source"); + GstElement* sink = gst_element_factory_make("fakesink", "sink"); + QVERIFY(source); + QVERIFY(sink); + + constexpr auto kSecretLocation = "/tmp/qgc-dot-secret-token"; + g_object_set(source, "location", kSecretLocation, nullptr); + gst_bin_add_many(GST_BIN(pipeline), source, sink, nullptr); + QVERIFY(gst_element_link(source, sink)); + + gchar* dotData = gst_debug_bin_to_dot_data(GST_BIN(pipeline), GStreamer::kDiagnosticDotGraphDetails); + QVERIFY(dotData); + const QByteArray dot(dotData); + g_free(dotData); + + QVERIFY(dot.contains("source")); + QVERIFY(dot.contains("sink")); + QVERIFY(!dot.contains(kSecretLocation)); + QVERIFY(!dot.contains("qgc-dot-secret-token")); +} + void GStreamerTest::_testCompleteInit() { GStreamer::redirectGLibLogging(); @@ -517,6 +544,7 @@ QGC_GST_SKIP_TEST(_testConfigureDebugLoggingIsIdempotent) QGC_GST_SKIP_TEST(_testVerifyRequiredPlugins) QGC_GST_SKIP_TEST(_testEnvironmentSetup) QGC_GST_SKIP_TEST(_testWritePipelineDotReturnsEmptyOnWriteFailure) +QGC_GST_SKIP_TEST(_testPipelineDotOmitsElementProperties) QGC_GST_SKIP_TEST(_testCompleteInit) QGC_GST_SKIP_TEST(_testCreateVideoReceiver) QGC_GST_SKIP_TEST(_testBindDebugLevelFactRejectsNullContext) diff --git a/test/VideoManager/GStreamer/GStreamerTest.h b/test/VideoManager/GStreamer/GStreamerTest.h index f45f2c6cc76f..edc12eecbed7 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.h +++ b/test/VideoManager/GStreamer/GStreamerTest.h @@ -22,6 +22,7 @@ private slots: void _testVerifyRequiredPlugins(); void _testEnvironmentSetup(); void _testWritePipelineDotReturnsEmptyOnWriteFailure(); + void _testPipelineDotOmitsElementProperties(); void _testCompleteInit(); void _testCreateVideoReceiver(); void _testBindDebugLevelFactRejectsNullContext(); From 8de9465f21b75ee98e0b2d88e0b008e0751e8c83 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 06:07:23 +0000 Subject: [PATCH 07/19] test(UnitTest): handle early local HTTP requests --- .../Fixtures/LocalHttpTestServer.cc | 55 ++++++++++++------- test/UnitTestFramework/Tests/CMakeLists.txt | 3 + .../Tests/LocalHttpTestServerTest.cc | 28 ++++++++++ .../Tests/LocalHttpTestServerTest.h | 11 ++++ 4 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc create mode 100644 test/UnitTestFramework/Tests/LocalHttpTestServerTest.h diff --git a/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc b/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc index a3ffc7e8edfb..6ae653861695 100644 --- a/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc +++ b/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc @@ -45,24 +45,33 @@ namespace { QByteArray httpReasonPhrase(int statusCode) { switch (statusCode) { - case 200: return QByteArrayLiteral("OK"); - case 204: return QByteArrayLiteral("No Content"); - case 206: return QByteArrayLiteral("Partial Content"); - case 304: return QByteArrayLiteral("Not Modified"); - case 400: return QByteArrayLiteral("Bad Request"); - case 404: return QByteArrayLiteral("Not Found"); - case 500: return QByteArrayLiteral("Internal Server Error"); - default: return QByteArrayLiteral("Status"); + case 200: + return QByteArrayLiteral("OK"); + case 204: + return QByteArrayLiteral("No Content"); + case 206: + return QByteArrayLiteral("Partial Content"); + case 304: + return QByteArrayLiteral("Not Modified"); + case 400: + return QByteArrayLiteral("Bad Request"); + case 404: + return QByteArrayLiteral("Not Found"); + case 500: + return QByteArrayLiteral("Internal Server Error"); + default: + return QByteArrayLiteral("Status"); } } -} // namespace +} // namespace void LocalHttpTestServer::installHttpResponder(const QByteArray& body, int statusCode, const QByteArray& contentType, int cacheMaxAge) { - QByteArray header = QStringLiteral("HTTP/1.1 %1 %2\r\n" - "Content-Type: %3\r\n" - "Connection: close\r\n") + QByteArray header = QStringLiteral( + "HTTP/1.1 %1 %2\r\n" + "Content-Type: %3\r\n" + "Connection: close\r\n") .arg(statusCode) .arg(QString::fromLatin1(httpReasonPhrase(statusCode))) .arg(QString::fromLatin1(contentType)) @@ -80,16 +89,20 @@ void LocalHttpTestServer::installRawResponder(const QByteArray& rawResponse) (void) QObject::connect(&_server, &QTcpServer::newConnection, &_server, [this, rawResponse]() { while (_server.hasPendingConnections()) { QTcpSocket* const socket = _server.nextPendingConnection(); - (void) QObject::connect( - socket, &QTcpSocket::readyRead, socket, - [socket, rawResponse]() { - socket->readAll(); - socket->write(rawResponse); - socket->flush(); - socket->disconnectFromHost(); - }, - Qt::SingleShotConnection); (void) QObject::connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); + + const auto sendResponse = [socket, rawResponse]() { + socket->readAll(); + socket->write(rawResponse); + socket->flush(); + socket->disconnectFromHost(); + }; + + if (socket->bytesAvailable() > 0) { + sendResponse(); + } else { + (void) QObject::connect(socket, &QTcpSocket::readyRead, socket, sendResponse, Qt::SingleShotConnection); + } } }); } diff --git a/test/UnitTestFramework/Tests/CMakeLists.txt b/test/UnitTestFramework/Tests/CMakeLists.txt index ce042e408f0a..73ab42187638 100644 --- a/test/UnitTestFramework/Tests/CMakeLists.txt +++ b/test/UnitTestFramework/Tests/CMakeLists.txt @@ -2,6 +2,8 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/MultiSignalSpyTest.cc ${CMAKE_CURRENT_SOURCE_DIR}/MultiSignalSpyTest.h + ${CMAKE_CURRENT_SOURCE_DIR}/LocalHttpTestServerTest.cc + ${CMAKE_CURRENT_SOURCE_DIR}/LocalHttpTestServerTest.h ${CMAKE_CURRENT_SOURCE_DIR}/SignalEmitter.h ${CMAKE_CURRENT_SOURCE_DIR}/TestBaseClassesTest.cc ${CMAKE_CURRENT_SOURCE_DIR}/TestBaseClassesTest.h @@ -16,6 +18,7 @@ target_sources(${CMAKE_PROJECT_NAME} target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_qgc_test(MultiSignalSpyTest LABELS Unit) +add_qgc_test(LocalHttpTestServerTest LABELS Unit) add_qgc_test(TestBaseClassesTest LABELS Unit) add_qgc_test(TestFixturesTest LABELS Unit) add_qgc_test(UnitTestAsyncHelpersTest LABELS Unit Utilities) diff --git a/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc new file mode 100644 index 000000000000..73b2d38b6a10 --- /dev/null +++ b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc @@ -0,0 +1,28 @@ +#include "LocalHttpTestServerTest.h" + +#include +#include + +#include "Fixtures/LocalHttpTestServer.h" + +void LocalHttpTestServerTest::_testEarlyRequest() +{ + TestFixtures::LocalHttpTestServer server; + QVERIFY(server.listen()); + server.installHttpResponder(QByteArrayLiteral("ready")); + + QTcpSocket client; + client.connectToHost(QHostAddress::LocalHost, server.port()); + QVERIFY(client.waitForConnected(TestTimeout::mediumMs())); + + const QByteArray request = QByteArrayLiteral("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + QCOMPARE(client.write(request), request.size()); + QVERIFY(client.waitForBytesWritten(TestTimeout::mediumMs())); + + QTRY_COMPARE_WITH_TIMEOUT(client.state(), QAbstractSocket::UnconnectedState, TestTimeout::mediumMs()); + const QByteArray response = client.readAll(); + QVERIFY(response.startsWith(QByteArrayLiteral("HTTP/1.1 200 OK\r\n"))); + QVERIFY(response.endsWith(QByteArrayLiteral("\r\n\r\nready"))); +} + +UT_REGISTER_TEST(LocalHttpTestServerTest, TestLabel::Unit) diff --git a/test/UnitTestFramework/Tests/LocalHttpTestServerTest.h b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.h new file mode 100644 index 000000000000..30342d8206b2 --- /dev/null +++ b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.h @@ -0,0 +1,11 @@ +#pragma once + +#include "UnitTest.h" + +class LocalHttpTestServerTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _testEarlyRequest(); +}; From e766d7b0c97c40ed781d6f64c57087847c63d05a Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 12:12:10 +0000 Subject: [PATCH 08/19] test(UnitTest): wait for complete HTTP requests --- .../Fixtures/LocalHttpTestServer.cc | 35 +++++++++++++++---- .../Tests/LocalHttpTestServerTest.cc | 13 +++++-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc b/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc index 6ae653861695..c6b21d056069 100644 --- a/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc +++ b/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc @@ -1,6 +1,7 @@ #include "LocalHttpTestServer.h" #include +#include #include #include @@ -8,6 +9,16 @@ namespace TestFixtures { +namespace { +constexpr qsizetype MAX_REQUEST_HEADER_SIZE = 64 * 1024; + +struct RawResponderState +{ + QByteArray request; + bool responseSent = false; +}; +} // namespace + LocalHttpTestServer::~LocalHttpTestServer() { close(); @@ -91,18 +102,28 @@ void LocalHttpTestServer::installRawResponder(const QByteArray& rawResponse) QTcpSocket* const socket = _server.nextPendingConnection(); (void) QObject::connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); - const auto sendResponse = [socket, rawResponse]() { - socket->readAll(); + const auto state = QSharedPointer::create(); + const auto sendResponseWhenRequestComplete = [socket, rawResponse, state]() { + if (state->responseSent) { + return; + } + + state->request.append(socket->readAll()); + if (!state->request.contains(QByteArrayLiteral("\r\n\r\n"))) { + if (state->request.size() > MAX_REQUEST_HEADER_SIZE) { + socket->disconnectFromHost(); + } + return; + } + + state->responseSent = true; socket->write(rawResponse); socket->flush(); socket->disconnectFromHost(); }; - if (socket->bytesAvailable() > 0) { - sendResponse(); - } else { - (void) QObject::connect(socket, &QTcpSocket::readyRead, socket, sendResponse, Qt::SingleShotConnection); - } + (void) QObject::connect(socket, &QTcpSocket::readyRead, socket, sendResponseWhenRequestComplete); + sendResponseWhenRequestComplete(); } }); } diff --git a/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc index 73b2d38b6a10..43175c87b69e 100644 --- a/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc +++ b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc @@ -2,6 +2,7 @@ #include #include +#include #include "Fixtures/LocalHttpTestServer.h" @@ -15,8 +16,16 @@ void LocalHttpTestServerTest::_testEarlyRequest() client.connectToHost(QHostAddress::LocalHost, server.port()); QVERIFY(client.waitForConnected(TestTimeout::mediumMs())); - const QByteArray request = QByteArrayLiteral("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); - QCOMPARE(client.write(request), request.size()); + QSignalSpy readyReadSpy(&client, &QTcpSocket::readyRead); + const QByteArray firstRequestFragment = + QByteArrayLiteral("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n"); + QCOMPARE(client.write(firstRequestFragment), firstRequestFragment.size()); + QVERIFY(client.waitForBytesWritten(TestTimeout::mediumMs())); + QVERIFY(!readyReadSpy.wait(TestTimeout::shortMs())); + QCOMPARE(client.state(), QAbstractSocket::ConnectedState); + + const QByteArray finalRequestFragment = QByteArrayLiteral("\r\n"); + QCOMPARE(client.write(finalRequestFragment), finalRequestFragment.size()); QVERIFY(client.waitForBytesWritten(TestTimeout::mediumMs())); QTRY_COMPARE_WITH_TIMEOUT(client.state(), QAbstractSocket::UnconnectedState, TestTimeout::mediumMs()); From 4a6ce652fc06fb1fa8c3ec59dd4b9190368a0c60 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Fri, 31 Jul 2026 01:21:26 +0000 Subject: [PATCH 09/19] test(UnitTest): address local HTTP fixture review --- .../Fixtures/LocalHttpTestServer.cc | 48 ++++++++----------- test/UnitTestFramework/Tests/CMakeLists.txt | 6 +-- .../Tests/LocalHttpTestServerTest.cc | 13 +++-- .../Tests/LocalHttpTestServerTest.h | 2 +- 4 files changed, 31 insertions(+), 38 deletions(-) diff --git a/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc b/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc index c6b21d056069..133950815a3a 100644 --- a/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc +++ b/test/UnitTestFramework/Fixtures/LocalHttpTestServer.cc @@ -17,7 +17,21 @@ struct RawResponderState QByteArray request; bool responseSent = false; }; -} // namespace + +QByteArray httpReasonPhrase(int statusCode) +{ + switch (statusCode) { + case 200: return QByteArrayLiteral("OK"); + case 204: return QByteArrayLiteral("No Content"); + case 206: return QByteArrayLiteral("Partial Content"); + case 304: return QByteArrayLiteral("Not Modified"); + case 400: return QByteArrayLiteral("Bad Request"); + case 404: return QByteArrayLiteral("Not Found"); + case 500: return QByteArrayLiteral("Internal Server Error"); + default: return QByteArrayLiteral("Status"); + } +} +} // namespace LocalHttpTestServer::~LocalHttpTestServer() { @@ -52,37 +66,12 @@ QString LocalHttpTestServer::url(const QString& path) const return QStringLiteral("http://%1:%2%3").arg(host).arg(port()).arg(path); } -namespace { -QByteArray httpReasonPhrase(int statusCode) -{ - switch (statusCode) { - case 200: - return QByteArrayLiteral("OK"); - case 204: - return QByteArrayLiteral("No Content"); - case 206: - return QByteArrayLiteral("Partial Content"); - case 304: - return QByteArrayLiteral("Not Modified"); - case 400: - return QByteArrayLiteral("Bad Request"); - case 404: - return QByteArrayLiteral("Not Found"); - case 500: - return QByteArrayLiteral("Internal Server Error"); - default: - return QByteArrayLiteral("Status"); - } -} -} // namespace - void LocalHttpTestServer::installHttpResponder(const QByteArray& body, int statusCode, const QByteArray& contentType, int cacheMaxAge) { - QByteArray header = QStringLiteral( - "HTTP/1.1 %1 %2\r\n" - "Content-Type: %3\r\n" - "Connection: close\r\n") + QByteArray header = QStringLiteral("HTTP/1.1 %1 %2\r\n" + "Content-Type: %3\r\n" + "Connection: close\r\n") .arg(statusCode) .arg(QString::fromLatin1(httpReasonPhrase(statusCode))) .arg(QString::fromLatin1(contentType)) @@ -111,6 +100,7 @@ void LocalHttpTestServer::installRawResponder(const QByteArray& rawResponse) state->request.append(socket->readAll()); if (!state->request.contains(QByteArrayLiteral("\r\n\r\n"))) { if (state->request.size() > MAX_REQUEST_HEADER_SIZE) { + state->responseSent = true; socket->disconnectFromHost(); } return; diff --git a/test/UnitTestFramework/Tests/CMakeLists.txt b/test/UnitTestFramework/Tests/CMakeLists.txt index 73ab42187638..d30982487aa7 100644 --- a/test/UnitTestFramework/Tests/CMakeLists.txt +++ b/test/UnitTestFramework/Tests/CMakeLists.txt @@ -1,9 +1,9 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/MultiSignalSpyTest.cc - ${CMAKE_CURRENT_SOURCE_DIR}/MultiSignalSpyTest.h ${CMAKE_CURRENT_SOURCE_DIR}/LocalHttpTestServerTest.cc ${CMAKE_CURRENT_SOURCE_DIR}/LocalHttpTestServerTest.h + ${CMAKE_CURRENT_SOURCE_DIR}/MultiSignalSpyTest.cc + ${CMAKE_CURRENT_SOURCE_DIR}/MultiSignalSpyTest.h ${CMAKE_CURRENT_SOURCE_DIR}/SignalEmitter.h ${CMAKE_CURRENT_SOURCE_DIR}/TestBaseClassesTest.cc ${CMAKE_CURRENT_SOURCE_DIR}/TestBaseClassesTest.h @@ -17,8 +17,8 @@ target_sources(${CMAKE_PROJECT_NAME} target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -add_qgc_test(MultiSignalSpyTest LABELS Unit) add_qgc_test(LocalHttpTestServerTest LABELS Unit) +add_qgc_test(MultiSignalSpyTest LABELS Unit) add_qgc_test(TestBaseClassesTest LABELS Unit) add_qgc_test(TestFixturesTest LABELS Unit) add_qgc_test(UnitTestAsyncHelpersTest LABELS Unit Utilities) diff --git a/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc index 43175c87b69e..c0f233b6f665 100644 --- a/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc +++ b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.cc @@ -1,19 +1,22 @@ #include "LocalHttpTestServerTest.h" -#include +#include "Fixtures/LocalHttpTestServer.h" + +#include #include #include -#include "Fixtures/LocalHttpTestServer.h" - -void LocalHttpTestServerTest::_testEarlyRequest() +void LocalHttpTestServerTest::_testFragmentedRequestHeader() { TestFixtures::LocalHttpTestServer server; QVERIFY(server.listen()); server.installHttpResponder(QByteArrayLiteral("ready")); + const QUrl serverUrl(server.url()); + QVERIFY(serverUrl.isValid()); + QTcpSocket client; - client.connectToHost(QHostAddress::LocalHost, server.port()); + client.connectToHost(serverUrl.host(), static_cast(serverUrl.port())); QVERIFY(client.waitForConnected(TestTimeout::mediumMs())); QSignalSpy readyReadSpy(&client, &QTcpSocket::readyRead); diff --git a/test/UnitTestFramework/Tests/LocalHttpTestServerTest.h b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.h index 30342d8206b2..2c077ab1e533 100644 --- a/test/UnitTestFramework/Tests/LocalHttpTestServerTest.h +++ b/test/UnitTestFramework/Tests/LocalHttpTestServerTest.h @@ -7,5 +7,5 @@ class LocalHttpTestServerTest : public UnitTest Q_OBJECT private slots: - void _testEarlyRequest(); + void _testFragmentedRequestHeader(); }; From 82b3a80c526c7fde2dafded164fe0200019a9ceb Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 05:36:25 +0000 Subject: [PATCH 10/19] feat(VideoManager): add HTTP MJPEG video source --- .github/build-config.json | 4 + .../GStreamer/tests/test_plugin_policy.cmake | 4 + .../qgc-user-guide/settings_view/general.md | 4 +- docs/en/qgc-user-guide/settings_view/video.md | 8 +- src/AppSettings/pages/Video.SettingsUI.json | 6 +- src/Settings/Video.SettingsGroup.json | 22 ++- src/Settings/VideoSettings.cc | 17 ++ src/Settings/VideoSettings.h | 4 + src/VideoManager/VideoManager.cc | 9 +- .../GStreamer/GstSourceFactory.cc | 120 +++++++++++- .../GStreamer/GstSourceFactory.h | 15 +- .../GStreamer/GstVideoReceiver.cc | 1 + test/VideoManager/GStreamer/GStreamerTest.cc | 3 + test/VideoManager/GStreamer/GStreamerTest.h | 3 + .../GStreamerSourceFactoryTest.cc | 181 ++++++++++++++++++ 15 files changed, 384 insertions(+), 17 deletions(-) diff --git a/.github/build-config.json b/.github/build-config.json index 24257720c20f..60bfa0a7ded7 100644 --- a/.github/build-config.json +++ b/.github/build-config.json @@ -38,9 +38,12 @@ "app", "coreelements", "isomp4", + "jpeg", + "jpegformat", "libav", "matroska", "mpegtsdemux", + "multipart", "multifile", "opengl", "openh264", @@ -49,6 +52,7 @@ "rtpmanager", "rtsp", "sdpelem", + "soup", "tcp", "typefindfunctions", "udp", diff --git a/cmake/GStreamer/tests/test_plugin_policy.cmake b/cmake/GStreamer/tests/test_plugin_policy.cmake index 66bd4541d7ef..e08e6af84479 100644 --- a/cmake/GStreamer/tests/test_plugin_policy.cmake +++ b/cmake/GStreamer/tests/test_plugin_policy.cmake @@ -35,6 +35,10 @@ file(READ "${CMAKE_CURRENT_LIST_DIR}/../../../.github/build-config.json" QGC_BUI gstreamer_plugins_for(PLATFORM windows OUT_VAR _plugins_windows_real) qgc_test_assert_in_list("windows has d3d11" d3d11 _plugins_windows_real) qgc_test_assert_in_list("windows has d3d12" d3d12 _plugins_windows_real) +qgc_test_assert_in_list("common has native JPEG decoder" jpeg _plugins_windows_real) +qgc_test_assert_in_list("common has HTTP MJPEG jpeg parser" jpegformat _plugins_windows_real) +qgc_test_assert_in_list("common has HTTP MJPEG multipart demuxer" multipart _plugins_windows_real) +qgc_test_assert_in_list("common has HTTP client source" soup _plugins_windows_real) qgc_test_pass("plugins_for real windows d3d addenda") set(_req videoconvertscale videoconvert videoscale x264enc) diff --git a/docs/en/qgc-user-guide/settings_view/general.md b/docs/en/qgc-user-guide/settings_view/general.md index 70b5cb9af8ba..045e7f030a59 100644 --- a/docs/en/qgc-user-guide/settings_view/general.md +++ b/docs/en/qgc-user-guide/settings_view/general.md @@ -201,13 +201,15 @@ The _Video_ section is used to define the source and connection settings for vid The settings are: -- **Video Source**: Video Stream Disabled | RTSP Video Stream | UDP h.264 Video Stream | UDP h.265 Video Stream | TCP-MPEG2 Video Stream | MPEG-TS Video Stream | Integrated Camera +- **Video Source**: Video Stream Disabled | RTSP Video Stream | HTTP MJPEG Video Stream | UDP h.264 Video Stream | UDP h.265 Video Stream | TCP-MPEG2 Video Stream | MPEG-TS Video Stream | Integrated Camera ::: info If no video source is specified then no other video or _video recording_ settings will be displayed. ::: - **URL/Port**: Connection type-specific stream address (may be port or URL). + HTTP MJPEG requires a full `http://` or `https://` URL serving + `multipart/x-mixed-replace` JPEG frames. - **Aspect Ratio**: Aspect ratio for scaling video in video widget (set to 0.0 to ignore scaling) - **Disabled When Disarmed**: Disable video feed when vehicle is disarmed. - **Low Latency Mode**: Enabling low latency mode reduces the video stream latency, but may cause frame loss and choppy video (especially with a poor network connection). diff --git a/docs/en/qgc-user-guide/settings_view/video.md b/docs/en/qgc-user-guide/settings_view/video.md index 6287717c14e0..0c8412330da1 100644 --- a/docs/en/qgc-user-guide/settings_view/video.md +++ b/docs/en/qgc-user-guide/settings_view/video.md @@ -4,16 +4,22 @@ Configure video streaming and recording settings. ## Video Source -- **Source** — Video Stream Disabled / RTSP Video Stream / UDP h.264 / UDP h.265 / TCP-MPEG2 / MPEG-TS / Integrated Camera +- **Source** — Video Stream Disabled / RTSP Video Stream / HTTP MJPEG Video Stream / UDP h.264 / UDP h.265 / TCP-MPEG2 / MPEG-TS / Integrated Camera ## Connection Connection settings vary by source type: - **RTSP URL** — full RTSP stream address +- **HTTP MJPEG URL** — full `http://` or `https://` address of a multipart MJPEG stream - **TCP URL** — TCP stream address - **UDP URL** — UDP stream address and port (default: `0.0.0.0:5600`) +HTTP MJPEG expects a `multipart/x-mixed-replace` response containing JPEG frames. +It does not accept a web page, a single JPEG URL, or an arbitrary HTTP video file. +HTTPS certificate validation remains enabled in GStreamer, redirects are not followed, and URL user information +(`user:password@host`) is rejected. + ## Settings - **Aspect Ratio** — aspect ratio for scaling video in the display widget (default: 16:9; set to 0.0 to disable scaling) diff --git a/src/AppSettings/pages/Video.SettingsUI.json b/src/AppSettings/pages/Video.SettingsUI.json index f2f5837398e3..641520c097b2 100644 --- a/src/AppSettings/pages/Video.SettingsUI.json +++ b/src/AppSettings/pages/Video.SettingsUI.json @@ -23,13 +23,17 @@ }, { "heading": "Connection", - "keywords": ["rtsp", "tcp", "udp", "mpegts", "video url", "stream url"], + "keywords": ["rtsp", "http", "https", "mjpeg", "jpeg", "tcp", "udp", "mpegts", "video url", "stream url"], "showWhen": "!sourceDisabled && !autoStreamConfig", "controls": [ { "setting": "videoSettings.rtspUrl", "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.rtspVideoSource" }, + { + "setting": "videoSettings.httpMjpegUrl", + "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.httpMjpegVideoSource" + }, { "setting": "videoSettings.tcpUrl", "showWhen": "videoSource === QGroundControl.settingsManager.videoSettings.tcpVideoSource" diff --git a/src/Settings/Video.SettingsGroup.json b/src/Settings/Video.SettingsGroup.json index 9a9c0cd4c213..79cc4b5dc04b 100644 --- a/src/Settings/Video.SettingsGroup.json +++ b/src/Settings/Video.SettingsGroup.json @@ -4,8 +4,8 @@ "QGC.MetaData.Facts": [ { "name": "videoSource", - "shortDesc": "Source for video stream (UDP, TCP, RTSP, or connected USB camera).", - "longDesc": "Source for video. UDP, TCP, RTSP and UVC Cameras may be supported depending on Vehicle and ground station version.", + "shortDesc": "Source for video stream (UDP, TCP, RTSP, HTTP MJPEG, or connected USB camera).", + "longDesc": "Source for video. UDP, TCP, RTSP, HTTP multipart MJPEG and UVC cameras may be supported depending on the ground station build.", "type": "string", "default": "", "label": "Source", @@ -29,6 +29,15 @@ "label": "RTSP URL", "keywords": "rtsp,video url,stream url" }, + { + "name": "httpMjpegUrl", + "shortDesc": "Full URL for an HTTP multipart MJPEG stream.", + "longDesc": "Full http:// or https:// URL for a multipart MJPEG stream (multipart/x-mixed-replace with JPEG frames). User information in the URL is not supported.", + "type": "string", + "default": "", + "label": "HTTP MJPEG URL", + "keywords": "http,https,mjpeg,jpeg,video url,stream url" + }, { "name": "tcpUrl", "shortDesc": "Network address and port for TCP video stream (e.g. 192.168.143.200:3001).", @@ -117,13 +126,14 @@ }, { "name": "rtspTimeout", - "shortDesc": "RTSP Video Timeout", - "longDesc": "How long to wait before assuming RTSP link is gone.", + "shortDesc": "Network video timeout.", + "longDesc": "How long to wait before assuming a timeout-based network video source is unavailable.", "type": "uint32", "min": 1, "units": "s", "default": 8, - "label": "RTSP Video Timeout" + "label": "Network Video Timeout", + "keywords": "rtsp,http,mjpeg,network,video timeout" }, { "name": "streamEnabled", @@ -170,7 +180,7 @@ "type": "bool", "default": true, "label": "Auto-reconnect on stream loss", - "keywords": "rtsp,reconnect,watchdog,recovery,advanced" + "keywords": "rtsp,http,mjpeg,stream,reconnect,watchdog,recovery,advanced" }, { "name": "forceVideoDecoder", diff --git a/src/Settings/VideoSettings.cc b/src/Settings/VideoSettings.cc index ffe9767c42c3..31f1fa7e449d 100644 --- a/src/Settings/VideoSettings.cc +++ b/src/Settings/VideoSettings.cc @@ -21,6 +21,9 @@ DECLARE_SETTINGGROUP(Video, "Video") // Setup enum values for videoSource settings into meta data QVariantList videoSourceList; videoSourceList.append(videoSourceRTSP); + if (kGstEnabled) { + videoSourceList.append(videoSourceHTTPMJPEG); + } videoSourceList.append(videoSourceUDPH264); videoSourceList.append(videoSourceUDPH265); videoSourceList.append(videoSourceTCP); @@ -221,6 +224,15 @@ DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, rtspUrl) return _rtspUrlFact; } +DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, httpMjpegUrl) +{ + if (!_httpMjpegUrlFact) { + _httpMjpegUrlFact = _createSettingsFact(httpMjpegUrlName); + connect(_httpMjpegUrlFact, &Fact::valueChanged, this, &VideoSettings::_configChanged); + } + return _httpMjpegUrlFact; +} + DECLARE_SETTINGSFACT_NO_FUNC(VideoSettings, tcpUrl) { if (!_tcpUrlFact) { @@ -256,6 +268,11 @@ bool VideoSettings::streamConfigured(void) << QGCNetworkHelper::redactedUrlForLogging(url); return !url.isEmpty(); } + //-- If HTTP MJPEG, check for URL + if (vSource == videoSourceHTTPMJPEG) { + qCDebug(VideoSettingsLog) << "Testing configuration for HTTP MJPEG Stream"; + return !httpMjpegUrl()->rawValue().toString().isEmpty(); + } //-- If TCP, check for URL if(vSource == videoSourceTCP) { const QString url = tcpUrl()->rawValue().toString(); diff --git a/src/Settings/VideoSettings.h b/src/Settings/VideoSettings.h index 6bd21c19b1b5..9b83cdbf30aa 100644 --- a/src/Settings/VideoSettings.h +++ b/src/Settings/VideoSettings.h @@ -17,6 +17,7 @@ class VideoSettings : public SettingsGroup DEFINE_SETTINGFACT(udpUrl) DEFINE_SETTINGFACT(tcpUrl) DEFINE_SETTINGFACT(rtspUrl) + DEFINE_SETTINGFACT(httpMjpegUrl) DEFINE_SETTINGFACT(aspectRatio) DEFINE_SETTINGFACT(videoFit) DEFINE_SETTINGFACT(gridLines) @@ -37,6 +38,7 @@ class VideoSettings : public SettingsGroup Q_PROPERTY(bool streamConfigured READ streamConfigured NOTIFY streamConfiguredChanged) Q_PROPERTY(QString rtspVideoSource READ rtspVideoSource CONSTANT) + Q_PROPERTY(QString httpMjpegVideoSource READ httpMjpegVideoSource CONSTANT) Q_PROPERTY(QString udp264VideoSource READ udp264VideoSource CONSTANT) Q_PROPERTY(QString udp265VideoSource READ udp265VideoSource CONSTANT) Q_PROPERTY(QString tcpVideoSource READ tcpVideoSource CONSTANT) @@ -45,6 +47,7 @@ class VideoSettings : public SettingsGroup bool streamConfigured (); QString rtspVideoSource () { return videoSourceRTSP; } + QString httpMjpegVideoSource () { return videoSourceHTTPMJPEG; } QString udp264VideoSource () { return videoSourceUDPH264; } QString udp265VideoSource () { return videoSourceUDPH265; } QString tcpVideoSource () { return videoSourceTCP; } @@ -59,6 +62,7 @@ class VideoSettings : public SettingsGroup static constexpr const char* videoSourceNoVideo = QT_TRANSLATE_NOOP("VideoSettings", "No Video Available"); static constexpr const char* videoDisabled = QT_TRANSLATE_NOOP("VideoSettings", "Video Stream Disabled"); static constexpr const char* videoSourceRTSP = QT_TRANSLATE_NOOP("VideoSettings", "RTSP Video Stream"); + static constexpr const char* videoSourceHTTPMJPEG = QT_TRANSLATE_NOOP("VideoSettings", "HTTP MJPEG Video Stream"); static constexpr const char* videoSourceUDPH264 = QT_TRANSLATE_NOOP("VideoSettings", "UDP h.264 Video Stream"); static constexpr const char* videoSourceUDPH265 = QT_TRANSLATE_NOOP("VideoSettings", "UDP h.265 Video Stream"); static constexpr const char* videoSourceTCP = QT_TRANSLATE_NOOP("VideoSettings", "TCP-MPEG2 Video Stream"); diff --git a/src/VideoManager/VideoManager.cc b/src/VideoManager/VideoManager.cc index 624923aae7b2..3f13c5353eb0 100644 --- a/src/VideoManager/VideoManager.cc +++ b/src/VideoManager/VideoManager.cc @@ -178,6 +178,7 @@ void VideoManager::init(QQuickWindow *mainWindow) (void) connect(_videoSettings->videoSource(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->udpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->rtspUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); + (void) connect(_videoSettings->httpMjpegUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->tcpUrl(), &Fact::rawValueChanged, this, &VideoManager::_videoSourceChanged); (void) connect(_videoSettings->aspectRatio(), &Fact::rawValueChanged, this, &VideoManager::aspectRatioChanged); (void) connect(_videoSettings->lowLatencyMode(), &Fact::rawValueChanged, this, [this](const QVariant &value) { Q_UNUSED(value); _restartAllVideos(); }); @@ -501,6 +502,7 @@ bool VideoManager::isStreamSource() const VideoSettings::videoSourceUDPH264, VideoSettings::videoSourceUDPH265, VideoSettings::videoSourceRTSP, + VideoSettings::videoSourceHTTPMJPEG, VideoSettings::videoSourceTCP, VideoSettings::videoSourceMPEGTS, VideoSettings::videoSource3DRSolo, @@ -703,6 +705,8 @@ bool VideoManager::_updateSettings(VideoReceiver *receiver) settingsChanged |= _updateVideoUri(receiver, QStringLiteral("mpegts://%1").arg(_videoSettings->udpUrl()->rawValue().toString())); } else if (source == VideoSettings::videoSourceRTSP) { settingsChanged |= _updateVideoUri(receiver, _videoSettings->rtspUrl()->rawValue().toString()); + } else if (source == VideoSettings::videoSourceHTTPMJPEG) { + settingsChanged |= _updateVideoUri(receiver, _videoSettings->httpMjpegUrl()->rawValue().toString()); } else if (source == VideoSettings::videoSourceTCP) { settingsChanged |= _updateVideoUri(receiver, QStringLiteral("tcp://%1").arg(_videoSettings->tcpUrl()->rawValue().toString())); } else if (source == VideoSettings::videoSource3DRSolo) { @@ -844,7 +848,10 @@ void VideoManager::_startReceiver(VideoReceiver *receiver) } const QString source = _videoSettings->videoSource()->rawValue().toString(); - const uint32_t timeout = ((source == VideoSettings::videoSourceRTSP) ? _videoSettings->rtspTimeout()->rawValue().toUInt() : 3); + const bool usesNetworkTimeout = + (source == VideoSettings::videoSourceRTSP) || (source == VideoSettings::videoSourceHTTPMJPEG); + // Keep the existing Fact/persistence key for compatibility while its UI meaning expands to timeout-based sources. + const uint32_t timeout = usesNetworkTimeout ? _videoSettings->rtspTimeout()->rawValue().toUInt() : 3; receiver->start(timeout); } diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 281cb98afdb8..921661bbe65a 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -402,6 +403,118 @@ GstElement* buildUdpSource(const QUrl& sourceUrl, bool isUdpH264, bool isUdpH265 return source; } +void linkMultipartJpegPad(GstElement* element, GstPad* pad, gpointer data) +{ + GstElement* parser = GST_ELEMENT(data); + if (!element || !pad || !parser || (GST_PAD_DIRECTION(pad) != GST_PAD_SRC)) { + return; + } + + GstCaps* jpegCaps = gst_caps_from_string("image/jpeg"); + GstCaps* padCaps = gst_pad_get_current_caps(pad); + if (!padCaps) { + padCaps = gst_pad_query_caps(pad, nullptr); + } + const bool isJpeg = jpegCaps && padCaps && gst_caps_can_intersect(padCaps, jpegCaps); + gst_clear_caps(&padCaps); + gst_clear_caps(&jpegCaps); + if (!isJpeg) { + return; + } + + GstPad* parserSink = gst_element_get_static_pad(parser, "sink"); + if (!parserSink) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG parser sink pad is unavailable"; + return; + } + + if (!gst_pad_is_linked(parserSink)) { + const GstPadLinkReturn result = gst_pad_link(pad, parserSink); + if (result != GST_PAD_LINK_OK) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG demux/parser link failed:" << result; + } + } + gst_object_unref(parserSink); +} + +GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) +{ + if (!sourceUrl.isValid() || sourceUrl.isRelative() || sourceUrl.host().isEmpty() || (sourceUrl.port() == 0)) { + qCWarning(GstSourceFactoryLog) << "Invalid HTTP MJPEG URL:" + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + return nullptr; + } + if (!sourceUrl.userInfo().isEmpty()) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG credentials in URLs are not supported"; + return nullptr; + } + + GstElement* source = gst_element_factory_make("souphttpsrc", "source"); + GstElement* demux = gst_element_factory_make("multipartdemux", "multipart-demux"); + GstElement* parser = gst_element_factory_make("jpegparse", "jpeg-parser"); + GstElement* bin = gst_bin_new("sourcebin"); + GstElement* sourceBin = nullptr; + + do { + if (!source || !demux || !parser || !bin) { + qCWarning(GstSourceFactoryLog) << "HTTP MJPEG requires souphttpsrc, multipartdemux, and jpegparse"; + break; + } + + QUrl cleanUrl(sourceUrl); + cleanUrl.setUserInfo(QString()); + cleanUrl.setFragment(QString()); + const QByteArray location = cleanUrl.toEncoded(QUrl::FullyEncoded); + const QByteArray userAgent = QGCNetworkHelper::defaultUserAgent().toUtf8(); + const guint timeoutS = std::clamp(config.timeoutS, 1u, 3600u); + g_object_set(source, "location", location.constData(), "method", "GET", "is-live", TRUE, "do-timestamp", TRUE, + "keep-alive", TRUE, "compress", FALSE, "iradio-mode", FALSE, "automatic-redirect", FALSE, + "retries", 0, "timeout", timeoutS, "ssl-strict", TRUE, "ssl-use-system-ca-file", TRUE, + "http-log-level", 0, "user-agent", userAgent.constData(), nullptr); + g_object_set(demux, "single-stream", TRUE, nullptr); + + if (!gst_bin_add(GST_BIN(bin), source)) { + qCWarning(GstSourceFactoryLog) << "Failed to add HTTP source to source bin"; + break; + } + GstElement* binSource = source; + source = nullptr; + + if (!gst_bin_add(GST_BIN(bin), demux)) { + qCWarning(GstSourceFactoryLog) << "Failed to add multipart demuxer to source bin"; + break; + } + GstElement* binDemux = demux; + demux = nullptr; + + if (!gst_bin_add(GST_BIN(bin), parser)) { + qCWarning(GstSourceFactoryLog) << "Failed to add JPEG parser to source bin"; + break; + } + GstElement* binParser = parser; + parser = nullptr; + + if (!gst_element_link(binSource, binDemux)) { + qCWarning(GstSourceFactoryLog) << "Failed to link HTTP source to multipart demuxer"; + break; + } + (void) g_signal_connect_object(binDemux, "pad-added", G_CALLBACK(linkMultipartJpegPad), binParser, + G_CONNECT_DEFAULT); + if (!addStaticGhostPad(binParser)) { + break; + } + + sourceBin = bin; + bin = nullptr; + } while (false); + + gst_clear_object(&bin); + gst_clear_object(&parser); + gst_clear_object(&demux); + gst_clear_object(&source); + return sourceBin; +} + // Wire upstream → (optional rtpjitterbuffer) → binParser, topology chosen by RTP probe (MPEG-TS // links via pad-added). Created elements join @p bin; returns false (logged) on failure. bool linkSourceToParser(GstElement* bin, GstElement* upstream, GstElement* binParser, const Config& config, @@ -507,13 +620,18 @@ GstElement* create(const QString& uri, const Config& config) const bool isUdpH265 = (scheme == QLatin1String("udp265")); const bool isUdpMPEGTS = (scheme == QLatin1String("mpegts")); const bool isTcpMPEGTS = (scheme == QLatin1String("tcp")); + const bool isHttpMjpeg = (scheme == QLatin1String("http")) || (scheme == QLatin1String("https")); - if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS) { + if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS && !isHttpMjpeg) { qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } + if (isHttpMjpeg) { + return buildHttpMjpegSource(sourceUrl, config); + } + // Owning locals until gst_bin_add*, then nulled (non-owning alias used downstream) so the // unconditional gst_clear_object cleanup at the bottom stays safe. GstElement* source = nullptr; diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h index 00c12ed127b5..909ae0f84977 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace GStreamer::SourceFactory { @@ -22,15 +23,17 @@ struct Config JitterBuffer jitterBuffer = JitterBuffer::DropOnLatency; int latencyMs = 80; bool doRetransmission = true; + /// Blocking network I/O timeout for source elements that expose one, in seconds. + uint32_t timeoutS = 8; }; -/// Build a source bin (`source` [+ `tsdemux`] [+ `rtpjitterbuffer`] + `parsebin`) -/// for `uri`. Supported schemes: rtsp/rtspt, tcp:// (MPEG-TS), udp:// (H.264 RTP), -/// udp265:// (H.265 RTP), mpegts:// (MPEG-TS over UDP). +/// Build a source bin that exposes parsed encoded video for `uri`. +/// Supported schemes: rtsp/rtspt, tcp:// (MPEG-TS), udp:// (H.264 RTP), +/// udp265:// (H.265 RTP), mpegts:// (MPEG-TS over UDP), and http(s):// +/// (multipart MJPEG). /// -/// Ghost pads on the returned bin are wired lazily; for `rtspsrc`/`tsdemux`/`parsebin` -/// they appear only after upstream produces pads, so callers must connect any -/// downstream `pad-added` handlers before transitioning to PLAYING. +/// Ghost pads on RTP/MPEG-TS bins are wired lazily after upstream produces pads. +/// The HTTP MJPEG bin exposes its parsed-JPEG pad immediately. /// /// Returns the source bin or nullptr on failure. GstElement* create(const QString& uri, const Config& config); diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc index 7a373dd635a8..1417208753c3 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstVideoReceiver.cc @@ -208,6 +208,7 @@ void GstVideoReceiver::start(uint32_t timeout) ? GStreamer::SourceFactory::JitterBuffer::DropOnLatency : GStreamer::SourceFactory::JitterBuffer::Buffered); sourceConfig.latencyMs = _rtpJitterLatencyMs; + sourceConfig.timeoutS = timeout; // do-retransmission needs ≥40 ms latency headroom over the default 20 ms rtx-delay; // forcibly disable for sub-frame latency configurations to avoid retransmit storms. sourceConfig.doRetransmission = (_rtpJitterLatencyMs >= 40) && (sourceConfig.jitterBuffer != GStreamer::SourceFactory::JitterBuffer::None); diff --git a/test/VideoManager/GStreamer/GStreamerTest.cc b/test/VideoManager/GStreamer/GStreamerTest.cc index c38c5389d08b..f0dd643831df 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.cc +++ b/test/VideoManager/GStreamer/GStreamerTest.cc @@ -599,6 +599,9 @@ QGC_GST_SKIP_TEST(_testSourceFactoryUdpRtpJitterBuffer) QGC_GST_SKIP_TEST(_testSourceFactoryJitterBufferNone) QGC_GST_SKIP_TEST(_testSourceFactoryNoRetransmission) QGC_GST_SKIP_TEST(_testSourceFactoryRtspExcludesStaticJitterBuffer) +QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpeg) +QGC_GST_SKIP_TEST(_testSourceFactoryHttpMjpegDelivery) +QGC_GST_SKIP_TEST(_testSourceFactoryRejectsUnsafeHttpMjpegUrl) QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadUri) QGC_GST_SKIP_TEST(_testSourceFactoryTcpMpegTs) QGC_GST_SKIP_TEST(_testSourceFactoryRejectsBadTcpUri) diff --git a/test/VideoManager/GStreamer/GStreamerTest.h b/test/VideoManager/GStreamer/GStreamerTest.h index edc12eecbed7..ad202b33da2f 100644 --- a/test/VideoManager/GStreamer/GStreamerTest.h +++ b/test/VideoManager/GStreamer/GStreamerTest.h @@ -85,6 +85,9 @@ private slots: void _testSourceFactoryJitterBufferNone(); void _testSourceFactoryNoRetransmission(); void _testSourceFactoryRtspExcludesStaticJitterBuffer(); + void _testSourceFactoryHttpMjpeg(); + void _testSourceFactoryHttpMjpegDelivery(); + void _testSourceFactoryRejectsUnsafeHttpMjpegUrl(); void _testSourceFactoryRejectsBadUri(); void _testSourceFactoryTcpMpegTs(); void _testSourceFactoryRejectsBadTcpUri(); diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 35895f2a9dba..75294c575ba4 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -2,14 +2,27 @@ #ifdef QGC_GST_STREAMING +#include #include #include +#include +#include #include #include "GstSourceFactory.h" +#include "LocalHttpTestServer.h" +#include "QGCNetworkHelper.h" namespace { +GstSample* tryPullSampleOrPreroll(GstAppSink* sink) +{ + if (GstSample* sample = gst_app_sink_try_pull_sample(sink, 0)) { + return sample; + } + return gst_app_sink_try_pull_preroll(sink, 0); +} + // Borrowed (bin-owned) first child whose element-factory name matches, or nullptr. GstElement* findChildByFactoryName(GstElement* bin, const char* factoryName) { @@ -132,6 +145,174 @@ void GStreamerTest::_testSourceFactoryRtspExcludesStaticJitterBuffer() "rtspsrc owns its internal jitterbuffer; the factory must not add a second one"); } +void GStreamerTest::_testSourceFactoryHttpMjpeg() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse")) { + QSKIP("souphttpsrc/multipartdemux/jpegparse plugins unavailable"); + } + + GStreamer::SourceFactory::Config config; + config.timeoutS = 9; + GstElement* bin = GStreamer::SourceFactory::create( + QStringLiteral("https://video.example.test:8443/camera.mjpg?quality=80#local-view"), config); + QVERIFY(bin); + const auto cleanup = qScopeGuard([&] { gst_object_unref(bin); }); + + GstElement* source = findChildByFactoryName(bin, "souphttpsrc"); + GstElement* demux = findChildByFactoryName(bin, "multipartdemux"); + GstElement* parser = findChildByFactoryName(bin, "jpegparse"); + QVERIFY(source); + QVERIFY(demux); + QVERIFY(parser); + + gchar* location = nullptr; + gchar* method = nullptr; + gboolean isLive = FALSE; + gboolean doTimestamp = FALSE; + gboolean keepAlive = FALSE; + gboolean compress = TRUE; + gboolean iradioMode = TRUE; + gboolean automaticRedirect = TRUE; + gboolean sslStrict = FALSE; + gboolean sslUseSystemCaFile = FALSE; + gboolean singleStream = FALSE; + gint retries = -1; + guint timeout = 0; + gint httpLogLevel = -1; + gchar* userAgent = nullptr; + g_object_get(source, "location", &location, "method", &method, "is-live", &isLive, "do-timestamp", &doTimestamp, + "keep-alive", &keepAlive, "compress", &compress, "iradio-mode", &iradioMode, "automatic-redirect", + &automaticRedirect, "retries", &retries, "timeout", &timeout, "ssl-strict", &sslStrict, + "ssl-use-system-ca-file", &sslUseSystemCaFile, "http-log-level", &httpLogLevel, "user-agent", + &userAgent, nullptr); + const auto stringsCleanup = qScopeGuard([&] { + g_free(location); + g_free(method); + g_free(userAgent); + }); + g_object_get(demux, "single-stream", &singleStream, nullptr); + + QCOMPARE(QString::fromUtf8(location), QStringLiteral("https://video.example.test:8443/camera.mjpg?quality=80")); + QCOMPARE(QString::fromUtf8(method), QStringLiteral("GET")); + QCOMPARE(isLive, TRUE); + QCOMPARE(doTimestamp, TRUE); + QCOMPARE(keepAlive, TRUE); + QCOMPARE(compress, FALSE); + QCOMPARE(iradioMode, FALSE); + QCOMPARE(automaticRedirect, FALSE); + QCOMPARE(retries, 0); + QCOMPARE(timeout, 9u); + QCOMPARE(sslStrict, TRUE); + QCOMPARE(sslUseSystemCaFile, TRUE); + QCOMPARE(httpLogLevel, 0); + QCOMPARE(QString::fromUtf8(userAgent), QGCNetworkHelper::defaultUserAgent()); + QCOMPARE(singleStream, TRUE); + + static GstStaticPadTemplate jpegPadTemplate = + GST_STATIC_PAD_TEMPLATE("src_%u", GST_PAD_SRC, GST_PAD_SOMETIMES, GST_STATIC_CAPS("image/jpeg")); + GstPad* jpegPad = gst_pad_new_from_static_template(&jpegPadTemplate, "src_0"); + QVERIFY(jpegPad); + QVERIFY2(gst_element_add_pad(demux, jpegPad), "multipartdemux test pad must be accepted"); + + GstPad* parserSink = gst_element_get_static_pad(parser, "sink"); + QVERIFY(parserSink); + const auto parserSinkCleanup = qScopeGuard([&] { gst_object_unref(parserSink); }); + QVERIFY2(gst_pad_is_linked(parserSink), "multipart JPEG pad-added must link to jpegparse"); + GstPad* peer = gst_pad_get_peer(parserSink); + QVERIFY(peer); + QCOMPARE(peer, jpegPad); + gst_object_unref(peer); + QVERIFY(gst_element_remove_pad(demux, jpegPad)); + + GstPad* srcPad = gst_element_get_static_pad(bin, "src"); + QVERIFY2(srcPad, "HTTP MJPEG source bin must expose a static parsed-JPEG source pad"); + gst_object_unref(srcPad); +} + +void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() +{ + if (!gst_element_factory_find("souphttpsrc") || !gst_element_factory_find("multipartdemux") || + !gst_element_factory_find("jpegparse") || !gst_element_factory_find("appsink")) { + QSKIP("souphttpsrc/multipartdemux/jpegparse/appsink plugins unavailable"); + } + + QByteArray jpeg; + QBuffer jpegBuffer(&jpeg); + QVERIFY(jpegBuffer.open(QIODevice::WriteOnly)); + QImage image(16, 16, QImage::Format_RGB32); + image.fill(Qt::green); + QVERIFY2(image.save(&jpegBuffer, "JPEG"), "Qt JPEG encoder unavailable"); + + const QByteArray boundary("qgc-test-boundary"); + const QByteArray framePart = "--" + boundary + + "\r\n" + "Content-Type: image/jpeg\r\n" + "Content-Length: " + + QByteArray::number(jpeg.size()) + "\r\n\r\n" + jpeg + "\r\n"; + // A multipart video stream contains repeated images. Supplying two also + // verifies delivery after multipartdemux exposes and links its dynamic pad. + const QByteArray body = framePart + framePart + "--" + boundary + "--\r\n"; + const QByteArray response = + "HTTP/1.1 200 OK\r\n" + "Content-Type: multipart/x-mixed-replace; boundary=" + + boundary + + "\r\n" + "Connection: close\r\n" + "Content-Length: " + + QByteArray::number(body.size()) + "\r\n\r\n" + body; + + TestFixtures::LocalHttpTestServer server; + QVERIFY2(server.listen(), "Could not start local MJPEG test server"); + server.installRawResponder(response); + + GStreamer::SourceFactory::Config config; + config.timeoutS = 5; + GstElement* source = GStreamer::SourceFactory::create(server.url(QStringLiteral("/video_feed")), config); + GstElement* sink = gst_element_factory_make("appsink", "sink"); + GstElement* pipeline = gst_pipeline_new("http-mjpeg-delivery-test"); + if (!source || !sink || !pipeline) { + gst_clear_object(&source); + gst_clear_object(&sink); + gst_clear_object(&pipeline); + QFAIL("Could not create HTTP MJPEG delivery pipeline"); + } + const auto pipelineCleanup = qScopeGuard([&] { + (void) gst_element_set_state(pipeline, GST_STATE_NULL); + gst_object_unref(pipeline); + }); + + g_object_set(sink, "sync", FALSE, "max-buffers", 1u, "drop", TRUE, nullptr); + gst_bin_add_many(GST_BIN(pipeline), source, sink, nullptr); + QVERIFY2(gst_element_link(source, sink), "Could not link HTTP MJPEG source to appsink"); + QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, + "HTTP MJPEG delivery pipeline failed to start"); + + GstSample* sample = nullptr; + QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::mediumMs()); + const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); + GstBuffer* buffer = gst_sample_get_buffer(sample); + QVERIFY(buffer); + QVERIFY(gst_buffer_get_size(buffer) > 0); + GstCaps* caps = gst_sample_get_caps(sample); + QVERIFY(caps); + QCOMPARE(QString::fromUtf8(gst_structure_get_name(gst_caps_get_structure(caps, 0))), QStringLiteral("image/jpeg")); +} + +void GStreamerTest::_testSourceFactoryRejectsUnsafeHttpMjpegUrl() +{ + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("Invalid HTTP MJPEG URL"))); + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, + QRegularExpression(QStringLiteral("HTTP MJPEG credentials in URLs are not supported"))); + + GStreamer::SourceFactory::Config config; + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("http:///camera.mjpg"), config)); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("http://video.example.test:0/camera.mjpg"), config)); + QVERIFY(!GStreamer::SourceFactory::create(QStringLiteral("https://operator:secret@video.example.test/camera.mjpg"), + config)); +} + void GStreamerTest::_testSourceFactoryRejectsBadUri() { ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtCriticalMsg, From 7b750abd1e9c3cfaa66dd3b4af00bede723f797f Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 08:12:58 +0000 Subject: [PATCH 11/19] fix(VideoManager): align HTTP video TLS and tests --- .../GStreamer/GstSourceFactory.cc | 7 +++++-- .../GStreamerSourceFactoryTest.cc | 20 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 921661bbe65a..bb3f938bb8ab 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -467,10 +467,13 @@ GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) const QByteArray location = cleanUrl.toEncoded(QUrl::FullyEncoded); const QByteArray userAgent = QGCNetworkHelper::defaultUserAgent().toUtf8(); const guint timeoutS = std::clamp(config.timeoutS, 1u, 3600u); + // ssl-strict keeps certificate validation enabled. The active GLib TLS + // backend supplies the platform trust database; the legacy + // ssl-use-system-ca-file property is a no-op with libsoup3. g_object_set(source, "location", location.constData(), "method", "GET", "is-live", TRUE, "do-timestamp", TRUE, "keep-alive", TRUE, "compress", FALSE, "iradio-mode", FALSE, "automatic-redirect", FALSE, - "retries", 0, "timeout", timeoutS, "ssl-strict", TRUE, "ssl-use-system-ca-file", TRUE, - "http-log-level", 0, "user-agent", userAgent.constData(), nullptr); + "retries", 0, "timeout", timeoutS, "ssl-strict", TRUE, "http-log-level", 0, "user-agent", + userAgent.constData(), nullptr); g_object_set(demux, "single-stream", TRUE, nullptr); if (!gst_bin_add(GST_BIN(bin), source)) { diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 75294c575ba4..228b86a60edb 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -175,7 +175,6 @@ void GStreamerTest::_testSourceFactoryHttpMjpeg() gboolean iradioMode = TRUE; gboolean automaticRedirect = TRUE; gboolean sslStrict = FALSE; - gboolean sslUseSystemCaFile = FALSE; gboolean singleStream = FALSE; gint retries = -1; guint timeout = 0; @@ -184,8 +183,7 @@ void GStreamerTest::_testSourceFactoryHttpMjpeg() g_object_get(source, "location", &location, "method", &method, "is-live", &isLive, "do-timestamp", &doTimestamp, "keep-alive", &keepAlive, "compress", &compress, "iradio-mode", &iradioMode, "automatic-redirect", &automaticRedirect, "retries", &retries, "timeout", &timeout, "ssl-strict", &sslStrict, - "ssl-use-system-ca-file", &sslUseSystemCaFile, "http-log-level", &httpLogLevel, "user-agent", - &userAgent, nullptr); + "http-log-level", &httpLogLevel, "user-agent", &userAgent, nullptr); const auto stringsCleanup = qScopeGuard([&] { g_free(location); g_free(method); @@ -204,7 +202,6 @@ void GStreamerTest::_testSourceFactoryHttpMjpeg() QCOMPARE(retries, 0); QCOMPARE(timeout, 9u); QCOMPARE(sslStrict, TRUE); - QCOMPARE(sslUseSystemCaFile, TRUE); QCOMPARE(httpLogLevel, 0); QCOMPARE(QString::fromUtf8(userAgent), QGCNetworkHelper::defaultUserAgent()); QCOMPARE(singleStream, TRUE); @@ -264,7 +261,6 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() TestFixtures::LocalHttpTestServer server; QVERIFY2(server.listen(), "Could not start local MJPEG test server"); - server.installRawResponder(response); GStreamer::SourceFactory::Config config; config.timeoutS = 5; @@ -288,6 +284,20 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, "HTTP MJPEG delivery pipeline failed to start"); + QTcpSocket* client = server.waitForConnection(TestTimeout::mediumMs()); + QVERIFY2(client, "HTTP MJPEG source did not connect to the local test server"); + const auto clientCleanup = qScopeGuard([&] { + client->disconnectFromHost(); + client->deleteLater(); + }); + if (client->bytesAvailable() == 0) { + QVERIFY2(client->waitForReadyRead(TestTimeout::mediumMs()), "HTTP MJPEG source did not send a request"); + } + const QByteArray request = client->readAll(); + QVERIFY2(request.startsWith("GET /video_feed HTTP/1.1\r\n"), "HTTP MJPEG source sent an unexpected request"); + QCOMPARE(client->write(response), response.size()); + QVERIFY2(client->waitForBytesWritten(TestTimeout::mediumMs()), "Could not deliver the local MJPEG response"); + GstSample* sample = nullptr; QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::mediumMs()); const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); From 9f587bfe261f15ffc56044f45c28f08b5476ee99 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 09:38:47 +0000 Subject: [PATCH 12/19] test(VideoManager): stabilize MJPEG delivery coverage --- .../SourceFactory/GStreamerSourceFactoryTest.cc | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index 228b86a60edb..e641723fb993 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -261,6 +261,7 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() TestFixtures::LocalHttpTestServer server; QVERIFY2(server.listen(), "Could not start local MJPEG test server"); + server.installRawResponder(response); GStreamer::SourceFactory::Config config; config.timeoutS = 5; @@ -284,22 +285,8 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, "HTTP MJPEG delivery pipeline failed to start"); - QTcpSocket* client = server.waitForConnection(TestTimeout::mediumMs()); - QVERIFY2(client, "HTTP MJPEG source did not connect to the local test server"); - const auto clientCleanup = qScopeGuard([&] { - client->disconnectFromHost(); - client->deleteLater(); - }); - if (client->bytesAvailable() == 0) { - QVERIFY2(client->waitForReadyRead(TestTimeout::mediumMs()), "HTTP MJPEG source did not send a request"); - } - const QByteArray request = client->readAll(); - QVERIFY2(request.startsWith("GET /video_feed HTTP/1.1\r\n"), "HTTP MJPEG source sent an unexpected request"); - QCOMPARE(client->write(response), response.size()); - QVERIFY2(client->waitForBytesWritten(TestTimeout::mediumMs()), "Could not deliver the local MJPEG response"); - GstSample* sample = nullptr; - QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::mediumMs()); + QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::longMs()); const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); GstBuffer* buffer = gst_sample_get_buffer(sample); QVERIFY(buffer); From 65c4627b8663f2973533913fb7366ca1dc65ad12 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 10:53:21 +0000 Subject: [PATCH 13/19] fix(VideoManager): support older GLib connect flags --- src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index bb3f938bb8ab..8917488131e6 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -502,7 +502,7 @@ GstElement* buildHttpMjpegSource(const QUrl& sourceUrl, const Config& config) break; } (void) g_signal_connect_object(binDemux, "pad-added", G_CALLBACK(linkMultipartJpegPad), binParser, - G_CONNECT_DEFAULT); + static_cast(0)); if (!addStaticGhostPad(binParser)) { break; } From 2a94373ed51b1c8830ba415798b1b03ea967551d Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 11:28:02 +0000 Subject: [PATCH 14/19] test(VideoManager): keep MJPEG fixture responsive --- .../SourceFactory/GStreamerSourceFactoryTest.cc | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index e641723fb993..a17355eaf41a 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -282,11 +283,18 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() g_object_set(sink, "sync", FALSE, "max-buffers", 1u, "drop", TRUE, nullptr); gst_bin_add_many(GST_BIN(pipeline), source, sink, nullptr); QVERIFY2(gst_element_link(source, sink), "Could not link HTTP MJPEG source to appsink"); - QVERIFY2(gst_element_set_state(pipeline, GST_STATE_PLAYING) != GST_STATE_CHANGE_FAILURE, + + // souphttpsrc can wait for the HTTP response while changing state. Run that + // transition off-thread so this test's Qt event loop can serve the request. + const QFuture stateFuture = + QtConcurrent::run([pipeline]() { return gst_element_set_state(pipeline, GST_STATE_PLAYING); }); + QTRY_VERIFY_WITH_TIMEOUT(stateFuture.isFinished(), TestTimeout::mediumMs()); + QVERIFY2(stateFuture.result() != GST_STATE_CHANGE_FAILURE, "HTTP MJPEG delivery pipeline failed to start"); GstSample* sample = nullptr; - QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, TestTimeout::longMs()); + QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, + TestTimeout::mediumMs()); const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); GstBuffer* buffer = gst_sample_get_buffer(sample); QVERIFY(buffer); From 34bcefbdfb5552ed0e304df484cf0062e632fc1e Mon Sep 17 00:00:00 2001 From: alireza787b Date: Tue, 28 Jul 2026 15:45:03 +0000 Subject: [PATCH 15/19] test(VideoManager): serve MJPEG delivery synchronously --- .../GStreamerSourceFactoryTest.cc | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index a17355eaf41a..d9c6960a5573 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -2,10 +2,11 @@ #ifdef QGC_GST_STREAMING +#include #include +#include #include #include -#include #include #include #include @@ -16,9 +17,9 @@ namespace { -GstSample* tryPullSampleOrPreroll(GstAppSink* sink) +GstSample* tryPullSampleOrPreroll(GstAppSink* sink, GstClockTime timeout = 0) { - if (GstSample* sample = gst_app_sink_try_pull_sample(sink, 0)) { + if (GstSample* sample = gst_app_sink_try_pull_sample(sink, timeout)) { return sample; } return gst_app_sink_try_pull_preroll(sink, 0); @@ -248,21 +249,18 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() "Content-Type: image/jpeg\r\n" "Content-Length: " + QByteArray::number(jpeg.size()) + "\r\n\r\n" + jpeg + "\r\n"; - // A multipart video stream contains repeated images. Supplying two also - // verifies delivery after multipartdemux exposes and links its dynamic pad. - const QByteArray body = framePart + framePart + "--" + boundary + "--\r\n"; + // Keep the multipart response open like a camera stream so delivery does + // not depend on the timing of a finite response reaching EOS. const QByteArray response = "HTTP/1.1 200 OK\r\n" "Content-Type: multipart/x-mixed-replace; boundary=" + boundary + "\r\n" - "Connection: close\r\n" - "Content-Length: " + - QByteArray::number(body.size()) + "\r\n\r\n" + body; + "Connection: keep-alive\r\n\r\n" + + framePart + framePart; TestFixtures::LocalHttpTestServer server; QVERIFY2(server.listen(), "Could not start local MJPEG test server"); - server.installRawResponder(response); GStreamer::SourceFactory::Config config; config.timeoutS = 5; @@ -285,16 +283,40 @@ void GStreamerTest::_testSourceFactoryHttpMjpegDelivery() QVERIFY2(gst_element_link(source, sink), "Could not link HTTP MJPEG source to appsink"); // souphttpsrc can wait for the HTTP response while changing state. Run that - // transition off-thread so this test's Qt event loop can serve the request. + // transition off-thread so this thread can accept and serve the request. const QFuture stateFuture = QtConcurrent::run([pipeline]() { return gst_element_set_state(pipeline, GST_STATE_PLAYING); }); + + QTcpSocket* client = server.waitForConnection(TestTimeout::mediumMs()); + QVERIFY2(client, "HTTP MJPEG source did not connect to the local test server"); + const auto clientCleanup = qScopeGuard([&] { + client->disconnectFromHost(); + client->deleteLater(); + }); + + QByteArray request; + QDeadlineTimer requestDeadline(TestTimeout::mediumDuration()); + while (!request.contains(QByteArrayLiteral("\r\n\r\n")) && !requestDeadline.hasExpired()) { + request.append(client->readAll()); + if (!request.contains(QByteArrayLiteral("\r\n\r\n"))) { + (void) client->waitForReadyRead(static_cast(requestDeadline.remainingTime())); + } + } + QVERIFY2(request.startsWith(QByteArrayLiteral("GET /video_feed HTTP/1.1\r\n")), + "HTTP MJPEG source sent an unexpected request"); + + QCOMPARE(client->write(response), static_cast(response.size())); + QDeadlineTimer responseDeadline(TestTimeout::mediumDuration()); + while ((client->bytesToWrite() > 0) && !responseDeadline.hasExpired()) { + (void) client->waitForBytesWritten(static_cast(responseDeadline.remainingTime())); + } + QCOMPARE(client->bytesToWrite(), 0); + QTRY_VERIFY_WITH_TIMEOUT(stateFuture.isFinished(), TestTimeout::mediumMs()); - QVERIFY2(stateFuture.result() != GST_STATE_CHANGE_FAILURE, - "HTTP MJPEG delivery pipeline failed to start"); + QVERIFY2(stateFuture.result() != GST_STATE_CHANGE_FAILURE, "HTTP MJPEG delivery pipeline failed to start"); - GstSample* sample = nullptr; - QTRY_VERIFY_WITH_TIMEOUT((sample = tryPullSampleOrPreroll(GST_APP_SINK(sink))) != nullptr, - TestTimeout::mediumMs()); + GstSample* sample = tryPullSampleOrPreroll(GST_APP_SINK(sink), TestTimeout::mediumDuration().count() * GST_MSECOND); + QVERIFY2(sample, "HTTP MJPEG stream did not deliver a JPEG sample before timeout"); const auto sampleCleanup = qScopeGuard([&] { gst_sample_unref(sample); }); GstBuffer* buffer = gst_sample_get_buffer(sample); QVERIFY(buffer); From 2cc2389f3ea25fc3b23baa9ee4258ec71a053fbf Mon Sep 17 00:00:00 2001 From: alireza787b Date: Fri, 31 Jul 2026 01:23:25 +0000 Subject: [PATCH 16/19] style(VideoManager): format source factory changes --- src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc index 8917488131e6..e0a9770c6432 100644 --- a/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc +++ b/src/VideoManager/VideoReceiver/GStreamer/GstSourceFactory.cc @@ -627,7 +627,7 @@ GstElement* create(const QString& uri, const Config& config) if (!isRtsp && !isUdpH264 && !isUdpH265 && !isUdpMPEGTS && !isTcpMPEGTS && !isHttpMjpeg) { qCWarning(GstSourceFactoryLog) << "Unsupported URI scheme:" << scheme << "in" - << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + << QGCNetworkHelper::redactedUrlForLogging(sourceUrl); return nullptr; } From 3a103ddda3369bc4d58ffef4a4ce61e475064ee1 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Fri, 31 Jul 2026 01:38:46 +0000 Subject: [PATCH 17/19] test(Utilities): run redaction coverage in CI --- test/Utilities/Network/CMakeLists.txt | 3 + .../Utilities/Network/QGCNetworkHelperTest.cc | 55 ---------------- test/Utilities/Network/QGCNetworkHelperTest.h | 5 -- .../Network/QGCNetworkRedactionTest.cc | 63 +++++++++++++++++++ .../Network/QGCNetworkRedactionTest.h | 15 +++++ 5 files changed, 81 insertions(+), 60 deletions(-) create mode 100644 test/Utilities/Network/QGCNetworkRedactionTest.cc create mode 100644 test/Utilities/Network/QGCNetworkRedactionTest.h diff --git a/test/Utilities/Network/CMakeLists.txt b/test/Utilities/Network/CMakeLists.txt index af4c50eba070..ce0c1a5d072e 100644 --- a/test/Utilities/Network/CMakeLists.txt +++ b/test/Utilities/Network/CMakeLists.txt @@ -7,8 +7,11 @@ target_sources(${CMAKE_PROJECT_NAME} PRIVATE QGCNetworkHelperTest.cc QGCNetworkHelperTest.h + QGCNetworkRedactionTest.cc + QGCNetworkRedactionTest.h ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_qgc_test(QGCNetworkHelperTest LABELS Unit Utilities Network) +add_qgc_test(QGCNetworkRedactionTest LABELS Unit Utilities) diff --git a/test/Utilities/Network/QGCNetworkHelperTest.cc b/test/Utilities/Network/QGCNetworkHelperTest.cc index 78e830e54170..e74d934a39ec 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.cc +++ b/test/Utilities/Network/QGCNetworkHelperTest.cc @@ -344,61 +344,6 @@ void QGCNetworkHelperTest::_testUrlWithoutQuery() QVERIFY(result.fragment().isEmpty()); } -void QGCNetworkHelperTest::_testRedactedUrlPreservesStreamIdentity() -{ - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("rtsp://camera.example:554/axis-media/media.amp")), - QStringLiteral("rtsp://camera.example:554/axis-media/media.amp")); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("udp://0.0.0.0:5600")), - QStringLiteral("udp://0.0.0.0:5600")); -} - -void QGCNetworkHelperTest::_testRedactedUrlRemovesUserInfo() -{ - const QString result = QGCNetworkHelper::redactedUrlForLogging( - QStringLiteral("https://pilot%40ops:secret%2Fvalue@example.com:8443/video%20feed")); - - QCOMPARE(result, QStringLiteral("https://example.com:8443/video%20feed")); - QVERIFY(!result.contains(QStringLiteral("pilot"))); - QVERIFY(!result.contains(QStringLiteral("secret"))); -} - -void QGCNetworkHelperTest::_testRedactedUrlRedactsQueryValues() -{ - const QString result = QGCNetworkHelper::redactedUrlForLogging( - QStringLiteral("https://example.com/video?token=abc123&mode=low-latency#session")); - const QUrl resultUrl(result); - const QUrlQuery resultQuery(resultUrl); - - QCOMPARE(resultUrl.path(), QStringLiteral("/video")); - QCOMPARE(resultQuery.queryItemValue(QStringLiteral("token")), QStringLiteral("REDACTED")); - QCOMPARE(resultQuery.queryItemValue(QStringLiteral("mode")), QStringLiteral("REDACTED")); - QCOMPARE(resultUrl.fragment(), QStringLiteral("REDACTED")); - QVERIFY(!result.contains(QStringLiteral("abc123"))); - QVERIFY(!result.contains(QStringLiteral("low-latency"))); - QVERIFY(!result.contains(QStringLiteral("session"))); -} - -void QGCNetworkHelperTest::_testRedactedUrlHandlesRelativeAndInvalidInput() -{ - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("5600")), QStringLiteral("5600")); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("camera.local:5600")), - QStringLiteral("camera.local:5600")); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QString()), QStringLiteral("")); - QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("http://[invalid")), - QStringLiteral("")); -} - -void QGCNetworkHelperTest::_testRedactedUrlQUrlOverload() -{ - const QUrl sourceUrl(QStringLiteral("rtsp://pilot:secret@camera.example:8554/live?token=abc123")); - const QString result = QGCNetworkHelper::redactedUrlForLogging(sourceUrl); - - QCOMPARE(QUrl(result).path(), QStringLiteral("/live")); - QVERIFY(!result.contains(QStringLiteral("pilot"))); - QVERIFY(!result.contains(QStringLiteral("secret"))); - QVERIFY(!result.contains(QStringLiteral("abc123"))); -} - // ============================================================================ // Request Configuration Tests // ============================================================================ diff --git a/test/Utilities/Network/QGCNetworkHelperTest.h b/test/Utilities/Network/QGCNetworkHelperTest.h index bef94e99522f..4b14265c6178 100644 --- a/test/Utilities/Network/QGCNetworkHelperTest.h +++ b/test/Utilities/Network/QGCNetworkHelperTest.h @@ -38,11 +38,6 @@ private slots: void _testBuildUrlFromMap(); void _testBuildUrlFromList(); void _testUrlWithoutQuery(); - void _testRedactedUrlPreservesStreamIdentity(); - void _testRedactedUrlRemovesUserInfo(); - void _testRedactedUrlRedactsQueryValues(); - void _testRedactedUrlHandlesRelativeAndInvalidInput(); - void _testRedactedUrlQUrlOverload(); // Request configuration tests void _testDefaultUserAgent(); diff --git a/test/Utilities/Network/QGCNetworkRedactionTest.cc b/test/Utilities/Network/QGCNetworkRedactionTest.cc new file mode 100644 index 000000000000..21ad614d3419 --- /dev/null +++ b/test/Utilities/Network/QGCNetworkRedactionTest.cc @@ -0,0 +1,63 @@ +#include "QGCNetworkRedactionTest.h" + +#include "QGCNetworkHelper.h" + +#include +#include + +void QGCNetworkRedactionTest::_testPreservesStreamIdentity() +{ + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("rtsp://camera.example:554/axis-media/media.amp")), + QStringLiteral("rtsp://camera.example:554/axis-media/media.amp")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("udp://0.0.0.0:5600")), + QStringLiteral("udp://0.0.0.0:5600")); +} + +void QGCNetworkRedactionTest::_testRemovesUserInfo() +{ + const QString result = QGCNetworkHelper::redactedUrlForLogging( + QStringLiteral("https://pilot%40ops:secret%2Fvalue@example.com:8443/video%20feed")); + + QCOMPARE(result, QStringLiteral("https://example.com:8443/video%20feed")); + QVERIFY(!result.contains(QStringLiteral("pilot"))); + QVERIFY(!result.contains(QStringLiteral("secret"))); +} + +void QGCNetworkRedactionTest::_testRedactsQueryValues() +{ + const QString result = QGCNetworkHelper::redactedUrlForLogging( + QStringLiteral("https://example.com/video?token=abc123&mode=low-latency#session")); + const QUrl resultUrl(result); + const QUrlQuery resultQuery(resultUrl); + + QCOMPARE(resultUrl.path(), QStringLiteral("/video")); + QCOMPARE(resultQuery.queryItemValue(QStringLiteral("token")), QStringLiteral("REDACTED")); + QCOMPARE(resultQuery.queryItemValue(QStringLiteral("mode")), QStringLiteral("REDACTED")); + QCOMPARE(resultUrl.fragment(), QStringLiteral("REDACTED")); + QVERIFY(!result.contains(QStringLiteral("abc123"))); + QVERIFY(!result.contains(QStringLiteral("low-latency"))); + QVERIFY(!result.contains(QStringLiteral("session"))); +} + +void QGCNetworkRedactionTest::_testHandlesRelativeAndInvalidInput() +{ + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("5600")), QStringLiteral("5600")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("camera.local:5600")), + QStringLiteral("camera.local:5600")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QString()), QStringLiteral("")); + QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("http://[invalid")), + QStringLiteral("")); +} + +void QGCNetworkRedactionTest::_testQUrlOverload() +{ + const QUrl sourceUrl(QStringLiteral("rtsp://pilot:secret@camera.example:8554/live?token=abc123")); + const QString result = QGCNetworkHelper::redactedUrlForLogging(sourceUrl); + + QCOMPARE(QUrl(result).path(), QStringLiteral("/live")); + QVERIFY(!result.contains(QStringLiteral("pilot"))); + QVERIFY(!result.contains(QStringLiteral("secret"))); + QVERIFY(!result.contains(QStringLiteral("abc123"))); +} + +UT_REGISTER_TEST(QGCNetworkRedactionTest, TestLabel::Unit, TestLabel::Utilities) diff --git a/test/Utilities/Network/QGCNetworkRedactionTest.h b/test/Utilities/Network/QGCNetworkRedactionTest.h new file mode 100644 index 000000000000..bc33846a4af8 --- /dev/null +++ b/test/Utilities/Network/QGCNetworkRedactionTest.h @@ -0,0 +1,15 @@ +#pragma once + +#include "UnitTest.h" + +class QGCNetworkRedactionTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _testPreservesStreamIdentity(); + void _testRemovesUserInfo(); + void _testRedactsQueryValues(); + void _testHandlesRelativeAndInvalidInput(); + void _testQUrlOverload(); +}; From 9739a6b52d23043109306f29cf1ccd4452698d02 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Fri, 31 Jul 2026 01:41:02 +0000 Subject: [PATCH 18/19] style(Utilities): follow configured include order --- test/Utilities/Network/QGCNetworkRedactionTest.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Utilities/Network/QGCNetworkRedactionTest.cc b/test/Utilities/Network/QGCNetworkRedactionTest.cc index 21ad614d3419..a5e532935dd6 100644 --- a/test/Utilities/Network/QGCNetworkRedactionTest.cc +++ b/test/Utilities/Network/QGCNetworkRedactionTest.cc @@ -1,10 +1,10 @@ #include "QGCNetworkRedactionTest.h" -#include "QGCNetworkHelper.h" - #include #include +#include "QGCNetworkHelper.h" + void QGCNetworkRedactionTest::_testPreservesStreamIdentity() { QCOMPARE(QGCNetworkHelper::redactedUrlForLogging(QStringLiteral("rtsp://camera.example:554/axis-media/media.amp")), From af1201e83ffce288ba2a4ea3bd900ce818d41725 Mon Sep 17 00:00:00 2001 From: alireza787b Date: Fri, 31 Jul 2026 02:10:34 +0000 Subject: [PATCH 19/19] test(VideoManager): expect invalid URI warnings --- .../GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc index d9c6960a5573..0f8ce9c1d84a 100644 --- a/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc +++ b/test/VideoManager/GStreamer/SourceFactory/GStreamerSourceFactoryTest.cc @@ -343,9 +343,9 @@ void GStreamerTest::_testSourceFactoryRejectsUnsafeHttpMjpegUrl() void GStreamerTest::_testSourceFactoryRejectsBadUri() { ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtCriticalMsg, - QRegularExpression(QStringLiteral("URI is not specified|Invalid UDP port"))); + QRegularExpression(QStringLiteral("URI is not specified"))); ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, - QRegularExpression(QStringLiteral("Unsupported URI scheme"))); + QRegularExpression(QStringLiteral("Unsupported URI scheme|Invalid UDP port"))); GStreamer::SourceFactory::Config config; QVERIFY(!GStreamer::SourceFactory::create(QString(), config)); @@ -380,7 +380,7 @@ void GStreamerTest::_testSourceFactoryTcpMpegTs() void GStreamerTest::_testSourceFactoryRejectsBadTcpUri() { - ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtCriticalMsg, + ignoreLogMessage("Video.GStreamer.GstSourceFactory", QtWarningMsg, QRegularExpression(QStringLiteral("Invalid TCP port|Missing host in TCP URI"))); GStreamer::SourceFactory::Config config;