From 9b964c4686dd4fb1d34a4885ba492b5a7e1eef24 Mon Sep 17 00:00:00 2001 From: Matej Bagar Date: Mon, 23 Mar 2026 13:06:00 +0100 Subject: [PATCH 01/12] Add qgis patch to support qgis 4 projects (#4379) The new patch includes changes done in #62446 & #65114. They were slightly modified for QGIS 3.40. --- vcpkg/ports/qgis/portfile.cmake | 2 + .../ports/qgis/qgis4-project-properties.patch | 274 ++++++ vcpkg/ports/qgis/qgis4_url_encoding.patch | 847 ++++++++++++++++++ 3 files changed, 1123 insertions(+) create mode 100644 vcpkg/ports/qgis/qgis4-project-properties.patch create mode 100644 vcpkg/ports/qgis/qgis4_url_encoding.patch diff --git a/vcpkg/ports/qgis/portfile.cmake b/vcpkg/ports/qgis/portfile.cmake index 2a174883f..b335a3e56 100644 --- a/vcpkg/ports/qgis/portfile.cmake +++ b/vcpkg/ports/qgis/portfile.cmake @@ -16,6 +16,8 @@ vcpkg_from_github( cmakelists.patch crssync.patch libxml2.patch + qgis4-project-properties.patch + qgis4_url_encoding.patch ) file(REMOVE ${SOURCE_PATH}/cmake/FindQtKeychain.cmake) diff --git a/vcpkg/ports/qgis/qgis4-project-properties.patch b/vcpkg/ports/qgis/qgis4-project-properties.patch new file mode 100644 index 000000000..814fcf5b5 --- /dev/null +++ b/vcpkg/ports/qgis/qgis4-project-properties.patch @@ -0,0 +1,274 @@ +commit 74549aad26c3358101e88477d9dfa1caae013d72 +Author: Jürgen E. Fischer +Date: Fri Jun 20 15:58:30 2025 +0200 + + Reapply "Allow free naming of project properties (#60855)" + + This reverts commit fb11239112adfc321b3bbacbb20da888a7a37c23. + +diff --git a/src/core/project/qgsproject.cpp b/src/core/project/qgsproject.cpp +index f78f9e53bef..cd6f78edaaf 100644 +--- a/src/core/project/qgsproject.cpp ++++ b/src/core/project/qgsproject.cpp +@@ -116,21 +116,6 @@ QStringList makeKeyTokens_( const QString &scope, const QString &key ) + // be sure to include the canonical root node + keyTokens.push_front( QStringLiteral( "properties" ) ); + +- //check validy of keys since an invalid xml name will will be dropped upon saving the xml file. If not valid, we print a message to the console. +- for ( int i = 0; i < keyTokens.size(); ++i ) +- { +- const QString keyToken = keyTokens.at( i ); +- +- //invalid chars in XML are found at http://www.w3.org/TR/REC-xml/#NT-NameChar +- //note : it seems \x10000-\xEFFFF is valid, but it when added to the regexp, a lot of unwanted characters remain +- const thread_local QRegularExpression sInvalidRegexp = QRegularExpression( QStringLiteral( "([^:A-Z_a-z\\x{C0}-\\x{D6}\\x{D8}-\\x{F6}\\x{F8}-\\x{2FF}\\x{370}-\\x{37D}\\x{37F}-\\x{1FFF}\\x{200C}-\\x{200D}\\x{2070}-\\x{218F}\\x{2C00}-\\x{2FEF}\\x{3001}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFFD}\\-\\.0-9\\x{B7}\\x{0300}-\\x{036F}\\x{203F}-\\x{2040}]|^[^:A-Z_a-z\\x{C0}-\\x{D6}\\x{D8}-\\x{F6}\\x{F8}-\\x{2FF}\\x{370}-\\x{37D}\\x{37F}-\\x{1FFF}\\x{200C}-\\x{200D}\\x{2070}-\\x{218F}\\x{2C00}-\\x{2FEF}\\x{3001}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFFD}])" ) ); +- if ( keyToken.contains( sInvalidRegexp ) ) +- { +- const QString errorString = QObject::tr( "Entry token invalid : '%1'. The token will not be saved to file." ).arg( keyToken ); +- QgsMessageLog::logMessage( errorString, QString(), Qgis::MessageLevel::Critical ); +- } +- } +- + return keyTokens; + } + +@@ -1322,20 +1307,20 @@ void dump_( const QgsProjectPropertyKey &topQgsPropertyKey ) + * scope. "layers" is a list containing three string values. + * + * \code{.xml} +- * +- * +- * 42 +- * 1 +- * ++ * ++ * ++ * 42 ++ * 1 ++ * + * railroad + * airport +- * +- * 1 +- * 123.456 +- * ++ * ++ * 1 ++ * 123.456 ++ * + * type +- * +- * ++ * ++ * + * + * \endcode + * +@@ -3992,10 +3977,25 @@ bool QgsProject::createEmbeddedLayer( const QString &layerId, const QString &pro + const QDomElement propertiesElem = sProjectDocument.documentElement().firstChildElement( QStringLiteral( "properties" ) ); + if ( !propertiesElem.isNull() ) + { +- const QDomElement absElem = propertiesElem.firstChildElement( QStringLiteral( "Paths" ) ).firstChildElement( QStringLiteral( "Absolute" ) ); +- if ( !absElem.isNull() ) ++ QDomElement e = propertiesElem.firstChildElement( QStringLiteral( "Paths" ) ); ++ if ( e.isNull() ) ++ { ++ e = propertiesElem.firstChildElement( QStringLiteral( "properties" ) ); ++ while ( !e.isNull() && e.attribute( QStringLiteral( "name" ) ) != QStringLiteral( "Paths" ) ) ++ e = e.nextSiblingElement( QStringLiteral( "properties" ) ); ++ ++ e = e.firstChildElement( QStringLiteral( "properties" ) ); ++ while ( !e.isNull() && e.attribute( QStringLiteral( "name" ) ) != QStringLiteral( "Absolute" ) ) ++ e = e.nextSiblingElement( QStringLiteral( "properties" ) ); ++ } ++ else ++ { ++ e = e.firstChildElement( QStringLiteral( "Absolute" ) ); ++ } ++ ++ if ( !e.isNull() ) + { +- useAbsolutePaths = absElem.text().compare( QLatin1String( "true" ), Qt::CaseInsensitive ) == 0; ++ useAbsolutePaths = e.text().compare( QLatin1String( "true" ), Qt::CaseInsensitive ) == 0; + } + } + +diff --git a/src/core/project/qgsprojectproperty.cpp b/src/core/project/qgsprojectproperty.cpp +index ff8024a5260..1af598012b4 100644 +--- a/src/core/project/qgsprojectproperty.cpp ++++ b/src/core/project/qgsprojectproperty.cpp +@@ -233,15 +233,15 @@ bool QgsProjectPropertyValue::readXml( const QDomNode &keyNode ) + + // keyElement is created by parent QgsProjectPropertyKey + bool QgsProjectPropertyValue::writeXml( QString const &nodeName, +- QDomElement &keyElement, +- QDomDocument &document ) ++ QDomElement &keyElement, ++ QDomDocument &document ) + { +- QDomElement valueElement = document.createElement( nodeName ); ++ QDomElement valueElement = document.createElement( QStringLiteral( "properties" ) ); + + // remember the type so that we can rebuild it when the project is read in ++ valueElement.setAttribute( QStringLiteral( "name" ), nodeName ); + valueElement.setAttribute( QStringLiteral( "type" ), mValue.typeName() ); + +- + // we handle string lists differently from other types in that we + // create a sequence of repeated elements to cover all the string list + // members; each value will be in a tag. +@@ -362,33 +362,41 @@ bool QgsProjectPropertyKey::readXml( const QDomNode &keyNode ) + + while ( i < subkeys.count() ) + { ++ const QDomNode subkey = subkeys.item( i ); ++ QString name; ++ ++ if ( subkey.nodeName() == QStringLiteral( "properties" ) && ++ subkey.hasAttributes() && // if we have attributes ++ subkey.isElement() && // and we're an element ++ subkey.toElement().hasAttribute( QStringLiteral( "name" ) ) ) // and we have a "name" attribute ++ name = subkey.toElement().attribute( QStringLiteral( "name" ) ); ++ else ++ name = subkey.nodeName(); ++ + // if the current node is an element that has a "type" attribute, + // then we know it's a leaf node; i.e., a subkey _value_, and not + // a subkey +- if ( subkeys.item( i ).hasAttributes() && // if we have attributes +- subkeys.item( i ).isElement() && // and we're an element +- subkeys.item( i ).toElement().hasAttribute( QStringLiteral( "type" ) ) ) // and we have a "type" attribute ++ if ( subkey.hasAttributes() && // if we have attributes ++ subkey.isElement() && // and we're an element ++ subkey.toElement().hasAttribute( QStringLiteral( "type" ) ) ) // and we have a "type" attribute + { + // then we're a key value +- delete mProperties.take( subkeys.item( i ).nodeName() ); +- mProperties.insert( subkeys.item( i ).nodeName(), new QgsProjectPropertyValue ); ++ // ++ delete mProperties.take( name ); ++ mProperties.insert( name, new QgsProjectPropertyValue ); + +- QDomNode subkey = subkeys.item( i ); +- +- if ( !mProperties[subkeys.item( i ).nodeName()]->readXml( subkey ) ) ++ if ( !mProperties[name]->readXml( subkey ) ) + { +- QgsDebugError( QStringLiteral( "unable to parse key value %1" ).arg( subkeys.item( i ).nodeName() ) ); ++ QgsDebugError( QStringLiteral( "unable to parse key value %1" ).arg( name ) ); + } + } + else // otherwise it's a subkey, so just recurse on down the remaining keys + { +- addKey( subkeys.item( i ).nodeName() ); +- +- QDomNode subkey = subkeys.item( i ); ++ addKey( name ); + +- if ( !mProperties[subkeys.item( i ).nodeName()]->readXml( subkey ) ) ++ if ( !mProperties[name]->readXml( subkey ) ) + { +- QgsDebugError( QStringLiteral( "unable to parse subkey %1" ).arg( subkeys.item( i ).nodeName() ) ); ++ QgsDebugError( QStringLiteral( "unable to parse subkey %1" ).arg( name ) ); + } + } + +@@ -408,7 +416,8 @@ bool QgsProjectPropertyKey::writeXml( QString const &nodeName, QDomElement &elem + // If it's an _empty_ node (i.e., one with no properties) we need to emit + // an empty place holder; else create new Dom elements as necessary. + +- QDomElement keyElement = document.createElement( nodeName ); // Dom element for this property key ++ QDomElement keyElement = document.createElement( "properties" ); // Dom element for this property key ++ keyElement.toElement().setAttribute( QStringLiteral( "name" ), nodeName ); + + if ( ! mProperties.isEmpty() ) + { +diff --git a/tests/src/python/test_qgsproject.py b/tests/src/python/test_qgsproject.py +index 237553260f6..d44d4438006 100644 +--- a/tests/src/python/test_qgsproject.py ++++ b/tests/src/python/test_qgsproject.py +@@ -65,84 +65,6 @@ class TestQgsProject(QgisTestCase): + QgisTestCase.__init__(self, methodName) + self.messageCaught = False + +- def test_makeKeyTokens_(self): +- # see http://www.w3.org/TR/REC-xml/#d0e804 for a list of valid characters +- +- invalidTokens = [] +- validTokens = [] +- +- # all test tokens will be generated by prepending or inserting characters to this token +- validBase = "valid" +- +- # some invalid characters, not allowed anywhere in a token +- # note that '/' must not be added here because it is taken as a separator by makeKeyTokens_() +- invalidChars = "+*,;<>|!$%()=?#\x01" +- +- # generate the characters that are allowed at the start of a token (and at every other position) +- validStartChars = ":_" +- charRanges = [ +- (ord("a"), ord("z")), +- (ord("A"), ord("Z")), +- (0x00F8, 0x02FF), +- (0x0370, 0x037D), +- (0x037F, 0x1FFF), +- (0x200C, 0x200D), +- (0x2070, 0x218F), +- (0x2C00, 0x2FEF), +- (0x3001, 0xD7FF), +- (0xF900, 0xFDCF), +- (0xFDF0, 0xFFFD), +- # (0x10000, 0xEFFFF), while actually valid, these are not yet accepted by makeKeyTokens_() +- ] +- for r in charRanges: +- for c in range(r[0], r[1]): +- validStartChars += chr(c) +- +- # generate the characters that are only allowed inside a token, not at the start +- validInlineChars = "-.\xB7" +- charRanges = [ +- (ord("0"), ord("9")), +- (0x0300, 0x036F), +- (0x203F, 0x2040), +- ] +- for r in charRanges: +- for c in range(r[0], r[1]): +- validInlineChars += chr(c) +- +- # test forbidden start characters +- for c in invalidChars + validInlineChars: +- invalidTokens.append(c + validBase) +- +- # test forbidden inline characters +- for c in invalidChars: +- invalidTokens.append(validBase[:4] + c + validBase[4:]) +- +- # test each allowed start character +- for c in validStartChars: +- validTokens.append(c + validBase) +- +- # test each allowed inline character +- for c in validInlineChars: +- validTokens.append(validBase[:4] + c + validBase[4:]) +- +- logger = QgsApplication.messageLog() +- logger.messageReceived.connect(self.catchMessage) +- prj = QgsProject.instance() +- +- for token in validTokens: +- self.messageCaught = False +- prj.readEntry("test", token) +- myMessage = f"valid token '{token}' not accepted" +- assert not self.messageCaught, myMessage +- +- for token in invalidTokens: +- self.messageCaught = False +- prj.readEntry("test", token) +- myMessage = f"invalid token '{token}' accepted" +- assert self.messageCaught, myMessage +- +- logger.messageReceived.disconnect(self.catchMessage) +- + def catchMessage(self): + self.messageCaught = True + diff --git a/vcpkg/ports/qgis/qgis4_url_encoding.patch b/vcpkg/ports/qgis/qgis4_url_encoding.patch new file mode 100644 index 000000000..d67e50968 --- /dev/null +++ b/vcpkg/ports/qgis/qgis4_url_encoding.patch @@ -0,0 +1,847 @@ +diff --git a/src/core/network/qgshttpheaders.cpp b/src/core/network/qgshttpheaders.cpp +index 74322dc2fcb..c5cfbbabc66 100644 +--- a/src/core/network/qgshttpheaders.cpp ++++ b/src/core/network/qgshttpheaders.cpp +@@ -73,7 +73,7 @@ bool QgsHttpHeaders::updateUrlQuery( QUrlQuery &uri ) const + { + for ( auto ite = mHeaders.constBegin(); ite != mHeaders.constEnd(); ++ite ) + { +- uri.addQueryItem( QgsHttpHeaders::PARAM_PREFIX + ite.key().toUtf8(), ite.value().toString().toUtf8() ); ++ uri.addQueryItem( QgsHttpHeaders::PARAM_PREFIX + ite.key().toUtf8(), QUrl::toPercentEncoding( ite.value().toString() ) ); + } + return true; + } +diff --git a/src/core/project/qgsprojectstorageregistry.cpp b/src/core/project/qgsprojectstorageregistry.cpp +index a86c4d2bc60..f559bb21112 100644 +--- a/src/core/project/qgsprojectstorageregistry.cpp ++++ b/src/core/project/qgsprojectstorageregistry.cpp +@@ -33,8 +33,7 @@ QgsProjectStorage *QgsProjectStorageRegistry::projectStorageFromUri( const QStri + for ( auto it = mBackends.constBegin(); it != mBackends.constEnd(); ++it ) + { + QgsProjectStorage *storage = it.value(); +- const QString scheme = storage->type() + ':'; +- if ( uri.startsWith( scheme ) ) ++ if ( uri.startsWith( storage->type() + ':' ) || uri.startsWith( storage->type() + "%3A" ) ) + return storage; + } + +diff --git a/src/core/qgsdatasourceuri.cpp b/src/core/qgsdatasourceuri.cpp +index 6b26e9dd2ee..fe0dd2a12b5 100644 +--- a/src/core/qgsdatasourceuri.cpp ++++ b/src/core/qgsdatasourceuri.cpp +@@ -701,17 +701,17 @@ QByteArray QgsDataSourceUri::encodedUri() const + QUrlQuery url; + for ( auto it = mParams.constBegin(); it != mParams.constEnd(); ++it ) + { +- url.addQueryItem( it.key(), it.value() ); ++ url.addQueryItem( it.key(), QUrl::toPercentEncoding( it.value() ) ); + } + + if ( !mUsername.isEmpty() ) +- url.addQueryItem( QStringLiteral( "username" ), mUsername ); ++ url.addQueryItem( QStringLiteral( "username" ), QUrl::toPercentEncoding( mUsername ) ); + + if ( !mPassword.isEmpty() ) +- url.addQueryItem( QStringLiteral( "password" ), mPassword ); ++ url.addQueryItem( QStringLiteral( "password" ), QUrl::toPercentEncoding( mPassword ) ); + + if ( !mAuthConfigId.isEmpty() ) +- url.addQueryItem( QStringLiteral( "authcfg" ), mAuthConfigId ); ++ url.addQueryItem( QStringLiteral( "authcfg" ), QUrl::toPercentEncoding( mAuthConfigId ) ); + + mHttpHeaders.updateUrlQuery( url ); + +@@ -731,7 +731,7 @@ void QgsDataSourceUri::setEncodedUri( const QByteArray &uri ) + + mHttpHeaders.setFromUrlQuery( query ); + +- const auto constQueryItems = query.queryItems(); ++ const auto constQueryItems = query.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ); + for ( const QPair &item : constQueryItems ) + { + if ( !item.first.startsWith( QgsHttpHeaders::PARAM_PREFIX ) && item.first != QgsHttpHeaders::KEY_REFERER ) +diff --git a/src/core/qgsmaplayer.cpp b/src/core/qgsmaplayer.cpp +index 90b7fba14bb..c2e3cc126bb 100644 +--- a/src/core/qgsmaplayer.cpp ++++ b/src/core/qgsmaplayer.cpp +@@ -3262,8 +3262,9 @@ QString QgsMapLayer::generalHtmlMetadata() const + } + if ( uriComponents.contains( QStringLiteral( "url" ) ) ) + { +- const QString url = uriComponents[QStringLiteral( "url" )].toString(); +- metadata += QStringLiteral( "" ) + tr( "URL" ) + QStringLiteral( "%1" ).arg( QStringLiteral( "%2" ).arg( QUrl( url ).toString(), url ) ) + QStringLiteral( "\n" ); ++ QUrl decodedUri = QUrl::fromPercentEncoding( uriComponents[QStringLiteral( "url" )].toString().toLocal8Bit() ); ++ const QString url = decodedUri.toString(); ++ metadata += QStringLiteral( "" ) + tr( "URL" ) + QStringLiteral( "%1" ).arg( QStringLiteral( "%2" ).arg( url, url ) ) + QStringLiteral( "\n" ); + } + } + +diff --git a/src/core/vectortile/qgsvectortileprovidermetadata.cpp b/src/core/vectortile/qgsvectortileprovidermetadata.cpp +index f7a8b5f1fd9..a6484adde6a 100644 +--- a/src/core/vectortile/qgsvectortileprovidermetadata.cpp ++++ b/src/core/vectortile/qgsvectortileprovidermetadata.cpp +@@ -147,7 +147,7 @@ QString QgsVectorTileProviderMetadata::absoluteToRelativeUri( const QString &uri + // relative path will become "file:./x.txt" + const QString relSrcUrl = context.pathResolver().writePath( sourceUrl.toLocalFile() ); + dsUri.removeParam( QStringLiteral( "url" ) ); // needed because setParam() would insert second "url" key +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( relSrcUrl ).toString() ); ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( relSrcUrl ).toString( QUrl::DecodeReserved ) ); + return dsUri.encodedUri(); + } + } +@@ -176,7 +176,7 @@ QString QgsVectorTileProviderMetadata::relativeToAbsoluteUri( const QString &uri + { + const QString absSrcUrl = context.pathResolver().readPath( sourceUrl.toLocalFile() ); + dsUri.removeParam( QStringLiteral( "url" ) ); // needed because setParam() would insert second "url" key +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( absSrcUrl ).toString() ); ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( absSrcUrl ).toString( QUrl::DecodeReserved ) ); + return dsUri.encodedUri(); + } + } +diff --git a/src/core/vectortile/qgsxyzvectortiledataprovider.cpp b/src/core/vectortile/qgsxyzvectortiledataprovider.cpp +index be607514666..08c45dbe3c5 100644 +--- a/src/core/vectortile/qgsxyzvectortiledataprovider.cpp ++++ b/src/core/vectortile/qgsxyzvectortiledataprovider.cpp +@@ -316,7 +316,7 @@ QString QgsXyzVectorTileDataProviderMetadata::absoluteToRelativeUri( const QStri + // relative path will become "file:./x.txt" + const QString relSrcUrl = context.pathResolver().writePath( sourceUrl.toLocalFile() ); + dsUri.removeParam( QStringLiteral( "url" ) ); // needed because setParam() would insert second "url" key +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( relSrcUrl ).toString() ); ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( relSrcUrl ).toString( QUrl::DecodeReserved ) ); + return dsUri.encodedUri(); + } + +@@ -335,7 +335,7 @@ QString QgsXyzVectorTileDataProviderMetadata::relativeToAbsoluteUri( const QStri + { + const QString absSrcUrl = context.pathResolver().readPath( sourceUrl.toLocalFile() ); + dsUri.removeParam( QStringLiteral( "url" ) ); // needed because setParam() would insert second "url" key +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( absSrcUrl ).toString() ); ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( absSrcUrl ).toString( QUrl::DecodeReserved ) ); + return dsUri.encodedUri(); + } + +diff --git a/tests/src/app/testqgsidentify.cpp b/tests/src/app/testqgsidentify.cpp +index 15aaec87c9d..65fe2e81bc0 100644 +--- a/tests/src/app/testqgsidentify.cpp ++++ b/tests/src/app/testqgsidentify.cpp +@@ -933,7 +933,9 @@ void TestQgsIdentify::identifyVectorTile() + const QString vtPath = QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/vector_tile/{z}-{x}-{y}.pbf" ); + QgsDataSourceUri dsUri; + dsUri.setParam( QStringLiteral( "type" ), QStringLiteral( "xyz" ) ); +- dsUri.setParam( QStringLiteral( "url" ), QUrl::fromLocalFile( vtPath ).toString() ); ++ // The values need to be passed to QgsDataSourceUri::setParam() in the same format they are expected to be retrieved. ++ // QUrl::fromPercentEncoding() is needed here because QUrl::fromLocalFile(vtPath).toString() returns the curly braces in an URL-encoded format. ++ dsUri.setParam( QStringLiteral( "url" ), QUrl::fromPercentEncoding( QUrl::fromLocalFile( vtPath ).toString().toUtf8() ) ); + QgsVectorTileLayer *tempLayer = new QgsVectorTileLayer( dsUri.encodedUri(), QStringLiteral( "testlayer" ) ); + QVERIFY( tempLayer->isValid() ); + +diff --git a/tests/src/core/testqgsdatasourceuri.cpp b/tests/src/core/testqgsdatasourceuri.cpp +index f7889423245..4eaac93f4bd 100644 +--- a/tests/src/core/testqgsdatasourceuri.cpp ++++ b/tests/src/core/testqgsdatasourceuri.cpp +@@ -38,6 +38,7 @@ class TestQgsDataSourceUri : public QObject + void checkParameterKeys(); + void checkRemovePassword(); + void checkUnicodeUri(); ++ void checkUriInUri(); + }; + + void TestQgsDataSourceUri::checkparser_data() +@@ -775,7 +776,7 @@ void TestQgsDataSourceUri::checkAuthParams() + // issue GH #53654 + QgsDataSourceUri uri5; + uri5.setEncodedUri( QStringLiteral( "zmax=14&zmin=0&styleUrl=http://localhost:8000/&f=application%2Fvnd.geoserver.mbstyle%2Bjson" ) ); +- QCOMPARE( uri5.param( QStringLiteral( "f" ) ), QStringLiteral( "application%2Fvnd.geoserver.mbstyle%2Bjson" ) ); ++ QCOMPARE( uri5.param( QStringLiteral( "f" ) ), QStringLiteral( "application/vnd.geoserver.mbstyle+json" ) ); + + uri5.setEncodedUri( QStringLiteral( "zmax=14&zmin=0&styleUrl=http://localhost:8000/&f=application/vnd.geoserver.mbstyle+json" ) ); + QCOMPARE( uri5.param( QStringLiteral( "f" ) ), QStringLiteral( "application/vnd.geoserver.mbstyle+json" ) ); +@@ -822,6 +823,83 @@ void TestQgsDataSourceUri::checkUnicodeUri() + QCOMPARE( uri.param( QStringLiteral( "url" ) ), QStringLiteral( "file:///directory/テスト.mbtiles" ) ); + } + ++void TestQgsDataSourceUri::checkUriInUri() ++{ ++ QString dataUri = QStringLiteral( "dpiMode=7&url=%1&SERVICE=WMS&REQUEST=GetCapabilities&username=username&password=qgis%C3%A8%C3%A9" ); ++ ++ // If the 'url' field references a QGIS server then the 'MAP' parameter can contain an url to the project file. ++ // When the project is saved in a postgresql db, the connection url will also contains '&' and '='. ++ { ++ QgsDataSourceUri uri; ++ // here the project url is encoded but the whole serverUrl is not encoded. ++ // The OGC server will receive a call with this url: http://localhost:8000/ows/?MAP=postgresql://?service=qgis_test&dbname&schema=project&project=luxembourg&SERVICE=WMS&REQUEST=GetCapabilities ++ // from the OGC server POV the 'schema' and 'project' keys will be parsed as main query parameters for 'http://localhost:8000/ows/?' ++ // and not associated to the project file uri. ++ QString project = "postgresql://?service=qgis_test&dbname&schema=project&project=luxembourg"; ++ QString projectEnc = QUrl::toPercentEncoding( project ); ++ QString serverUrl = QString( "http://localhost:8000/ows/?MAP=%1" ); ++ uri.setEncodedUri( dataUri.arg( serverUrl.arg( projectEnc ) ) ); ++ QCOMPARE( uri.param( QStringLiteral( "username" ) ), QStringLiteral( "username" ) ); ++ QCOMPARE( uri.username(), QStringLiteral( "username" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "password" ) ), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri.password(), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "SERVICE" ) ), QStringLiteral( "WMS" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "REQUEST" ) ), QStringLiteral( "GetCapabilities" ) ); ++ // not enough encoded at the beginning ==> bad encoding at the end ++ QCOMPARE( uri.param( QStringLiteral( "url" ) ), serverUrl.arg( project ) ); ++ ++ QgsDataSourceUri uri2; ++ // here the project url is encoded and the whole serverUrl is also encoded. ++ // The OGC server will receive a call with this url: http://localhost:8000/ows/?MAP=postgresql%3A%2F%2F%3Fservice%3Dqgis_test%26dbname%26schema%3Dproject%26project%3Dluxembourg&SERVICE=WMS&REQUEST=GetCapabilities ++ // and will be able to decode all parameters ++ QString serverUrlEnc = QUrl::toPercentEncoding( serverUrl.arg( projectEnc ) ); ++ uri2.setEncodedUri( dataUri.arg( serverUrlEnc ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "username" ) ), QStringLiteral( "username" ) ); ++ QCOMPARE( uri2.username(), QStringLiteral( "username" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "password" ) ), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri2.password(), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "SERVICE" ) ), QStringLiteral( "WMS" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "REQUEST" ) ), QStringLiteral( "GetCapabilities" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "url" ) ), serverUrl.arg( projectEnc ) ); ++ } ++ ++ // same as above but with extra param at the end of the ++ { ++ QgsDataSourceUri uri; ++ // here the project url is encoded but the whole serverUrl is not encoded. ++ // The OGC server will receive a call with this url: https://titiler.xyz/cog/tiles/WebMercatorQuad/16/34060/23336@1x?url=https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif&bidx=1&rescale=1600%2C2100&colormap_name=gist_earth ++ // from the OGC server POV the 'rescale' and 'colormap_name' keys could be parsed as sub query parameters for 'https://data.geo.admin.ch/' ++ QString project = "https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2573-1085/swissalti3d_2019_2573-1085_0.5_2056_5728.tif"; ++ QString projectEnc = QUrl::toPercentEncoding( project ); ++ QString extraParam = "&bidx=1&rescale=1600%2C2100&colormap_name=gist_earth"; ++ QString serverUrl = QString( "https://titiler.xyz/cog/tiles/WebMercatorQuad/16/34060/23336@1x?url=%1" ); ++ ++ uri.setEncodedUri( dataUri.arg( serverUrl.arg( projectEnc ) + extraParam ) ); ++ QCOMPARE( uri.param( QStringLiteral( "username" ) ), QStringLiteral( "username" ) ); ++ QCOMPARE( uri.username(), QStringLiteral( "username" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "password" ) ), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri.password(), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "SERVICE" ) ), QStringLiteral( "WMS" ) ); ++ QCOMPARE( uri.param( QStringLiteral( "REQUEST" ) ), QStringLiteral( "GetCapabilities" ) ); ++ // not enough encoded at the beginning ==> bad encoding at the end ++ QCOMPARE( uri.param( QStringLiteral( "url" ) ), serverUrl.arg( project ) ); ++ ++ QgsDataSourceUri uri2; ++ // here the project url is encoded and the whole serverUrl is also encoded. ++ // The OGC server will receive a call with this url: https://titiler.xyz/cog/tiles/WebMercatorQuad/16/34060/23336@1x?url=https%3A%2F%2Fdata.geo.admin.ch%2Fch.swisstopo.swissalti3d%2Fswissalti3d_2019_2573-1085%2Fswissalti3d_2019_2573-1085_0.5_2056_5728.tif&bidx=1&rescale=1600%2C2100&colormap_name=gist_earth ++ // and will be able to decode all parameters ++ QString serverUrlEnc = QUrl::toPercentEncoding( serverUrl.arg( projectEnc ) + extraParam ); ++ uri2.setEncodedUri( dataUri.arg( serverUrlEnc ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "username" ) ), QStringLiteral( "username" ) ); ++ QCOMPARE( uri2.username(), QStringLiteral( "username" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "password" ) ), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri2.password(), QStringLiteral( "qgisèé" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "SERVICE" ) ), QStringLiteral( "WMS" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "REQUEST" ) ), QStringLiteral( "GetCapabilities" ) ); ++ QCOMPARE( uri2.param( QStringLiteral( "url" ) ), serverUrl.arg( projectEnc ) + extraParam ); ++ } ++} ++ + + QGSTEST_MAIN( TestQgsDataSourceUri ) + #include "testqgsdatasourceuri.moc" +diff --git a/tests/src/core/testqgsgdalcloudconnection.cpp b/tests/src/core/testqgsgdalcloudconnection.cpp +index e43c4757ee7..0e69eb210ab 100644 +--- a/tests/src/core/testqgsgdalcloudconnection.cpp ++++ b/tests/src/core/testqgsgdalcloudconnection.cpp +@@ -59,7 +59,7 @@ void TestQgsGdalCloudConnection::encodeDecode() + data.rootPath = QStringLiteral( "some/path" ); + data.credentialOptions = QVariantMap { { "pw", QStringLiteral( "xxxx" ) }, { "key", QStringLiteral( "yyy" ) } }; + +- QCOMPARE( QgsGdalCloudProviderConnection::encodedUri( data ), QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some/path" ) ); ++ QCOMPARE( QgsGdalCloudProviderConnection::encodedUri( data ), QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some%2Fpath" ) ); + + const QgsGdalCloudProviderConnection::Data data2 = QgsGdalCloudProviderConnection::decodedUri( QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some/path" ) ); + QCOMPARE( data2.vsiHandler, QStringLiteral( "vsis3" ) ); +@@ -94,7 +94,7 @@ void TestQgsGdalCloudConnection::testConnections() + + // retrieve stored connection + conn = QgsGdalCloudProviderConnection( QStringLiteral( "my connection" ) ); +- QCOMPARE( conn.uri(), QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some/path" ) ); ++ QCOMPARE( conn.uri(), QStringLiteral( "container=my_container&credentialOptions=key%3Dyyy%7Cpw%3Dxxxx&handler=vsis3&rootPath=some%2Fpath" ) ); + + // add a second connection + QgsGdalCloudProviderConnection::Data data2; +diff --git a/tests/src/core/testqgshttpheaders.cpp b/tests/src/core/testqgshttpheaders.cpp +index 867e44de619..623e9532dbb 100644 +--- a/tests/src/core/testqgshttpheaders.cpp ++++ b/tests/src/core/testqgshttpheaders.cpp +@@ -187,11 +187,14 @@ void TestQgsHttpheaders::createQgsOwsConnection() + + QgsOwsConnection ows( "service", "name" ); + QCOMPARE( ows.connectionInfo(), ",authcfg=,referer=http://test.com" ); +- QCOMPARE( ows.uri().encodedUri(), "url&http-header:other_http_header=value&http-header:referer=http://test.com" ); ++ if ( ows.uri().encodedUri().startsWith( "url=" ) ) ++ QCOMPARE( ows.uri().encodedUri(), "url=&http-header:other_http_header=value&http-header:referer=http%3A%2F%2Ftest.com" ); ++ else ++ QCOMPARE( ows.uri().encodedUri(), "url&http-header:other_http_header=value&http-header:referer=http%3A%2F%2Ftest.com" ); + + QgsDataSourceUri uri( QString( "https://www.ogc.org/?p1=v1" ) ); + QgsDataSourceUri uri2 = ows.addWmsWcsConnectionSettings( uri, "service", "name" ); +- QCOMPARE( uri2.encodedUri(), "https://www.ogc.org/?p1=v1&http-header:other_http_header=value&http-header:referer=http://test.com" ); ++ QCOMPARE( uri2.encodedUri(), "https://www.ogc.org/?p1=v1&http-header:other_http_header=value&http-header:referer=http%3A%2F%2Ftest.com" ); + + // check space separated string + QCOMPARE( uri2.uri(), " https://www.ogc.org/?p1='v1' http-header:other_http_header='value' http-header:referer='http://test.com' referer='http://test.com'" ); +@@ -199,7 +202,7 @@ void TestQgsHttpheaders::createQgsOwsConnection() + QgsDataSourceUri uri3( uri2.uri() ); + QCOMPARE( uri3.httpHeader( QgsHttpHeaders::KEY_REFERER ), "http://test.com" ); + QCOMPARE( uri3.httpHeader( "other_http_header" ), "value" ); +- QCOMPARE( uri3.encodedUri(), "https://www.ogc.org/?p1=v1&referer=http://test.com&http-header:other_http_header=value&http-header:referer=http://test.com" ); ++ QCOMPARE( uri3.encodedUri(), "https://www.ogc.org/?p1=v1&referer=http%3A%2F%2Ftest.com&http-header:other_http_header=value&http-header:referer=http%3A%2F%2Ftest.com" ); + } + + +diff --git a/tests/src/core/testqgsmaplayer.cpp b/tests/src/core/testqgsmaplayer.cpp +index b4a99607aa0..90969a5f0a4 100644 +--- a/tests/src/core/testqgsmaplayer.cpp ++++ b/tests/src/core/testqgsmaplayer.cpp +@@ -33,6 +33,7 @@ + #include "qgsmaplayerstore.h" + #include "qgsproject.h" + #include "qgsxmlutils.h" ++#include "qgsvectortilelayer.h" + + /** + * \ingroup UnitTests +@@ -55,6 +56,8 @@ class TestQgsMapLayer : public QObject + void testId(); + void formatName(); + ++ void generalHtmlMetadata(); ++ + void setBlendMode(); + + void isInScaleRange_data(); +@@ -153,6 +156,33 @@ void TestQgsMapLayer::testId() + QCOMPARE( spy3.count(), 1 ); + } + ++void TestQgsMapLayer::generalHtmlMetadata() ++{ ++ { ++ QgsDataSourceUri ds; ++ ds.setParam( QStringLiteral( "type" ), "xyz" ); ++ ds.setParam( QStringLiteral( "zmax" ), "1" ); ++ ds.setParam( QStringLiteral( "url" ), "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" ); ++ std::unique_ptr vl( new QgsVectorTileLayer( ds.encodedUri(), QStringLiteral( "testLayer" ) ) ); ++ QVERIFY( vl->dataProvider() ); ++ QVERIFY( vl->dataProvider()->isValid() ); ++ QCOMPARE( ds.param( QStringLiteral( "url" ) ), "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" ); ++ QVERIFY( vl->generalHtmlMetadata().contains( "URL vl( new QgsVectorTileLayer( ds.encodedUri(), QStringLiteral( "testLayer" ) ) ); ++ QVERIFY( vl->dataProvider() ); ++ QVERIFY( vl->dataProvider()->isValid() ); ++ QCOMPARE( ds.param( QStringLiteral( "url" ) ), QStringLiteral( "%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QVERIFY( vl->generalHtmlMetadata().contains( QStringLiteral( "Path( &conn )->providerKey(), QStringLiteral( "test_provider" ) ); + + // add a second connection +@@ -110,7 +110,7 @@ void TestQgsTiledSceneConnection::testConnections() + data2.httpHeaders.insert( QStringLiteral( "my_header" ), QStringLiteral( "value2" ) ); + // construct connection using encoded uri + QgsTiledSceneProviderConnection conn2( QgsTiledSceneProviderConnection::encodedUri( data2 ), QStringLiteral( "test_provider2" ), {} ); +- QCOMPARE( conn2.uri(), QStringLiteral( "url=http://testurl2&username=my_user2&password=my_pw2&authcfg=my_auth2&http-header:my_header=value2" ) ); ++ QCOMPARE( conn2.uri(), QStringLiteral( "url=http%3A%2F%2Ftesturl2&username=my_user2&password=my_pw2&authcfg=my_auth2&http-header:my_header=value2" ) ); + QCOMPARE( qgis::down_cast( &conn2 )->providerKey(), QStringLiteral( "test_provider2" ) ); + conn2.store( QStringLiteral( "second connection" ) ); + +diff --git a/tests/src/core/testqgsvectortileconnection.cpp b/tests/src/core/testqgsvectortileconnection.cpp +index e539eb0be69..d73454fa428 100644 +--- a/tests/src/core/testqgsvectortileconnection.cpp ++++ b/tests/src/core/testqgsvectortileconnection.cpp +@@ -62,13 +62,13 @@ void TestQgsVectorTileConnection::test_encodedUri() + conn.zMin = 0; + conn.zMax = 18; + QString uri = QgsVectorTileProviderConnection::encodedUri( conn ); +- QCOMPARE( uri, QStringLiteral( "type=xyz&url=https://api.maptiler.com/tiles/v3/%7Bz%7D/%7Bx%7D/%7By%7D.pbf?key%3Dabcdef12345&zmax=18&zmin=0" ) ); ++ QCOMPARE( uri, QStringLiteral( "type=xyz&url=https%3A%2F%2Fapi.maptiler.com%2Ftiles%2Fv3%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.pbf%3Fkey%3Dabcdef12345&zmax=18&zmin=0" ) ); + + conn.url = QStringLiteral( "file:///home/user/tiles.mbtiles" ); + conn.zMin = 0; + conn.zMax = 18; + uri = QgsVectorTileProviderConnection::encodedUri( conn ); +- QCOMPARE( uri, QStringLiteral( "type=mbtiles&url=file:///home/user/tiles.mbtiles&zmax=18&zmin=0" ) ); ++ QCOMPARE( uri, QStringLiteral( "type=mbtiles&url=file%3A%2F%2F%2Fhome%2Fuser%2Ftiles.mbtiles&zmax=18&zmin=0" ) ); + } + + +diff --git a/tests/src/core/testqgsvectortilelayer.cpp b/tests/src/core/testqgsvectortilelayer.cpp +index 8248b48f4b5..b9b0ec9ef9f 100644 +--- a/tests/src/core/testqgsvectortilelayer.cpp ++++ b/tests/src/core/testqgsvectortilelayer.cpp +@@ -261,11 +261,12 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() + QCOMPARE( vectorTileMetadata->validLayerTypesForUri( QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ), { Qgis::LayerType::VectorTile } ); + + // query sublayers ++ QString localMbtilesPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/vector_tile/mbtiles_vt.mbtiles" ) ) ); + QList sublayers = vectorTileMetadata->querySublayers( QStringLiteral( "%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( !QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); +@@ -274,7 +275,7 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + +@@ -283,7 +284,7 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); +@@ -292,17 +293,19 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + + // fast scan mode means that any mbtile file will be reported, including those with only raster tiles + // (we are skipping a potentially expensive db open and format check) ++ QString localIsleOfManPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/isle_of_man.mbtiles" ) ) ); ++ + sublayers = vectorTileMetadata->querySublayers( QStringLiteral( "%1/isle_of_man.mbtiles" ).arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1/isle_of_man.mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=mbtiles&url=%1" ).arg( localIsleOfManPath ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + +@@ -333,8 +336,9 @@ void TestQgsVectorTileLayer::test_relativePathsMbTiles() + QgsReadWriteContext contextRel; + contextRel.setPathResolver( QgsPathResolver( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/project.qgs" ) ) ); + const QgsReadWriteContext contextAbs; ++ QString localMbtilesPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/vector_tile/mbtiles_vt.mbtiles" ) ) ); + +- const QString srcMbtiles = QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ); ++ const QString srcMbtiles = QStringLiteral( "type=mbtiles&url=%1" ).arg( localMbtilesPath ); + + std::unique_ptr layer = std::make_unique( srcMbtiles ); + QVERIFY( layer->isValid() ); +@@ -342,7 +346,7 @@ void TestQgsVectorTileLayer::test_relativePathsMbTiles() + + // encode source: converting absolute paths to relative + const QString srcMbtilesRel = layer->encodedSource( srcMbtiles, contextRel ); +- QCOMPARE( srcMbtilesRel, QStringLiteral( "type=mbtiles&url=./vector_tile/mbtiles_vt.mbtiles" ) ); ++ QCOMPARE( srcMbtilesRel, QStringLiteral( "type=mbtiles&url=.%2Fvector_tile%2Fmbtiles_vt.mbtiles" ) ); + + // encode source: keeping absolute paths + QCOMPARE( layer->encodedSource( srcMbtiles, contextAbs ), srcMbtiles ); +@@ -393,15 +397,15 @@ void TestQgsVectorTileLayer::test_relativePathsXyz() + contextRel.setPathResolver( QgsPathResolver( "/home/qgis/project.qgs" ) ); + const QgsReadWriteContext contextAbs; + +- const QString srcXyzLocal = "type=xyz&url=file:///home/qgis/%7Bz%7D/%7Bx%7D/%7By%7D.pbf"; +- const QString srcXyzRemote = "type=xyz&url=http://www.example.com/%7Bz%7D/%7Bx%7D/%7By%7D.pbf"; ++ const QString srcXyzLocal = "type=xyz&url=file%3A%2F%2F%2Fhome%2Fqgis%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.pbf"; ++ const QString srcXyzRemote = "type=xyz&url=http%3A%2F%2Fwww.example.com%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.pbf"; + + std::unique_ptr layer = std::make_unique( srcXyzLocal ); + QCOMPARE( layer->providerType(), QStringLiteral( "xyzvectortiles" ) ); + + // encode source: converting absolute paths to relative + const QString srcXyzLocalRel = layer->encodedSource( srcXyzLocal, contextRel ); +- QCOMPARE( srcXyzLocalRel, QStringLiteral( "type=xyz&url=file:./%7Bz%7D/%7Bx%7D/%7By%7D.pbf" ) ); ++ QCOMPARE( srcXyzLocalRel, QStringLiteral( "type=xyz&url=file%3A.%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.pbf" ) ); + QCOMPARE( layer->encodedSource( srcXyzRemote, contextRel ), srcXyzRemote ); + + // encode source: keeping absolute paths +@@ -437,7 +441,8 @@ void TestQgsVectorTileLayer::test_absoluteRelativeUriXyz() + + QString absoluteUri = dsAbs.encodedUri(); + QString relativeUri = dsRel.encodedUri(); +- QCOMPARE( vectorTileMetadata->absoluteToRelativeUri( absoluteUri, context ), relativeUri ); ++ QString absToRelUri = vectorTileMetadata->absoluteToRelativeUri( absoluteUri, context ); ++ QCOMPARE( absToRelUri, relativeUri ); + QCOMPARE( vectorTileMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); + } + +@@ -459,23 +464,23 @@ void TestQgsVectorTileLayer::testVtpkProviderMetadata() + QVERIFY( vectorTileMetadata->querySublayers( QStringLiteral( "type=vtpk&url=%1/points.shp" ).arg( TEST_DATA_DIR ) ).isEmpty() ); + + // vtpk uris +- QCOMPARE( vectorTileMetadata->priorityForUri( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/testvtpk.vtpk" ) ), 100 ); +- QCOMPARE( vectorTileMetadata->validLayerTypesForUri( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/testvtpk.vtpk" ) ), { Qgis::LayerType::VectorTile } ); +- QList sublayers = vectorTileMetadata->querySublayers( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/testvtpk.vtpk" ) ); +- QCOMPARE( sublayers.size(), 1 ); +- QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "vtpkvectortiles" ) ); +- QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "testvtpk" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); +- QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); +- +- QCOMPARE( vectorTileMetadata->priorityForUri( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ), 100 ); +- QCOMPARE( vectorTileMetadata->validLayerTypesForUri( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ), { Qgis::LayerType::VectorTile } ); +- sublayers = vectorTileMetadata->querySublayers( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); +- QCOMPARE( sublayers.size(), 1 ); +- QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "vtpkvectortiles" ) ); +- QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "testvtpk" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); +- QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); ++ QString localVtpkPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/testvtpk.vtpk" ) ) ); ++ ++ for ( auto uriStr : { ++ QStringLiteral( "%1/%2" ).arg( TEST_DATA_DIR ).arg( "testvtpk.vtpk" ), // ++ QStringLiteral( "type=vtpk&url=%1" ).arg( localVtpkPath ), // ++ QStringLiteral( "type=vtpk&url=%1" ).arg( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/testvtpk.vtpk" ) ) ++ } ) ++ { ++ QCOMPARE( vectorTileMetadata->priorityForUri( uriStr ), 100 ); ++ QCOMPARE( vectorTileMetadata->validLayerTypesForUri( uriStr ), { Qgis::LayerType::VectorTile } ); ++ QList sublayers = vectorTileMetadata->querySublayers( uriStr ); ++ QCOMPARE( sublayers.size(), 1 ); ++ QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "vtpkvectortiles" ) ); ++ QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "testvtpk" ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "type=vtpk&url=%1" ).arg( localVtpkPath ) ); ++ QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); ++ } + + // test that vtpk provider is the preferred provider for vtpk files + QList candidates = QgsProviderRegistry::instance()->preferredProvidersForUri( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); +@@ -501,7 +506,9 @@ void TestQgsVectorTileLayer::test_relativePathsVtpk() + contextRel.setPathResolver( QgsPathResolver( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/project.qgs" ) ) ); + const QgsReadWriteContext contextAbs; + +- const QString srcVtpk = QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ); ++ QString localVtpkPath = QStringLiteral( "%1%2" ).arg( QUrl::toPercentEncoding( TEST_DATA_DIR ), QUrl::toPercentEncoding( QStringLiteral( "/testvtpk.vtpk" ) ) ); ++ ++ const QString srcVtpk = QStringLiteral( "type=vtpk&url=%1" ).arg( localVtpkPath ); + + std::unique_ptr layer = std::make_unique( srcVtpk ); + QVERIFY( layer->isValid() ); +@@ -509,7 +516,7 @@ void TestQgsVectorTileLayer::test_relativePathsVtpk() + + // encode source: converting absolute paths to relative + const QString srcVtpkRel = layer->encodedSource( srcVtpk, contextRel ); +- QCOMPARE( srcVtpkRel, QStringLiteral( "type=vtpk&url=./testvtpk.vtpk" ) ); ++ QCOMPARE( srcVtpkRel, QStringLiteral( "type=vtpk&url=.%2Ftestvtpk.vtpk" ) ); + + // encode source: keeping absolute paths + QCOMPARE( layer->encodedSource( srcVtpk, contextAbs ), srcVtpk ); +diff --git a/tests/src/providers/testqgswmsprovider.cpp b/tests/src/providers/testqgswmsprovider.cpp +index 8fe106aab19..e86d96fe894 100644 +--- a/tests/src/providers/testqgswmsprovider.cpp ++++ b/tests/src/providers/testqgswmsprovider.cpp +@@ -469,10 +469,27 @@ void TestQgsWmsProvider::absoluteRelativeUri() + QgsProviderMetadata *wmsMetadata = QgsProviderRegistry::instance()->providerMetadata( "wms" ); + QVERIFY( wmsMetadata ); + +- QString absoluteUri = "type=mbtiles&url=file://" + QStringLiteral( TEST_DATA_DIR ) + "/isle_of_man.mbtiles"; +- QString relativeUri = "type=mbtiles&url=file:./isle_of_man.mbtiles"; +- QCOMPARE( wmsMetadata->absoluteToRelativeUri( absoluteUri, context ), relativeUri ); +- QCOMPARE( wmsMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); ++ // from no encoded absolute url to encoded relative url ++ { ++ QString absoluteUri = QString( "type=mbtiles&url=" ) + "file://" + QStringLiteral( TEST_DATA_DIR ) + "/isle_of_man.mbtiles"; ++ QString relativeUri = "type=mbtiles&url=file%3A.%2Fisle_of_man.mbtiles"; ++ QCOMPARE( wmsMetadata->absoluteToRelativeUri( absoluteUri, context ), relativeUri ); ++ } ++ ++ // from no encoded relative url to encoded absolute url ++ { ++ QString relativeUri = "type=mbtiles&url=file:./isle_of_man.mbtiles"; ++ QString absoluteUri = "type=mbtiles&url=" + QString( QUrl::toPercentEncoding( "file://" + QStringLiteral( TEST_DATA_DIR ) + "/isle_of_man.mbtiles" ) ); ++ QCOMPARE( wmsMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); ++ } ++ ++ // from encoded to encoded ++ { ++ QString absoluteUri = "type=mbtiles&url=" + QString( QUrl::toPercentEncoding( "file://" + QStringLiteral( TEST_DATA_DIR ) + "/isle_of_man.mbtiles" ) ); ++ QString relativeUri = "type=mbtiles&url=file%3A.%2Fisle_of_man.mbtiles"; ++ QCOMPARE( wmsMetadata->absoluteToRelativeUri( absoluteUri, context ), relativeUri ); ++ QCOMPARE( wmsMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); ++ } + } + + void TestQgsWmsProvider::testXyzIsBasemap() +diff --git a/tests/src/python/test_qgsvectortile.py b/tests/src/python/test_qgsvectortile.py +index a4866d1229b..4c42b630b58 100644 +--- a/tests/src/python/test_qgsvectortile.py ++++ b/tests/src/python/test_qgsvectortile.py +@@ -105,7 +105,7 @@ class TestVectorTile(QgisTestCase): + + parts["path"] = "/my/new/file.mbtiles" + uri = md.encodeUri(parts) +- self.assertEqual(uri, "type=mbtiles&url=/my/new/file.mbtiles") ++ self.assertEqual(uri, "type=mbtiles&url=%2Fmy%2Fnew%2Ffile.mbtiles") + + uri = ( + "type=xyz&url=https://fake.server/%7Bx%7D/%7By%7D/%7Bz%7D.png&zmin=0&zmax=2" +@@ -125,7 +125,7 @@ class TestVectorTile(QgisTestCase): + uri = md.encodeUri(parts) + self.assertEqual( + uri, +- "type=xyz&url=https://fake.new.server/%7Bx%7D/%7By%7D/%7Bz%7D.png&zmax=2&zmin=0", ++ "type=xyz&url=https%3A%2F%2Ffake.new.server%2F%7Bx%7D%2F%7By%7D%2F%7Bz%7D.png&zmax=2&zmin=0", + ) + + uri = "type=xyz&serviceType=arcgis&url=https://fake.server/%7Bx%7D/%7By%7D/%7Bz%7D.png&zmax=2&http-header:referer=https://qgis.org/&styleUrl=https://qgis.org/" +@@ -147,7 +147,7 @@ class TestVectorTile(QgisTestCase): + uri = md.encodeUri(parts) + self.assertEqual( + uri, +- "serviceType=arcgis&styleUrl=https://qgis.org/&type=xyz&url=https://fake.new.server/%7Bx%7D/%7By%7D/%7Bz%7D.png&zmax=2&http-header:referer=https://qgis.org/", ++ "serviceType=arcgis&styleUrl=https%3A%2F%2Fqgis.org%2F&type=xyz&url=https%3A%2F%2Ffake.new.server%2F%7Bx%7D%2F%7By%7D%2F%7Bz%7D.png&zmax=2&http-header:referer=https%3A%2F%2Fqgis.org%2F", + ) + + def testZoomRange(self): +diff --git a/tests/src/server/wms/test_qgsserver_wms_parameters.cpp b/tests/src/server/wms/test_qgsserver_wms_parameters.cpp +index 792325c642b..5aa2ab3bd9f 100644 +--- a/tests/src/server/wms/test_qgsserver_wms_parameters.cpp ++++ b/tests/src/server/wms/test_qgsserver_wms_parameters.cpp +@@ -64,14 +64,14 @@ void TestQgsServerWmsParameters::external_layers() + + QgsWms::QgsWmsParametersLayer layer_params = layers_params[0]; + QCOMPARE( layer_params.mNickname, QString( "external_layer_1" ) ); +- QCOMPARE( layer_params.mExternalUri, QString( "layers=layer_1_name&url=http://url_1" ) ); ++ QCOMPARE( layer_params.mExternalUri, QString( "layers=layer_1_name&url=http%3A%2F%2Furl_1" ) ); + + layer_params = layers_params[1]; + QCOMPARE( layer_params.mNickname, QString( "layer" ) ); + + layer_params = layers_params[2]; + QCOMPARE( layer_params.mNickname, QString( "external_layer_2" ) ); +- QCOMPARE( layer_params.mExternalUri, QString( "layers=layer_2_name&opacities=100&url=http://url_2" ) ); ++ QCOMPARE( layer_params.mExternalUri, QString( "layers=layer_2_name&opacities=100&url=http%3A%2F%2Furl_2" ) ); + + //test if opacities are also applied to external layers + QCOMPARE( layers_params[0].mOpacity, 255 ); +@@ -94,7 +94,7 @@ void TestQgsServerWmsParameters::external_layers() + + QgsWms::QgsWmsParametersLayer layer_params2 = layers_params2[0]; + QCOMPARE( layer_params2.mNickname, QString( "external_layer_1" ) ); +- QCOMPARE( layer_params2.mExternalUri, QString( "layers=layer_1_name&url=http://url_1" ) ); ++ QCOMPARE( layer_params2.mExternalUri, QString( "layers=layer_1_name&url=http%3A%2F%2Furl_1" ) ); + } + + void TestQgsServerWmsParameters::percent_encoding() +diff --git a/src/providers/wms/qgswmsprovider.cpp b/src/providers/wms/qgswmsprovider.cpp +index f7a17b8c6ba..429dafa69dd 100644 +--- a/src/providers/wms/qgswmsprovider.cpp ++++ b/src/providers/wms/qgswmsprovider.cpp +@@ -4983,7 +4983,7 @@ QList QgsWmsProviderMetadata::dataItemProviders() const + QVariantMap QgsWmsProviderMetadata::decodeUri( const QString &uri ) const + { + const QUrlQuery query { uri }; +- const QList> constItems { query.queryItems() }; ++ const QList> constItems { query.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ) }; + QVariantMap decoded; + for ( const QPair &item : constItems ) + { +@@ -5035,24 +5035,31 @@ QString QgsWmsProviderMetadata::encodeUri( const QVariantMap &parts ) const + { + if ( it.key() == QLatin1String( "path" ) ) + { +- items.push_back( { QStringLiteral( "url" ), QUrl::fromLocalFile( it.value().toString() ).toString() } ); ++ items.push_back( { QStringLiteral( "url" ), QUrl::toPercentEncoding( QUrl::fromLocalFile( it.value().toString() ).toString() ) } ); + } + else if ( it.key() == QLatin1String( "url" ) ) + { + if ( !parts.contains( QLatin1String( "path" ) ) ) + { +- items.push_back( { it.key(), it.value().toString() } ); ++ items.push_back( { it.key(), QUrl::toPercentEncoding( it.value().toString() ) } ); + } + } + else + { + if ( it.value().userType() == QMetaType::Type::QStringList ) + { +- listItems.push_back( { it.key(), it.value().toStringList() } ); ++ QStringList encodedList; ++ const QStringList unencodedList = it.value().toStringList(); ++ encodedList.reserve( unencodedList.size() ); ++ for ( const QString &item : unencodedList ) ++ { ++ encodedList << QUrl::toPercentEncoding( item ); ++ } ++ listItems.push_back( { it.key(), encodedList } ); + } + else + { +- items.push_back( { it.key(), it.value().toString() } ); ++ items.push_back( { it.key(), QUrl::toPercentEncoding( it.value().toString() ) } ); + } + } + } +diff --git a/tests/src/providers/testqgswmsprovider.cpp b/tests/src/providers/testqgswmsprovider.cpp +index 7f07c933b6d..5ac4d3c43c9 100644 +--- a/tests/src/providers/testqgswmsprovider.cpp ++++ b/tests/src/providers/testqgswmsprovider.cpp +@@ -319,7 +319,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( !QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); +@@ -328,7 +328,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + +@@ -345,16 +345,16 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); + +- sublayers = wmsMetadata->querySublayers( QStringLiteral( "type=mbtiles&url=%1/isle_of_man.mbtiles" ).arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); ++ sublayers = wmsMetadata->querySublayers( u"type=mbtiles&url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles"_s.arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + +@@ -372,7 +372,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/vector_tile/mbtiles_vt.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fvector_tile%2Fmbtiles_vt.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + +@@ -433,22 +433,21 @@ void TestQgsWmsProvider::providerUriUpdates() + QCOMPARE( parts["testParam"], QVariant( "false" ) ); + + QString updatedUri = metadata->encodeUri( parts ); +- QString expectedUri = QStringLiteral( "crs=EPSG:4326&dpiMode=7&" ++ QString expectedUri = QStringLiteral( "crs=EPSG%3A4326&dpiMode=7&" + "layers=testlayer&styles&" + "testParam=false&" +- "url=http://localhost:8380/mapserv" ); ++ "url=http%3A%2F%2Flocalhost%3A8380%2Fmapserv" ); + QCOMPARE( updatedUri, expectedUri ); + } + + void TestQgsWmsProvider::providerUriLocalFile() + { +- QString uriString = QStringLiteral( "url=file:///my/local/tiles.mbtiles&type=mbtiles" ); +- QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( QStringLiteral( "wms" ), uriString ); ++ QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( u"wms"_s, u"url=file:///my/local/tiles.mbtiles&type=mbtiles"_s ); + QVariantMap expectedParts { { QString( "type" ), QVariant( "mbtiles" ) }, { QString( "path" ), QVariant( "/my/local/tiles.mbtiles" ) }, { QString( "url" ), QVariant( "file:///my/local/tiles.mbtiles" ) } }; + QCOMPARE( parts, expectedParts ); + + QString encodedUri = QgsProviderRegistry::instance()->encodeUri( QStringLiteral( "wms" ), parts ); +- QCOMPARE( encodedUri, uriString ); ++ QCOMPARE( encodedUri, u"url=file%3A%2F%2F%2Fmy%2Flocal%2Ftiles.mbtiles&type=mbtiles"_s ); + + QgsProviderMetadata *wmsMetadata = QgsProviderRegistry::instance()->providerMetadata( "wms" ); + QVERIFY( wmsMetadata ); +diff --git a/tests/src/python/test_qgsmapboxglconverter.py b/tests/src/python/test_qgsmapboxglconverter.py +index 16c0b6652a5..2da4bce4e0c 100644 +--- a/tests/src/python/test_qgsmapboxglconverter.py ++++ b/tests/src/python/test_qgsmapboxglconverter.py +@@ -2554,7 +2554,7 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): + self.assertIsInstance(rl, QgsRasterLayer) + self.assertEqual( + rl.source(), +- "tilePixelRation=1&type=xyz&url=https://yyyyyy/v1/tiles/texturereliefshade/EPSG:3857/%7Bz%7D/%7Bx%7D/%7By%7D.webp&zmax=20&zmin=3", ++ "tilePixelRation=1&type=xyz&url=https%3A%2F%2Fyyyyyy%2Fv1%2Ftiles%2Ftexturereliefshade%2FEPSG%3A3857%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.webp&zmax=20&zmin=3", + ) + self.assertEqual(rl.providerType(), "wms") + +@@ -2566,7 +2566,7 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): + self.assertEqual(raster_layer.name(), "Texture-Relief") + self.assertEqual( + raster_layer.source(), +- "tilePixelRation=1&type=xyz&url=https://yyyyyy/v1/tiles/texturereliefshade/EPSG:3857/%7Bz%7D/%7Bx%7D/%7By%7D.webp&zmax=20&zmin=3", ++ "tilePixelRation=1&type=xyz&url=https%3A%2F%2Fyyyyyy%2Fv1%2Ftiles%2Ftexturereliefshade%2FEPSG%3A3857%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.webp&zmax=20&zmin=3", + ) + self.assertEqual( + raster_layer.pipe() From 779448b5a4dbc704230f8bd04cc875e862c54ad9 Mon Sep 17 00:00:00 2001 From: Tomas Mizera Date: Mon, 23 Mar 2026 13:30:12 +0100 Subject: [PATCH 02/12] Fix value relation performance regression (#4414) --- app/valuerelationfeaturesmodel.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/valuerelationfeaturesmodel.cpp b/app/valuerelationfeaturesmodel.cpp index d2f8db682..1d4f8066b 100644 --- a/app/valuerelationfeaturesmodel.cpp +++ b/app/valuerelationfeaturesmodel.cpp @@ -155,7 +155,7 @@ QVariant ValueRelationFeaturesModel::convertFromQgisType( QVariant qgsValue, Mod for ( int ix = 0; ix < rowCount(); ++ix ) { - QgsFeature f = data( index( ix, 0 ), Feature ).value(); + QgsFeature f = mFeatures.at( ix ).feature(); if ( keyMap.contains( f.attribute( mKeyField ).toString() ) ) { From e7d279174eba3896db8fa18430114c5bdad1deb3 Mon Sep 17 00:00:00 2001 From: Matej Bagar Date: Mon, 23 Mar 2026 13:34:13 +0100 Subject: [PATCH 03/12] Update translations --- app/i18n/input_ca.qm | Bin 80339 -> 79884 bytes app/i18n/input_en.qm | Bin 3028 -> 2707 bytes app/i18n/input_et.qm | Bin 74805 -> 74705 bytes app/i18n/input_et_EE.qm | Bin 74700 -> 74600 bytes app/i18n/input_fi.qm | Bin 76805 -> 76506 bytes app/i18n/input_fi_FI.qm | Bin 76946 -> 76641 bytes app/i18n/input_hu.qm | Bin 80249 -> 79789 bytes app/i18n/input_sk.qm | Bin 77146 -> 76423 bytes app/i18n/input_sk_SK.qm | Bin 77131 -> 76408 bytes 9 files changed, 0 insertions(+), 0 deletions(-) diff --git a/app/i18n/input_ca.qm b/app/i18n/input_ca.qm index 8b368122225710182f7c9dd159212d0e59584b19..5279f80f7c85cb59b5c66ed02633e9e873ef76d3 100644 GIT binary patch delta 4914 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 delta 5264 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 W!dU@M3rA)JL|&6--HIN1;XeT1^B`gX diff --git a/app/i18n/input_en.qm b/app/i18n/input_en.qm index 61969a250aabf8730b1a0e99a1cb174a8ab2fa12..666e47cd082d668d3ec9f3e9c16dfda7bf1ff48c 100644 GIT binary patch delta 332 zcmca2K3Q~vT)hPYgHJF61KU9cmTj&K49rgkbIbewe|1+7Sj20Ml&!4BmmF3=B+l3{&KR<}&IqeqPH7q_;9P z-`)b$|BC7Os+9~3^$hwfGd%YIwY`JTtS?#C=qv>)Y=O{hs;u41T7VWcvaX)}80f%7 zY>w+^0_A_RgZUhR9IGBb0g5v~XtuYU3HuKN9l8xdb98W?bp8r7e=X-fwsk;t77&_U z4NN;Quz%tF$36ilkqe>OW4J#@#sLlZ#_1OR|*SWf@| delta 656 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 buwTeo>n#|BoL3cvk5q2A=5((r{A~C?9A~M4 delta 5669 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 Q~i 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 QIkcr7GgP+yf;FT0Ki&kP@&Et; delta 5672 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 HU^4y-dcBKM diff --git a/app/i18n/input_fi.qm b/app/i18n/input_fi.qm index 6e7e0e13acd33a43a28a9e5fa641e34ea040e568..30fff2e1cd3a6fc163bd786f7cd70691e3bc29ab 100644 GIT binary patch delta 5434 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 delta 5329 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 diff --git a/app/i18n/input_sk_SK.qm b/app/i18n/input_sk_SK.qm index 95368c81204c0f7e01b9b5d8267d4d83afe23b26..ef723a1e0f3f502aeb7c59387c2e7670930af420 100644 GIT binary patch delta 4619 zcmXAtdq7V2AIIP4dCqyR=Xs7OnN4zug*HT(`*Ix`DssQfWf7vV+SWrUSwzEDEVmUY z3b~66Etjm4n505&3v*eBAL{qk`RjGg^PK1V{e0h__viEdp65!5`0Bd2p@q$^0DvDb zcRV?P3O8U=VL>;k5p2R?U!rp;G?j~Qb0L15)oh#SYlhOc!Hj}8XHho~&N1@V*!B+OTNrWeFZ z1Av(eAwKF1A;RLkPh&?w27J*)Ij&BngP|_mUT1^GM zoQK#uNx->k#Fmri`(negjliNt*w}I@gvRCA)RM}M{s!^KwgKA%u;X1mu($#{OPNxS za-`Z;xB#VBvEL&WC~u7PqDr879~=sPPGtg-xtmI~Pr|7$6#j5MvP-Gdvr+i%Y=7YP zT;!h41iE>k@L*dmU<-6kXJuhx;0M5WTZKu7j{v`YF3g(2z)pJ#ff-F`sK+X& z_ZEVrFrcJL2yZ$In0{V}{!@TpF9>T6a^-`vh4mkCLRX!T(ESYXMSzg7LS!ncg+zyb zKPlxcPSOD} zt||85dZOYrH$P(F0b@1K!dSqY4VqW`tPqsZTCsyKo%2*{HZ-u8$S^Uq}h2P?&bi&S_?f>_jr5r7|o%lxd0-EKDZ+cz;QlE)+<065WCQ{>gEt|4Ua@eyF zcs^cgn(_>S=_{#OP9+4%TWX%oK*u;YP;gu!wHPb`%LhvBTs?ui@zTd{Hv$WHHBe|Y zQtDVb2v~4U>g2_C6&`Any8l$i2>+ElKl}su)FO?jpvyH+RVvOZ+g7Xe7}7vNz9@}Z zU|=A>NWODjXs&mX?*c#GUz5gzDVB*hG*Sp$2i zJZIY?#q3^1544hE(pa7IlBKmna(KT%WmcNB!G9J6oh)r`&y`k;m$pX7b49mQF3MJU zw^mAyrB{CKD($|wnT^&><@{YzY7A35XN#1!^*-B?ue5K*5sq_7+86g7Q{vJ^z1R@d zKtb3n9c^6?OfHtPFINHf71D)PWt2El%I(hF@0=msdeaZ+7b=ys>dZ#lLaKFP>Ry-V z?55cPr&4rw+2w4|`MO?jmU3r#s~ea>kN6pMqvM$B{8u_Zt2@hCubU(|yx#-E8!k{? zqnm8}jdkgwo7(p=@XKu7+!RVT=dCW_G!;6qQWxmL&%g22E&t#-H_^2!i+cYK03kdda#@jF9zZh5QA z?bwzLrH$_PG_KrhfUe>)x1>JjbyZz1uuOKS+_qWwmm>pe(p~qg2PJkCb^rb6!v+(j zdmc+^AD_@0bR{g~UiuGLPX*c>)w|rR1Bx^Bo$BrZH`eMuO_)VbywV|a<&@znBm7iG-cp%4vw?!$Px|*}5*i)Tr%%lQdU)thp7#Z=T-Tp1r4l*S z`rAL*1Ii_Rh3HJPdg&j;--FP^SO2ho5V?4E-wW$|p9~j2JZ3BEP7$ziz@f5Ju5YV!a zyVVWD4}*6CXU-XRE|Y=0Y{Tv^K4#?ghSd9&Ku|}O1yP2y>0FWj5JP%WD+v0#hKzqG zH7#-AOe8y1OM$d&ehNp%S(*& zUY7xB*~Z}cR$y?dG5R^jop_f_XZ<(V7!P)hW9!ryvw~g&v0oT}yUT;Q;hgc3C#z%N zIAiG`dZDs|vGn`y+*HDhrO*8VQ?aqO`YYhV24kHrovGNPvfzZu+Z~K``VIX2FO>za zO^u`aalAB>lSd>395S_&Thlz&E~c(kM}T&%P5m0a@G=e97X%n@m4)8 zWtGasek!v!o4$UwoW7GB&`3eBPqC-~=%*-}GALX*r*<`whkHQ4O9 zgWmb3(CqOn41!&AbB|HpRA!3#bK3)VHiSOrVPP9N;A^w*>If>4X7;Ogg`kNxPhLE>7Ktw4*B zGRE#@^<>Dp9se?gJLRU^LwLq6ksVhu<&n*0*C|JM2+y*~?&V=XVYJ*kk`rW`WzR?b zxq@hU*yWjQD4z20g0XBo$K??hsnm{2c}zZ6^zxqU>mSVtzLjUJ=B^h!Tb|pRfky_( zi$|0JN0Q`Vr#*n~njD(Diihs;a>Vmn+>~a>F-Q9`g{S0o`<6gxYV(&9t&HS+oV+`D zInZ{Wy!+2te7!g!AJ57G?lh85)E05~bCXXAL2RXR1BE8ea#j>S4~&p=2C-^v_VVR! zyw8{<=d~_oZ~shww6qEscUpego2^Sxkmy zm8K5(-(pK*Z@T!3&62vF1K6Lm?BCgvt#hR1z`J0eb9c+3Io@okf2xdYr}E+jOV&y% z8FJBb;UXIrdtw7Sf2T6HrzNKu%TV!GxvY)l_NFYF;qpGqqiE*t#uH0@4yEb4+S(e! zX_m%T*Att7^}Vg#qbh;budP16KZhXNtigr<@r}^V8ampa_3vtp%U%e?ZM3F82m;>u zTMz5GE0#K2^KNk*mtbqY+j;(b;A6d;O=a$Fw?69c1=Mx5{##=6Wuu5y@-t*bka1 zpYBO#dEHSuhtxu7=B&6M-3CnRt8(fXrJIqzYx%h{!lt97-Uk)06e@5akK}!dgUW-Q zl+i96sK@^l?;E|?5AG^ohi>LOUJqqnNL!xyrYk{{&H$dHmDPSU(~VBb`gX3sgC0uU z*g-si994ea!sqtdEAd{}=_N@?T`%!}mSp9)C5(!SNkDuNhj=USzzDw%)66e*8;zu=j!ukun$lf4X9UbSKC%{->O o@^Jvx6)5%BDE-wR{3%1o#`@cxGDkOF)pp~A9(^+p_xic!|L1|1Q2+n{ delta 5267 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 Date: Wed, 25 Mar 2026 12:12:21 +0100 Subject: [PATCH 04/12] Refactor QGIS 4 patch (#4415) --- .../ports/qgis/qgis4-project-properties.patch | 50 +-- vcpkg/ports/qgis/qgis4_url_encoding.patch | 344 +++++++----------- 2 files changed, 147 insertions(+), 247 deletions(-) diff --git a/vcpkg/ports/qgis/qgis4-project-properties.patch b/vcpkg/ports/qgis/qgis4-project-properties.patch index 814fcf5b5..0f1149cf5 100644 --- a/vcpkg/ports/qgis/qgis4-project-properties.patch +++ b/vcpkg/ports/qgis/qgis4-project-properties.patch @@ -1,13 +1,5 @@ -commit 74549aad26c3358101e88477d9dfa1caae013d72 -Author: Jürgen E. Fischer -Date: Fri Jun 20 15:58:30 2025 +0200 - - Reapply "Allow free naming of project properties (#60855)" - - This reverts commit fb11239112adfc321b3bbacbb20da888a7a37c23. - diff --git a/src/core/project/qgsproject.cpp b/src/core/project/qgsproject.cpp -index f78f9e53bef..cd6f78edaaf 100644 +index d5cd3e3ebb4..819f8809084 100644 --- a/src/core/project/qgsproject.cpp +++ b/src/core/project/qgsproject.cpp @@ -116,21 +116,6 @@ QStringList makeKeyTokens_( const QString &scope, const QString &key ) @@ -32,7 +24,7 @@ index f78f9e53bef..cd6f78edaaf 100644 return keyTokens; } -@@ -1322,20 +1307,20 @@ void dump_( const QgsProjectPropertyKey &topQgsPropertyKey ) +@@ -1311,20 +1296,20 @@ void dump_( const QgsProjectPropertyKey &topQgsPropertyKey ) * scope. "layers" is a list containing three string values. * * \code{.xml} @@ -64,7 +56,7 @@ index f78f9e53bef..cd6f78edaaf 100644 * * \endcode * -@@ -3992,10 +3977,25 @@ bool QgsProject::createEmbeddedLayer( const QString &layerId, const QString &pro +@@ -3967,10 +3952,25 @@ bool QgsProject::createEmbeddedLayer( const QString &layerId, const QString &pro const QDomElement propertiesElem = sProjectDocument.documentElement().firstChildElement( QStringLiteral( "properties" ) ); if ( !propertiesElem.isNull() ) { @@ -94,29 +86,9 @@ index f78f9e53bef..cd6f78edaaf 100644 } diff --git a/src/core/project/qgsprojectproperty.cpp b/src/core/project/qgsprojectproperty.cpp -index ff8024a5260..1af598012b4 100644 +index ff8024a5260..7691c1b5d53 100644 --- a/src/core/project/qgsprojectproperty.cpp +++ b/src/core/project/qgsprojectproperty.cpp -@@ -233,15 +233,15 @@ bool QgsProjectPropertyValue::readXml( const QDomNode &keyNode ) - - // keyElement is created by parent QgsProjectPropertyKey - bool QgsProjectPropertyValue::writeXml( QString const &nodeName, -- QDomElement &keyElement, -- QDomDocument &document ) -+ QDomElement &keyElement, -+ QDomDocument &document ) - { -- QDomElement valueElement = document.createElement( nodeName ); -+ QDomElement valueElement = document.createElement( QStringLiteral( "properties" ) ); - - // remember the type so that we can rebuild it when the project is read in -+ valueElement.setAttribute( QStringLiteral( "name" ), nodeName ); - valueElement.setAttribute( QStringLiteral( "type" ), mValue.typeName() ); - -- - // we handle string lists differently from other types in that we - // create a sequence of repeated elements to cover all the string list - // members; each value will be in a tag. @@ -362,33 +362,41 @@ bool QgsProjectPropertyKey::readXml( const QDomNode &keyNode ) while ( i < subkeys.count() ) @@ -173,21 +145,11 @@ index ff8024a5260..1af598012b4 100644 } } -@@ -408,7 +416,8 @@ bool QgsProjectPropertyKey::writeXml( QString const &nodeName, QDomElement &elem - // If it's an _empty_ node (i.e., one with no properties) we need to emit - // an empty place holder; else create new Dom elements as necessary. - -- QDomElement keyElement = document.createElement( nodeName ); // Dom element for this property key -+ QDomElement keyElement = document.createElement( "properties" ); // Dom element for this property key -+ keyElement.toElement().setAttribute( QStringLiteral( "name" ), nodeName ); - - if ( ! mProperties.isEmpty() ) - { diff --git a/tests/src/python/test_qgsproject.py b/tests/src/python/test_qgsproject.py -index 237553260f6..d44d4438006 100644 +index 4da8f330941..752110de78a 100644 --- a/tests/src/python/test_qgsproject.py +++ b/tests/src/python/test_qgsproject.py -@@ -65,84 +65,6 @@ class TestQgsProject(QgisTestCase): +@@ -63,84 +63,6 @@ class TestQgsProject(QgisTestCase): QgisTestCase.__init__(self, methodName) self.messageCaught = False diff --git a/vcpkg/ports/qgis/qgis4_url_encoding.patch b/vcpkg/ports/qgis/qgis4_url_encoding.patch index d67e50968..6d1854015 100644 --- a/vcpkg/ports/qgis/qgis4_url_encoding.patch +++ b/vcpkg/ports/qgis/qgis4_url_encoding.patch @@ -1,5 +1,5 @@ diff --git a/src/core/network/qgshttpheaders.cpp b/src/core/network/qgshttpheaders.cpp -index 74322dc2fcb..c5cfbbabc66 100644 +index de9caeceeee..890c3100852 100644 --- a/src/core/network/qgshttpheaders.cpp +++ b/src/core/network/qgshttpheaders.cpp @@ -73,7 +73,7 @@ bool QgsHttpHeaders::updateUrlQuery( QUrlQuery &uri ) const @@ -26,32 +26,10 @@ index a86c4d2bc60..f559bb21112 100644 } diff --git a/src/core/qgsdatasourceuri.cpp b/src/core/qgsdatasourceuri.cpp -index 6b26e9dd2ee..fe0dd2a12b5 100644 +index 689690e4003..aaef521e652 100644 --- a/src/core/qgsdatasourceuri.cpp +++ b/src/core/qgsdatasourceuri.cpp -@@ -701,17 +701,17 @@ QByteArray QgsDataSourceUri::encodedUri() const - QUrlQuery url; - for ( auto it = mParams.constBegin(); it != mParams.constEnd(); ++it ) - { -- url.addQueryItem( it.key(), it.value() ); -+ url.addQueryItem( it.key(), QUrl::toPercentEncoding( it.value() ) ); - } - - if ( !mUsername.isEmpty() ) -- url.addQueryItem( QStringLiteral( "username" ), mUsername ); -+ url.addQueryItem( QStringLiteral( "username" ), QUrl::toPercentEncoding( mUsername ) ); - - if ( !mPassword.isEmpty() ) -- url.addQueryItem( QStringLiteral( "password" ), mPassword ); -+ url.addQueryItem( QStringLiteral( "password" ), QUrl::toPercentEncoding( mPassword ) ); - - if ( !mAuthConfigId.isEmpty() ) -- url.addQueryItem( QStringLiteral( "authcfg" ), mAuthConfigId ); -+ url.addQueryItem( QStringLiteral( "authcfg" ), QUrl::toPercentEncoding( mAuthConfigId ) ); - - mHttpHeaders.updateUrlQuery( url ); - -@@ -731,7 +731,7 @@ void QgsDataSourceUri::setEncodedUri( const QByteArray &uri ) +@@ -711,7 +711,7 @@ void QgsDataSourceUri::setEncodedUri( const QByteArray &uri ) mHttpHeaders.setFromUrlQuery( query ); @@ -61,10 +39,10 @@ index 6b26e9dd2ee..fe0dd2a12b5 100644 { if ( !item.first.startsWith( QgsHttpHeaders::PARAM_PREFIX ) && item.first != QgsHttpHeaders::KEY_REFERER ) diff --git a/src/core/qgsmaplayer.cpp b/src/core/qgsmaplayer.cpp -index 90b7fba14bb..c2e3cc126bb 100644 +index e1cfb604055..abd3c19a0fc 100644 --- a/src/core/qgsmaplayer.cpp +++ b/src/core/qgsmaplayer.cpp -@@ -3262,8 +3262,9 @@ QString QgsMapLayer::generalHtmlMetadata() const +@@ -3277,8 +3277,9 @@ QString QgsMapLayer::generalHtmlMetadata() const } if ( uriComponents.contains( QStringLiteral( "url" ) ) ) { @@ -120,11 +98,24 @@ index be607514666..08c45dbe3c5 100644 return dsUri.encodedUri(); } +diff --git a/src/providers/wms/qgswmsprovider.cpp b/src/providers/wms/qgswmsprovider.cpp +index 8c602f74b2e..538087e0163 100644 +--- a/src/providers/wms/qgswmsprovider.cpp ++++ b/src/providers/wms/qgswmsprovider.cpp +@@ -4957,7 +4957,7 @@ QList QgsWmsProviderMetadata::dataItemProviders() const + QVariantMap QgsWmsProviderMetadata::decodeUri( const QString &uri ) const + { + const QUrlQuery query { uri }; +- const QList> constItems { query.queryItems() }; ++ const QList> constItems { query.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ) }; + QVariantMap decoded; + for ( const QPair &item : constItems ) + { diff --git a/tests/src/app/testqgsidentify.cpp b/tests/src/app/testqgsidentify.cpp -index 15aaec87c9d..65fe2e81bc0 100644 +index 856d5077c15..401e58747d8 100644 --- a/tests/src/app/testqgsidentify.cpp +++ b/tests/src/app/testqgsidentify.cpp -@@ -933,7 +933,9 @@ void TestQgsIdentify::identifyVectorTile() +@@ -932,7 +932,9 @@ void TestQgsIdentify::identifyVectorTile() const QString vtPath = QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/vector_tile/{z}-{x}-{y}.pbf" ); QgsDataSourceUri dsUri; dsUri.setParam( QStringLiteral( "type" ), QStringLiteral( "xyz" ) ); @@ -136,10 +127,10 @@ index 15aaec87c9d..65fe2e81bc0 100644 QVERIFY( tempLayer->isValid() ); diff --git a/tests/src/core/testqgsdatasourceuri.cpp b/tests/src/core/testqgsdatasourceuri.cpp -index f7889423245..4eaac93f4bd 100644 +index 409b059b488..436216ede80 100644 --- a/tests/src/core/testqgsdatasourceuri.cpp +++ b/tests/src/core/testqgsdatasourceuri.cpp -@@ -38,6 +38,7 @@ class TestQgsDataSourceUri : public QObject +@@ -37,6 +37,7 @@ class TestQgsDataSourceUri : public QObject void checkParameterKeys(); void checkRemovePassword(); void checkUnicodeUri(); @@ -147,7 +138,7 @@ index f7889423245..4eaac93f4bd 100644 }; void TestQgsDataSourceUri::checkparser_data() -@@ -775,7 +776,7 @@ void TestQgsDataSourceUri::checkAuthParams() +@@ -564,7 +565,7 @@ void TestQgsDataSourceUri::checkAuthParams() // issue GH #53654 QgsDataSourceUri uri5; uri5.setEncodedUri( QStringLiteral( "zmax=14&zmin=0&styleUrl=http://localhost:8000/&f=application%2Fvnd.geoserver.mbstyle%2Bjson" ) ); @@ -156,7 +147,7 @@ index f7889423245..4eaac93f4bd 100644 uri5.setEncodedUri( QStringLiteral( "zmax=14&zmin=0&styleUrl=http://localhost:8000/&f=application/vnd.geoserver.mbstyle+json" ) ); QCOMPARE( uri5.param( QStringLiteral( "f" ) ), QStringLiteral( "application/vnd.geoserver.mbstyle+json" ) ); -@@ -822,6 +823,83 @@ void TestQgsDataSourceUri::checkUnicodeUri() +@@ -611,6 +612,83 @@ void TestQgsDataSourceUri::checkUnicodeUri() QCOMPARE( uri.param( QStringLiteral( "url" ) ), QStringLiteral( "file:///directory/テスト.mbtiles" ) ); } @@ -263,10 +254,10 @@ index e43c4757ee7..0e69eb210ab 100644 // add a second connection QgsGdalCloudProviderConnection::Data data2; diff --git a/tests/src/core/testqgshttpheaders.cpp b/tests/src/core/testqgshttpheaders.cpp -index 867e44de619..623e9532dbb 100644 +index 9c2df3cc20e..78bc5f8be81 100644 --- a/tests/src/core/testqgshttpheaders.cpp +++ b/tests/src/core/testqgshttpheaders.cpp -@@ -187,11 +187,14 @@ void TestQgsHttpheaders::createQgsOwsConnection() +@@ -147,11 +147,14 @@ void TestQgsHttpheaders::createQgsOwsConnection() QgsOwsConnection ows( "service", "name" ); QCOMPARE( ows.connectionInfo(), ",authcfg=,referer=http://test.com" ); @@ -283,7 +274,7 @@ index 867e44de619..623e9532dbb 100644 // check space separated string QCOMPARE( uri2.uri(), " https://www.ogc.org/?p1='v1' http-header:other_http_header='value' http-header:referer='http://test.com' referer='http://test.com'" ); -@@ -199,7 +202,7 @@ void TestQgsHttpheaders::createQgsOwsConnection() +@@ -159,7 +162,7 @@ void TestQgsHttpheaders::createQgsOwsConnection() QgsDataSourceUri uri3( uri2.uri() ); QCOMPARE( uri3.httpHeader( QgsHttpHeaders::KEY_REFERER ), "http://test.com" ); QCOMPARE( uri3.httpHeader( "other_http_header" ), "value" ); @@ -293,10 +284,10 @@ index 867e44de619..623e9532dbb 100644 diff --git a/tests/src/core/testqgsmaplayer.cpp b/tests/src/core/testqgsmaplayer.cpp -index b4a99607aa0..90969a5f0a4 100644 +index 47e6f2d5a6e..e9d527b186f 100644 --- a/tests/src/core/testqgsmaplayer.cpp +++ b/tests/src/core/testqgsmaplayer.cpp -@@ -33,6 +33,7 @@ +@@ -32,6 +32,7 @@ #include "qgsmaplayerstore.h" #include "qgsproject.h" #include "qgsxmlutils.h" @@ -304,7 +295,7 @@ index b4a99607aa0..90969a5f0a4 100644 /** * \ingroup UnitTests -@@ -55,6 +56,8 @@ class TestQgsMapLayer : public QObject +@@ -54,6 +55,8 @@ class TestQgsMapLayer : public QObject void testId(); void formatName(); @@ -313,7 +304,7 @@ index b4a99607aa0..90969a5f0a4 100644 void setBlendMode(); void isInScaleRange_data(); -@@ -153,6 +156,33 @@ void TestQgsMapLayer::testId() +@@ -150,6 +153,33 @@ void TestQgsMapLayer::testId() QCOMPARE( spy3.count(), 1 ); } @@ -432,10 +423,10 @@ index e539eb0be69..d73454fa428 100644 diff --git a/tests/src/core/testqgsvectortilelayer.cpp b/tests/src/core/testqgsvectortilelayer.cpp -index 8248b48f4b5..b9b0ec9ef9f 100644 +index 4a5f82f0b0d..99c0b503c30 100644 --- a/tests/src/core/testqgsvectortilelayer.cpp +++ b/tests/src/core/testqgsvectortilelayer.cpp -@@ -261,11 +261,12 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() +@@ -256,11 +256,12 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() QCOMPARE( vectorTileMetadata->validLayerTypesForUri( QStringLiteral( "type=mbtiles&url=%1/vector_tile/mbtiles_vt.mbtiles" ).arg( TEST_DATA_DIR ) ), { Qgis::LayerType::VectorTile } ); // query sublayers @@ -449,7 +440,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); QVERIFY( !QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); -@@ -274,7 +275,7 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() +@@ -269,7 +270,7 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() QCOMPARE( sublayers.size(), 1 ); QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); @@ -458,7 +449,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); -@@ -283,7 +284,7 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() +@@ -278,7 +279,7 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() QCOMPARE( sublayers.size(), 1 ); QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); @@ -467,7 +458,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); QVERIFY( QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); -@@ -292,17 +293,19 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() +@@ -287,17 +288,19 @@ void TestQgsVectorTileLayer::testMbtilesProviderMetadata() QCOMPARE( sublayers.size(), 1 ); QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "mbtilesvectortiles" ) ); QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); @@ -489,7 +480,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::VectorTile ); QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); -@@ -333,8 +336,9 @@ void TestQgsVectorTileLayer::test_relativePathsMbTiles() +@@ -328,8 +331,9 @@ void TestQgsVectorTileLayer::test_relativePathsMbTiles() QgsReadWriteContext contextRel; contextRel.setPathResolver( QgsPathResolver( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/project.qgs" ) ) ); const QgsReadWriteContext contextAbs; @@ -500,7 +491,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 std::unique_ptr layer = std::make_unique( srcMbtiles ); QVERIFY( layer->isValid() ); -@@ -342,7 +346,7 @@ void TestQgsVectorTileLayer::test_relativePathsMbTiles() +@@ -337,7 +341,7 @@ void TestQgsVectorTileLayer::test_relativePathsMbTiles() // encode source: converting absolute paths to relative const QString srcMbtilesRel = layer->encodedSource( srcMbtiles, contextRel ); @@ -509,7 +500,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 // encode source: keeping absolute paths QCOMPARE( layer->encodedSource( srcMbtiles, contextAbs ), srcMbtiles ); -@@ -393,15 +397,15 @@ void TestQgsVectorTileLayer::test_relativePathsXyz() +@@ -377,15 +381,15 @@ void TestQgsVectorTileLayer::test_relativePathsXyz() contextRel.setPathResolver( QgsPathResolver( "/home/qgis/project.qgs" ) ); const QgsReadWriteContext contextAbs; @@ -528,7 +519,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 QCOMPARE( layer->encodedSource( srcXyzRemote, contextRel ), srcXyzRemote ); // encode source: keeping absolute paths -@@ -437,7 +441,8 @@ void TestQgsVectorTileLayer::test_absoluteRelativeUriXyz() +@@ -421,7 +425,8 @@ void TestQgsVectorTileLayer::test_absoluteRelativeUriXyz() QString absoluteUri = dsAbs.encodedUri(); QString relativeUri = dsRel.encodedUri(); @@ -538,7 +529,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 QCOMPARE( vectorTileMetadata->relativeToAbsoluteUri( relativeUri, context ), absoluteUri ); } -@@ -459,23 +464,23 @@ void TestQgsVectorTileLayer::testVtpkProviderMetadata() +@@ -443,23 +448,23 @@ void TestQgsVectorTileLayer::testVtpkProviderMetadata() QVERIFY( vectorTileMetadata->querySublayers( QStringLiteral( "type=vtpk&url=%1/points.shp" ).arg( TEST_DATA_DIR ) ).isEmpty() ); // vtpk uris @@ -579,7 +570,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 // test that vtpk provider is the preferred provider for vtpk files QList candidates = QgsProviderRegistry::instance()->preferredProvidersForUri( QStringLiteral( "type=vtpk&url=%1/testvtpk.vtpk" ).arg( TEST_DATA_DIR ) ); -@@ -501,7 +506,9 @@ void TestQgsVectorTileLayer::test_relativePathsVtpk() +@@ -485,7 +490,9 @@ void TestQgsVectorTileLayer::test_relativePathsVtpk() contextRel.setPathResolver( QgsPathResolver( QStringLiteral( TEST_DATA_DIR ) + QStringLiteral( "/project.qgs" ) ) ); const QgsReadWriteContext contextAbs; @@ -590,7 +581,7 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 std::unique_ptr layer = std::make_unique( srcVtpk ); QVERIFY( layer->isValid() ); -@@ -509,7 +516,7 @@ void TestQgsVectorTileLayer::test_relativePathsVtpk() +@@ -493,7 +500,7 @@ void TestQgsVectorTileLayer::test_relativePathsVtpk() // encode source: converting absolute paths to relative const QString srcVtpkRel = layer->encodedSource( srcVtpk, contextRel ); @@ -600,10 +591,84 @@ index 8248b48f4b5..b9b0ec9ef9f 100644 // encode source: keeping absolute paths QCOMPARE( layer->encodedSource( srcVtpk, contextAbs ), srcVtpk ); diff --git a/tests/src/providers/testqgswmsprovider.cpp b/tests/src/providers/testqgswmsprovider.cpp -index 8fe106aab19..e86d96fe894 100644 +index d736bfcc38f..3cbaf2578fd 100644 --- a/tests/src/providers/testqgswmsprovider.cpp +++ b/tests/src/providers/testqgswmsprovider.cpp -@@ -469,10 +469,27 @@ void TestQgsWmsProvider::absoluteRelativeUri() +@@ -321,7 +321,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( !QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); +@@ -330,7 +330,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); + +@@ -347,16 +347,16 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + QVERIFY( QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); + +- sublayers = wmsMetadata->querySublayers( QStringLiteral( "type=mbtiles&url=%1/isle_of_man.mbtiles" ).arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); ++ sublayers = wmsMetadata->querySublayers( u"type=mbtiles&url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles"_s.arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + +@@ -374,7 +374,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() + QCOMPARE( sublayers.size(), 1 ); + QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); + QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); +- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/vector_tile/mbtiles_vt.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); ++ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fvector_tile%2Fmbtiles_vt.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); + QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); + QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); + +@@ -435,22 +435,21 @@ void TestQgsWmsProvider::providerUriUpdates() + QCOMPARE( parts["testParam"], QVariant( "false" ) ); + + QString updatedUri = metadata->encodeUri( parts ); +- QString expectedUri = QStringLiteral( "crs=EPSG:4326&dpiMode=7&" ++ QString expectedUri = QStringLiteral( "crs=EPSG%3A4326&dpiMode=7&" + "layers=testlayer&styles&" + "testParam=false&" +- "url=http://localhost:8380/mapserv" ); ++ "url=http%3A%2F%2Flocalhost%3A8380%2Fmapserv" ); + QCOMPARE( updatedUri, expectedUri ); + } + + void TestQgsWmsProvider::providerUriLocalFile() + { +- QString uriString = QStringLiteral( "url=file:///my/local/tiles.mbtiles&type=mbtiles" ); +- QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( QStringLiteral( "wms" ), uriString ); ++ QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( u"wms"_s, u"url=file:///my/local/tiles.mbtiles&type=mbtiles"_s ); + QVariantMap expectedParts { { QString( "type" ), QVariant( "mbtiles" ) }, { QString( "path" ), QVariant( "/my/local/tiles.mbtiles" ) }, { QString( "url" ), QVariant( "file:///my/local/tiles.mbtiles" ) } }; + QCOMPARE( parts, expectedParts ); + + QString encodedUri = QgsProviderRegistry::instance()->encodeUri( QStringLiteral( "wms" ), parts ); +- QCOMPARE( encodedUri, uriString ); ++ QCOMPARE( encodedUri, u"url=file%3A%2F%2F%2Fmy%2Flocal%2Ftiles.mbtiles&type=mbtiles"_s ); + + QgsProviderMetadata *wmsMetadata = QgsProviderRegistry::instance()->providerMetadata( "wms" ); + QVERIFY( wmsMetadata ); +@@ -475,10 +474,27 @@ void TestQgsWmsProvider::absoluteRelativeUri() QgsProviderMetadata *wmsMetadata = QgsProviderRegistry::instance()->providerMetadata( "wms" ); QVERIFY( wmsMetadata ); @@ -635,6 +700,28 @@ index 8fe106aab19..e86d96fe894 100644 } void TestQgsWmsProvider::testXyzIsBasemap() +diff --git a/tests/src/python/test_qgsmapboxglconverter.py b/tests/src/python/test_qgsmapboxglconverter.py +index 8f4640eb89c..0031ee3c54d 100644 +--- a/tests/src/python/test_qgsmapboxglconverter.py ++++ b/tests/src/python/test_qgsmapboxglconverter.py +@@ -2406,7 +2406,7 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): + self.assertIsInstance(rl, QgsRasterLayer) + self.assertEqual( + rl.source(), +- "tilePixelRation=1&type=xyz&url=https://yyyyyy/v1/tiles/texturereliefshade/EPSG:3857/%7Bz%7D/%7Bx%7D/%7By%7D.webp&zmax=20&zmin=3", ++ "tilePixelRation=1&type=xyz&url=https%3A%2F%2Fyyyyyy%2Fv1%2Ftiles%2Ftexturereliefshade%2FEPSG%3A3857%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.webp&zmax=20&zmin=3", + ) + self.assertEqual(rl.providerType(), "wms") + +@@ -2418,7 +2418,7 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): + self.assertEqual(raster_layer.name(), "Texture-Relief") + self.assertEqual( + raster_layer.source(), +- "tilePixelRation=1&type=xyz&url=https://yyyyyy/v1/tiles/texturereliefshade/EPSG:3857/%7Bz%7D/%7Bx%7D/%7By%7D.webp&zmax=20&zmin=3", ++ "tilePixelRation=1&type=xyz&url=https%3A%2F%2Fyyyyyy%2Fv1%2Ftiles%2Ftexturereliefshade%2FEPSG%3A3857%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.webp&zmax=20&zmin=3", + ) + self.assertEqual( + raster_layer.pipe() diff --git a/tests/src/python/test_qgsvectortile.py b/tests/src/python/test_qgsvectortile.py index a4866d1229b..4c42b630b58 100644 --- a/tests/src/python/test_qgsvectortile.py @@ -696,152 +783,3 @@ index 792325c642b..5aa2ab3bd9f 100644 } void TestQgsServerWmsParameters::percent_encoding() -diff --git a/src/providers/wms/qgswmsprovider.cpp b/src/providers/wms/qgswmsprovider.cpp -index f7a17b8c6ba..429dafa69dd 100644 ---- a/src/providers/wms/qgswmsprovider.cpp -+++ b/src/providers/wms/qgswmsprovider.cpp -@@ -4983,7 +4983,7 @@ QList QgsWmsProviderMetadata::dataItemProviders() const - QVariantMap QgsWmsProviderMetadata::decodeUri( const QString &uri ) const - { - const QUrlQuery query { uri }; -- const QList> constItems { query.queryItems() }; -+ const QList> constItems { query.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ) }; - QVariantMap decoded; - for ( const QPair &item : constItems ) - { -@@ -5035,24 +5035,31 @@ QString QgsWmsProviderMetadata::encodeUri( const QVariantMap &parts ) const - { - if ( it.key() == QLatin1String( "path" ) ) - { -- items.push_back( { QStringLiteral( "url" ), QUrl::fromLocalFile( it.value().toString() ).toString() } ); -+ items.push_back( { QStringLiteral( "url" ), QUrl::toPercentEncoding( QUrl::fromLocalFile( it.value().toString() ).toString() ) } ); - } - else if ( it.key() == QLatin1String( "url" ) ) - { - if ( !parts.contains( QLatin1String( "path" ) ) ) - { -- items.push_back( { it.key(), it.value().toString() } ); -+ items.push_back( { it.key(), QUrl::toPercentEncoding( it.value().toString() ) } ); - } - } - else - { - if ( it.value().userType() == QMetaType::Type::QStringList ) - { -- listItems.push_back( { it.key(), it.value().toStringList() } ); -+ QStringList encodedList; -+ const QStringList unencodedList = it.value().toStringList(); -+ encodedList.reserve( unencodedList.size() ); -+ for ( const QString &item : unencodedList ) -+ { -+ encodedList << QUrl::toPercentEncoding( item ); -+ } -+ listItems.push_back( { it.key(), encodedList } ); - } - else - { -- items.push_back( { it.key(), it.value().toString() } ); -+ items.push_back( { it.key(), QUrl::toPercentEncoding( it.value().toString() ) } ); - } - } - } -diff --git a/tests/src/providers/testqgswmsprovider.cpp b/tests/src/providers/testqgswmsprovider.cpp -index 7f07c933b6d..5ac4d3c43c9 100644 ---- a/tests/src/providers/testqgswmsprovider.cpp -+++ b/tests/src/providers/testqgswmsprovider.cpp -@@ -319,7 +319,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() - QCOMPARE( sublayers.size(), 1 ); - QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); - QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); -- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); -+ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); - QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); - QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); - QVERIFY( !QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); -@@ -328,7 +328,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() - QCOMPARE( sublayers.size(), 1 ); - QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); - QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); -- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); -+ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); - QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); - QVERIFY( !sublayers.at( 0 ).skippedContainerScan() ); - -@@ -345,16 +345,16 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() - QCOMPARE( sublayers.size(), 1 ); - QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); - QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); -- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); -+ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); - QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); - QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); - QVERIFY( QgsProviderUtils::sublayerDetailsAreIncomplete( sublayers ) ); - -- sublayers = wmsMetadata->querySublayers( QStringLiteral( "type=mbtiles&url=%1/isle_of_man.mbtiles" ).arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); -+ sublayers = wmsMetadata->querySublayers( u"type=mbtiles&url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles"_s.arg( TEST_DATA_DIR ), Qgis::SublayerQueryFlag::FastScan ); - QCOMPARE( sublayers.size(), 1 ); - QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); - QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "isle_of_man" ) ); -- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/isle_of_man.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); -+ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fisle_of_man.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); - QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); - QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); - -@@ -372,7 +372,7 @@ void TestQgsWmsProvider::testMbtilesProviderMetadata() - QCOMPARE( sublayers.size(), 1 ); - QCOMPARE( sublayers.at( 0 ).providerKey(), QStringLiteral( "wms" ) ); - QCOMPARE( sublayers.at( 0 ).name(), QStringLiteral( "mbtiles_vt" ) ); -- QCOMPARE( sublayers.at( 0 ).uri(), QStringLiteral( "url=file://%1/vector_tile/mbtiles_vt.mbtiles&type=mbtiles" ).arg( TEST_DATA_DIR ) ); -+ QCOMPARE( sublayers.at( 0 ).uri(), u"url=file%3A%2F%2F%1%2Fvector_tile%2Fmbtiles_vt.mbtiles&type=mbtiles"_s.arg( QString( TEST_DATA_DIR ).replace( "/", "%2F" ) ) ); - QCOMPARE( sublayers.at( 0 ).type(), Qgis::LayerType::Raster ); - QVERIFY( sublayers.at( 0 ).skippedContainerScan() ); - -@@ -433,22 +433,21 @@ void TestQgsWmsProvider::providerUriUpdates() - QCOMPARE( parts["testParam"], QVariant( "false" ) ); - - QString updatedUri = metadata->encodeUri( parts ); -- QString expectedUri = QStringLiteral( "crs=EPSG:4326&dpiMode=7&" -+ QString expectedUri = QStringLiteral( "crs=EPSG%3A4326&dpiMode=7&" - "layers=testlayer&styles&" - "testParam=false&" -- "url=http://localhost:8380/mapserv" ); -+ "url=http%3A%2F%2Flocalhost%3A8380%2Fmapserv" ); - QCOMPARE( updatedUri, expectedUri ); - } - - void TestQgsWmsProvider::providerUriLocalFile() - { -- QString uriString = QStringLiteral( "url=file:///my/local/tiles.mbtiles&type=mbtiles" ); -- QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( QStringLiteral( "wms" ), uriString ); -+ QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( u"wms"_s, u"url=file:///my/local/tiles.mbtiles&type=mbtiles"_s ); - QVariantMap expectedParts { { QString( "type" ), QVariant( "mbtiles" ) }, { QString( "path" ), QVariant( "/my/local/tiles.mbtiles" ) }, { QString( "url" ), QVariant( "file:///my/local/tiles.mbtiles" ) } }; - QCOMPARE( parts, expectedParts ); - - QString encodedUri = QgsProviderRegistry::instance()->encodeUri( QStringLiteral( "wms" ), parts ); -- QCOMPARE( encodedUri, uriString ); -+ QCOMPARE( encodedUri, u"url=file%3A%2F%2F%2Fmy%2Flocal%2Ftiles.mbtiles&type=mbtiles"_s ); - - QgsProviderMetadata *wmsMetadata = QgsProviderRegistry::instance()->providerMetadata( "wms" ); - QVERIFY( wmsMetadata ); -diff --git a/tests/src/python/test_qgsmapboxglconverter.py b/tests/src/python/test_qgsmapboxglconverter.py -index 16c0b6652a5..2da4bce4e0c 100644 ---- a/tests/src/python/test_qgsmapboxglconverter.py -+++ b/tests/src/python/test_qgsmapboxglconverter.py -@@ -2554,7 +2554,7 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): - self.assertIsInstance(rl, QgsRasterLayer) - self.assertEqual( - rl.source(), -- "tilePixelRation=1&type=xyz&url=https://yyyyyy/v1/tiles/texturereliefshade/EPSG:3857/%7Bz%7D/%7Bx%7D/%7By%7D.webp&zmax=20&zmin=3", -+ "tilePixelRation=1&type=xyz&url=https%3A%2F%2Fyyyyyy%2Fv1%2Ftiles%2Ftexturereliefshade%2FEPSG%3A3857%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.webp&zmax=20&zmin=3", - ) - self.assertEqual(rl.providerType(), "wms") - -@@ -2566,7 +2566,7 @@ class TestQgsMapBoxGlStyleConverter(QgisTestCase): - self.assertEqual(raster_layer.name(), "Texture-Relief") - self.assertEqual( - raster_layer.source(), -- "tilePixelRation=1&type=xyz&url=https://yyyyyy/v1/tiles/texturereliefshade/EPSG:3857/%7Bz%7D/%7Bx%7D/%7By%7D.webp&zmax=20&zmin=3", -+ "tilePixelRation=1&type=xyz&url=https%3A%2F%2Fyyyyyy%2Fv1%2Ftiles%2Ftexturereliefshade%2FEPSG%3A3857%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.webp&zmax=20&zmin=3", - ) - self.assertEqual( - raster_layer.pipe() From e58f7dc52c654ad78870a6464bacf0c387d93839 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina <80618569+gabriel-bolbotina@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:51:33 +0200 Subject: [PATCH 05/12] Fix gallery hot reload (#4358) --- gallery/hotreload.cpp | 78 +++++++++++++++++++++++++++---------------- gallery/hotreload.h | 2 ++ gallery/qml/Main.qml | 20 +++++++++-- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/gallery/hotreload.cpp b/gallery/hotreload.cpp index a6025c2d4..9cbd55af2 100644 --- a/gallery/hotreload.cpp +++ b/gallery/hotreload.cpp @@ -14,18 +14,14 @@ #include #include -// TODO: not needed to sync dirs every second, just when a file was changed QString HotReload::syncScript() const { - return "#!/bin/sh \n\ -echo running hot reload sync directories ... \n\ -while true; do \n\ - rsync -ra " GALLERY_SOURCE_DIR "/qml/ HotReload/qml/ \n\ - rsync -ra " GALLERY_SOURCE_DIR "/../app/qml/ HotReload/app/qml/ \n\ - sleep 1 \n\ -done"; + return QString( "#!/bin/sh \n" + "echo 'Syncing modified files...' \n" + "rsync -rau \"%1/qml/\" HotReload/qml/ \n" + "rsync -rau \"%1/../app/qml/\" HotReload/app/qml/ \n" ) + .arg( GALLERY_SOURCE_DIR ); } - HotReload::HotReload( QQmlApplicationEngine &engine, QObject *parent ): _engine( engine ) { @@ -66,28 +62,54 @@ void HotReload::clearCache() void HotReload::startHotReload() { + _debounceTimer = new QTimer( this ); + _debounceTimer->setSingleShot( true ); + _debounceTimer->setInterval( 300 ); + + // when the timer starts, run the sync script ONCE, then reload + connect( _debounceTimer, &QTimer::timeout, this, [this]() + { + // run the sync synchronously so it finishes before reloading + QProcess::execute( "./syncGallery.sh" ); + emit watchedSourceChanged(); + } ); + _watcher = new QFileSystemWatcher( this ); - _watcher->addPath( "HotReload/qml" ); - _watcher->addPath( "HotReload/qml/Pages" ); - _watcher->addPath( "HotReload/app/qml/account" ); - _watcher->addPath( "HotReload/app/qml/account/components" ); - _watcher->addPath( "HotReload/app/qml/components" ); - _watcher->addPath( "HotReload/app/qml/dialogs" ); - _watcher->addPath( "HotReload/app/qml/form" ); - _watcher->addPath( "HotReload/app/qml/form/components" ); - _watcher->addPath( "HotReload/app/qml/form/editors" ); - _watcher->addPath( "HotReload/app/qml/gps" ); - _watcher->addPath( "HotReload/app/qml/inputs" ); - _watcher->addPath( "HotReload/app/qml/layers" ); - _watcher->addPath( "HotReload/app/qml/map" ); - _watcher->addPath( "HotReload/app/qml/project" ); - _watcher->addPath( "HotReload/app/qml/project/components" ); - _watcher->addPath( "HotReload/app/qml/settings" ); - _watcher->addPath( "HotReload/app/qml/settings/components" ); - // send signal for hot reloading + // Set up base paths for your source code + QString gallerySrc = QString( GALLERY_SOURCE_DIR ) + "/qml"; + QString appSrc = QString( GALLERY_SOURCE_DIR ) + "/../app/qml"; + + // Watch the SOURCE directories instead of the destination + _watcher->addPath( gallerySrc ); + _watcher->addPath( gallerySrc + "/Pages" ); + _watcher->addPath( gallerySrc + "/pages" ); + _watcher->addPath( gallerySrc + "/components" ); + + _watcher->addPath( appSrc + "/account" ); + _watcher->addPath( appSrc + "/account/components" ); + _watcher->addPath( appSrc + "/components" ); + _watcher->addPath( appSrc + "/dialogs" ); + _watcher->addPath( appSrc + "/form" ); + _watcher->addPath( appSrc + "/form/components" ); + _watcher->addPath( appSrc + "/form/editors" ); + _watcher->addPath( appSrc + "/gps" ); + _watcher->addPath( appSrc + "/inputs" ); + _watcher->addPath( appSrc + "/layers" ); + _watcher->addPath( appSrc + "/map" ); + _watcher->addPath( appSrc + "/project" ); + _watcher->addPath( appSrc + "/project/components" ); + _watcher->addPath( appSrc + "/settings" ); + _watcher->addPath( appSrc + "/settings/components" ); + + // when you save the file, start the debounce timer connect( _watcher, &QFileSystemWatcher::directoryChanged, this, [this]( const QString & path ) { - emit watchedSourceChanged(); + _debounceTimer->start(); + } ); + + connect( _watcher, &QFileSystemWatcher::fileChanged, this, [this]( const QString & path ) + { + _debounceTimer->start(); } ); } diff --git a/gallery/hotreload.h b/gallery/hotreload.h index b1cecc0f6..fa8b62628 100644 --- a/gallery/hotreload.h +++ b/gallery/hotreload.h @@ -12,6 +12,7 @@ #include #include +#include class QFileSystemWatcher; @@ -34,6 +35,7 @@ class HotReload : public QObject private: QFileSystemWatcher *_watcher; QQmlApplicationEngine &_engine; + QTimer *_debounceTimer = nullptr; }; #endif // HOTRELOAD_H diff --git a/gallery/qml/Main.qml b/gallery/qml/Main.qml index 878a1bf5e..78d05319c 100644 --- a/gallery/qml/Main.qml +++ b/gallery/qml/Main.qml @@ -25,15 +25,29 @@ ApplicationWindow { property string currentPageSource: "InitialGalleryPage.qml" + Timer { + id: reloadTimer + interval: 50 + onTriggered: { + // delete the cache after 50ms + _hotReload.clearCache() + + mainLoader.source = Qt.binding(function () { + return (__isMobile ? "qrc:/qml/pages/" : ("file://" + _qmlWrapperPath)) + window.currentPageSource + }) + mainLoader.active = true + + console.log(new Date().toLocaleTimeString().split(' ')[0] + " ------ App reloaded 🔥 ------ ") + } + } Connections { target: __isMobile ? null : _hotReload enabled: !__isMobile function onWatchedSourceChanged() { mainLoader.active = false + mainLoader.setSource("") _hotReload.clearCache() - mainLoader.setSource("file:///" + _qmlWrapperPath + currentPageSource) - mainLoader.active = true - console.log( new Date().toLocaleTimeString().split(' ')[0] + " ------ App reloaded 🔥 ------ ") + reloadTimer.start() } } From 5e184e0996cad1e6ef2ba47b0282d7c273f6ca93 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina <80618569+gabriel-bolbotina@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:08:03 +0200 Subject: [PATCH 06/12] Downgraded cppcheck version (#4416) --- .github/workflows/code_style.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index 827ef7a10..a03edd9d1 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -68,9 +68,12 @@ jobs: uses: actions/checkout@v6 - name: Install Requirements + #workaround for the current false positive bug in cppcheck 2.20 (latest version) run: | - brew update - brew install cppcheck + curl -L https://github.com/danmar/cppcheck/archive/refs/tags/2.19.1.tar.gz | tar xz + cmake -S cppcheck-2.19.1 -B cppcheck-build -DCMAKE_BUILD_TYPE=Release + cmake --build cppcheck-build -j$(sysctl -n hw.logicalcpu) + cmake --install cppcheck-build - name: Run cppcheck test run: ./scripts/cppcheck.bash From af4e30f1713fe2fb2d9841d0c9c43530799340af Mon Sep 17 00:00:00 2001 From: Matej Bagar Date: Wed, 25 Mar 2026 15:09:07 +0100 Subject: [PATCH 07/12] Artefacts build bot CI update (#4385) --- .github/workflows/android.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 122ac0e48..c1976fe7e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -274,6 +274,14 @@ jobs: echo "$FASTLANE_OUTPUT" SHARE_URL=$(echo "$FASTLANE_OUTPUT" | awk '/APP_SHARE_URL:/ {print $NF}') echo "Google play store APK link: $SHARE_URL" >> $GITHUB_STEP_SUMMARY + + # Write the Play Store link into this job's check run output + curl -s -X PATCH \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "https://api.github.com/repos/${{ github.repository }}/check-runs/${{ job.check_run_id }}" \ + -d "{\"output\":{\"title\":\"Android ${{ matrix.ANDROID_ABI }}\",\"summary\":\"$SHARE_URL\"}}" - name: Build AAB if: ${{ github.ref_name == 'master' || github.ref_type == 'tag' }} @@ -309,3 +317,11 @@ jobs: echo "$FASTLANE_OUTPUT" SHARE_URL=$(echo "$FASTLANE_OUTPUT" | awk '/APP_SHARE_URL:/ {print $NF}') echo "Google play store AAB link: $SHARE_URL" >> $GITHUB_STEP_SUMMARY + + # Write the Play Store link into this job's check run output + curl -s -X PATCH \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "https://api.github.com/repos/${{ github.repository }}/check-runs/${{ job.check_run_id }}" \ + -d "{\"output\":{\"title\":\"Android ${{ matrix.ANDROID_ABI }}\",\"summary\":\"$SHARE_URL\"}}" \ No newline at end of file From a8bb047a6c7861353081db7454b0c0fed0389ce6 Mon Sep 17 00:00:00 2001 From: Stefanos Natsis Date: Thu, 26 Mar 2026 10:38:38 +0200 Subject: [PATCH 08/12] Update links and make them stable. Add note for failed libpq build (#4407) --- INSTALL.md | 92 +++++++++++++++++++++++++++++------------------------- 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 461a92e22..386ae2ca3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,23 +1,27 @@ -# Table of Contents +# Table of Contents - [Table of Contents](#table-of-contents) -- [1. Introduction](#1-introduction) -- [2. Overview](#2-overview) - - [2.1 Secrets](#21-secrets) - - [2.2 Code formatting](#22-code-formatting) -- [3. Building GNU/Linux](#3-building-gnulinux) -- [4. Building Android (on Linux/macOS/Windows)](#4-building-android-on-linuxmacoswindows) - - [4.1. Android on Ubuntu](#41-android-on-ubuntu) - - [4.2. Android on macOS](#42-android-on-macos) - - [4.3. Android on Windows](#43-android-on-windows) -- [5. Building iOS](#5-building-ios) -- [6. Building macOS](#6-building-macos) -- [7. Building Windows](#7-building-windows) -- [8. FAQ](#8-faq) -- [9. Auto Testing](#9-auto-testing) - -# 1. Introduction +- [1. Introduction](#introduction) +- [2. Overview](#overview) + - [2.1 Secrets](#secrets) + - [2.2 Code formatting](#code-formatting) + - [2.3 Qt packages](#qt-packages) + - [2.4 Vcpkg](#vcpkg) + - [2.5 ccache](#ccache) +- [3. Building GNU/Linux](#building-linux) + - [3.1 Ubuntu 22.04](#ubuntu) +- [4. Building Android (on Linux/macOS/Windows)](#building-android) + - [4.1. Android on Ubuntu](#android-on-linux) + - [4.2. Android on macOS](#android-on-macos) + - [4.3. Android on Windows](#android-on-windows) +- [5. Building iOS](#building-ios) +- [6. Building macOS](#building-macos) +- [7. Building Windows](#building-windows) +- [8. FAQ](#faq) +- [9. Auto Testing](#auto-testing) + +# 1. Introduction This document is the original installation guide of the described software Mergin Maps mobile app. The software and hardware descriptions named in this @@ -39,7 +43,7 @@ For code architecture of codebase, please see [docs](./docs/README.md). **Note to document writers:** Please use this document as the central place for describing build procedures. Please do not remove this notice. -# 2. Overview +# 2. Overview Mobile app, like a number of major projects (e.g., KDE), uses [CMake](https://www.cmake.org) for building from source. @@ -53,7 +57,7 @@ Generally, for building setup, we recommend to use the same versions of librarie [GitHub Actions](https://github.com/MerginMaps/mobile/tree/master/.github/workflows). Open workflow file for your platform/target and see the version of libraries used and replicate it in your setup. -## 2.1 Secrets +## 2.1 Secrets To communicate with MerginAPI, some endpoints need to attach `api_key`. To not leak API_KEY, the source code that returns the API_KEYS is encrypted. @@ -81,7 +85,7 @@ cd core/ openssl aes-256-cbc -d -in merginsecrets.cpp.enc -out merginsecrets.cpp -md md5 ``` -## 2.2 Code formatting +## 2.2 Code formatting We use `astyle` to format CPP and Objective-C files. Format is similar to what QGIS has. We use `cmake-format` to format CMake files. @@ -92,12 +96,12 @@ their usage For more details about code conventions, please read our [code conventions doc](./docs/code_convention.md). -## 2.3 Qt packages +## 2.3 Qt packages Mergin Maps Mobile app is built with Qt. Qt is build with vcpkg as part of the configure step, but it is recommended to install QtCreator and Qt on your host to be able to release translations. -## 2.4 Vcpkg +## 2.4 Vcpkg Dependencies are build with vcpkg. To fix the version of libraries, you need to download vcpkg and checkout to git commit specified in the file `VCPKG_BASELINE` in the repository. @@ -115,13 +119,13 @@ in the file `VCPKG_BASELINE` in the repository. ./bootstrap-vcpkg.sh ``` -## 2.4 ccache +## 2.5 ccache Install and configure ccache for development. It speeds up the development significantly. -# 3. Building GNU/Linux +# 3. Building GNU/Linux -## 3.1 Ubuntu 22.04 +## 3.1 Ubuntu 22.04 Steps to build and run mobile app: @@ -157,7 +161,7 @@ Steps to build and run mobile app: Alternatively you can open QtCreator and add cmake defines to the QtCreator Project setup table and configure from QtCreator (recommended for development and debugging) - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. ``` mkdir -p build @@ -172,6 +176,10 @@ Steps to build and run mobile app: -GNinja \ -S ../mobile ``` + + Note: `libpq` will fail to build if the `zic` tool is not in the system path. In that case, set the `ZIC` environment variable to the full path leading to + the executable, for example: `export ZIC=/usr/sbin/zic`. + 4. Build application ``` @@ -184,13 +192,13 @@ Steps to build and run mobile app: ./app/Input ``` - For testing read [Auto Testing](#AutoTesting) section. + For testing read [Auto Testing](#auto-testing) section. -# 4. Building Android (on Linux/macOS/Windows) +# 4. Building Android (on Linux/macOS/Windows) For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back -## 4.1. Android on Linux +## 4.1. Android on Linux 1. Install some dependencies, see requirements in `.github/workflows/android.yml` @@ -275,7 +283,7 @@ For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back ``` - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. 4. Build and Run @@ -290,7 +298,7 @@ For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back MerginMaps ``` -## 4.2. Android on macOS +## 4.2. Android on macOS 1. Install Java - `brew install openjdk@17`, then make this java version default ``export JAVA_HOME=`usr/libexec/java_home -v 17` ``. Check if it's default by executing `java --version` @@ -391,7 +399,7 @@ For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back ``` - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. 4. Build and Run @@ -406,12 +414,12 @@ For building ABIs see https://www.qt.io/blog/android-multi-abi-builds-are-back MerginMaps ``` -## 4.3. Android on Windows +## 4.3. Android on Windows Even technically it should be possible, we haven't tried this setup yet. If you managed to compile mobile app for Android on Windows, please help us to update this section. -# 5. Building iOS +# 5. Building iOS - you have to run Release or RelWithDebInfo builds. Debug builds will usually crash on some Qt's assert - if there is any problem running mobile app from Qt Creator, open cmake-generated project in XCode directly @@ -441,7 +449,7 @@ mobile app for Android on Windows, please help us to update this section. Alternatively you can open QtCreator and add cmake defines to the QtCreator Project setup table and configure from QtCreator (recommended for development and debugging) - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. Note: make sure you adjust VCPKG_HOST_TRIPLET and CMAKE_SYSTEM_PROCESSOR if you use x64-osx host machine. @@ -492,7 +500,7 @@ Alternatively, navigate to the build folder and open the Xcode Project: Once the project is opened, build it from Xcode. -# 6. Building macOS +# 6. Building macOS 1. Install some dependencies, critically XCode, bison and flex. See "Install Build Dependencies" step in `.github/workflows/macos.yml` ``` @@ -521,7 +529,7 @@ Once the project is opened, build it from Xcode. Alternatively you can open QtCreator and add cmake defines to the QtCreator Project setup table and configure from QtCreator (recommended for development and debugging) - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. Note: for x64-osx (intel laptops) build use VCPKG_TARGET_TRIPLET instead of arm64-osx (Mx laptops) @@ -555,7 +563,7 @@ Once the project is opened, build it from Xcode. ./app/MerginMaps.app/Contents/MacOS/MerginMaps ``` -# 7. Building Windows +# 7. Building Windows 1. Install some dependencies. See `.github/workflows/win.yml` Critically Visual Studio, cmake, bison and flex. Setup build VS environment (adjust to your version) @@ -583,7 +591,7 @@ Once the project is opened, build it from Xcode. Alternatively you can open QtCreator and add cmake defines to the QtCreator Project setup table and configure from QtCreator (recommended for development and debugging) - To use USE_MM_SERVER_API_KEY read [Secrets](#Secrets) section. + To use USE_MM_SERVER_API_KEY read [Secrets](#secrets) section. ``` mkdir build @@ -613,7 +621,7 @@ Once the project is opened, build it from Xcode. ./app/MerginMaps.exe ``` -# 8. FAQ +# 8. FAQ - If you have "error: undefined reference to 'stdout'" or so, make sure that in BUILD ENV you have ANDROID_NDK_PLATFORM=android-24 or later! ![image](https://user-images.githubusercontent.com/22449698/166630970-a776576f-c505-4265-b4c8-ffbe212c6745.png) @@ -625,7 +633,7 @@ Once the project is opened, build it from Xcode. - Make sure it's targeting **build** directory - If using Visual Studio Code to configure and build the project, check the template in the `docs` folder. -# 9. Auto Testing +# 9. Auto Testing You need to add cmake define `-DENABLE_TESTING=TRUE` on your cmake configure line. Also, you need to open Passbolt and check for password for user `test_mobileapp` on `app.dev.merginmaps.com`, @@ -641,4 +649,4 @@ TEST_API_PASSWORD= ``` Build binary, and you can run tests either with `ctest` or you can run individual tests by adding `--test` -e.g. ` ./MerginMaps --testMerginApi` \ No newline at end of file +e.g. ` ./MerginMaps --testMerginApi` From 3181c8f89e34148b62e3e90afda5cc921d527e72 Mon Sep 17 00:00:00 2001 From: Matej Bagar Date: Thu, 26 Mar 2026 12:42:31 +0100 Subject: [PATCH 09/12] Update version --- .zenodo.json | 4 ++-- CITATION.cff | 2 +- CMakeLists.txt | 4 ++-- vcpkg.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.zenodo.json b/.zenodo.json index 21c739cf3..176982a58 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -2,7 +2,7 @@ "description": "

Mergin Maps mobile app is a QGIS powered app for Android and iOS devices.

", "license": "GPLv3", "title": "Mergin Maps mobile app", - "version": "2026.1.2", + "version": "2026.2.0", "upload_type": "software", "publication_date": "2022-02-24", "creators": [ @@ -39,7 +39,7 @@ "related_identifiers": [ { "scheme": "url", - "identifier": "https://github.com/MerginMaps/mobile/tree/2026.1.2", + "identifier": "https://github.com/MerginMaps/mobile/tree/2026.2.0", "relation": "isSupplementTo" }, { diff --git a/CITATION.cff b/CITATION.cff index 742fc777b..632185ef2 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,4 +1,4 @@ -cff-version: 2026.1.2 +cff-version: 2026.2.0 message: "If you use this software, please cite it as below." authors: - family-names: "Martin" diff --git a/CMakeLists.txt b/CMakeLists.txt index f413ea798..b43b4e1e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,8 +6,8 @@ cmake_minimum_required(VERSION 3.22) # Note: To update version use script/update_all_versions.bash set(MM_VERSION_MAJOR "2026") -set(MM_VERSION_MINOR "1") -set(MM_VERSION_PATCH "2") +set(MM_VERSION_MINOR "2") +set(MM_VERSION_PATCH "0") if (VCPKG_TARGET_TRIPLET MATCHES ".*ios.*") set(IOS TRUE) diff --git a/vcpkg.json b/vcpkg.json index 86d08083b..ba8124308 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -9,7 +9,7 @@ }, "name": "merginmaps-mobile-app", "description": "Collect. Share. Publish.", - "version": "2026.1.2", + "version": "2026.2.0", "homepage": "https://github.com/merginmaps/mobile", "dependencies": [ { From 2d3227ecd460244d4b8275b047aa35e50840d630 Mon Sep 17 00:00:00 2001 From: Matej Bagar Date: Sun, 29 Mar 2026 14:55:13 +0200 Subject: [PATCH 10/12] Refactor some basic stuff --- CLAUDE.md | 137 ----------------------- app/activeproject.cpp | 7 ++ app/activeproject.h | 10 ++ app/filtercontroller.cpp | 20 ++-- app/filtercontroller.h | 6 +- app/main.cpp | 2 - app/qml/filters/MMFilterLayerSection.qml | 29 +++-- app/qml/filters/MMFiltersPanel.qml | 16 +-- 8 files changed, 48 insertions(+), 179 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a85881b21..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,137 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Mergin Maps Mobile is a cross-platform geospatial surveying application built with C++ and Qt 6/QML. It integrates with QGIS for project design and syncs data with Mergin Maps Cloud. Target platforms: Android, iOS, Linux, macOS, Windows. - -## Build System - -Uses CMake 3.22+ with vcpkg for dependency management. Dependencies (Qt, GDAL, QGIS) are built automatically during CMake configure. - -### Directory Layout Expected - -``` -mm1/ - build/ - vcpkg/ # clone from github.com/microsoft/vcpkg, checkout to VCPKG_BASELINE - mobile/ # this repository -``` - -### Build Commands (Linux/macOS) - -```bash -# Configure (first time takes ~1 hour for deps) -cmake \ - -DCMAKE_BUILD_TYPE=Debug \ - -DVCPKG_TARGET_TRIPLET=x64-linux \ # or arm64-osx for Apple Silicon - -DCMAKE_TOOLCHAIN_FILE=../vcpkg/scripts/buildsystems/vcpkg.cmake \ - -DUSE_MM_SERVER_API_KEY=FALSE \ - -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ - -DENABLE_TESTS=TRUE \ - -GNinja \ - -S ../mobile - -# Build -ninja - -# Run -./app/Input # Linux -./app/MerginMaps.app/Contents/MacOS/MerginMaps # macOS -``` - -### Running Tests - -```bash -# Enable tests in cmake with -DENABLE_TESTS=TRUE -# Set environment variables: -export TEST_MERGIN_URL=https://app.dev.merginmaps.com/ -export TEST_API_USERNAME= -export TEST_API_PASSWORD= - -# Run all tests -ctest - -# Run individual test -./MerginMaps --testMerginApi -``` - -### Code Formatting - -```bash -# C++ formatting (required, CI enforced) - uses astyle 3.4.13 -./scripts/format_cpp.bash - -# CMake formatting -./scripts/format_cmake.bash - -# Static analysis -./scripts/cppcheck.bash -``` - -## Code Architecture - -### Source Structure - -- `app/` - Main application: UI logic, QML components, platform-specific code - - `attributes/` - Form attribute handling - - `qml/` - All QML UI files (2-space indentation) - - `android/`, `ios/` - Platform-specific resources -- `core/` - Business logic, API client, project management - - `merginapi.cpp` - Mergin Maps cloud API client - - `localprojectsmanager.*` - Local project storage - - `project.*` - Project model -- `gallery/` - UI component development/demo app -- `test/` - Unit tests and test data - -### Key Classes - -- `MerginApi` - Cloud service API client -- `LocalProjectsManager` - Local project storage management -- `Project` - Project data model -- `GeodiffUtils` - Geographic diff operations - -### Architecture Pattern - -MVVM with Qt's model-view architecture. C++ handles business logic; QML handles UI. - -## Code Style - -### C++ - -Follow QGIS code style: https://docs.qgis.org/3.28/en/docs/developers_guide/codingstandards.html - -### QML - -Follow https://github.com/Furkanzmc/QML-Coding-Guide with 2-space indentation. - -Key rules: -- Root item `id` should be `root` -- Properties ordered: required props, regular props, signals, size/position, other props, attached props, states, signal handlers, visual children, non-visual children, functions -- Only one ternary per line; use if-else blocks for complex conditionals: -```qml -// Bad -width: hasFocus ? 100 : isVisible ? 40 : 10 - -// Good -width: { - if (hasFocus) return 100 - else if (isVisible) return 40 - return 10 -} -``` - -## Git Workflow - -Trunk-based development on `master`. Use `feature/`, `bugfix/`, `hotfix/` branch prefixes. - -PRs require clear descriptions, linked issues, and screenshots/videos for UI changes. C++ changes should include unit tests. - -## Secrets - -API keys are in `core/merginsecrets.cpp.enc`. Decrypt for development against production/dev servers: -```bash -cd core/ -openssl aes-256-cbc -d -in merginsecrets.cpp.enc -out merginsecrets.cpp -md md5 -``` diff --git a/app/activeproject.cpp b/app/activeproject.cpp index 6f1859b0c..041b790c7 100644 --- a/app/activeproject.cpp +++ b/app/activeproject.cpp @@ -75,6 +75,8 @@ ActiveProject::ActiveProject( AppSettings &appSettings setAutosyncEnabled( mAppSettings.autosyncAllowed() ); QObject::connect( &mAppSettings, &AppSettings::autosyncAllowedChanged, this, &ActiveProject::setAutosyncEnabled ); + + mFilterController = std::make_unique(); } ActiveProject::~ActiveProject() = default; @@ -668,3 +670,8 @@ bool ActiveProject::photoSketchingEnabled() const return mQgsProject->readBoolEntry( QStringLiteral( "Mergin" ), QStringLiteral( "PhotoSketching/Enabled" ), false ); } + +FilterController* ActiveProject::filterController() const +{ + return mFilterController.get(); +} diff --git a/app/activeproject.h b/app/activeproject.h index 92fee83b8..f15913df0 100644 --- a/app/activeproject.h +++ b/app/activeproject.h @@ -24,6 +24,7 @@ #include "inputmapsettings.h" #include "merginprojectmetadata.h" #include "synchronizationoptions.h" +#include "filtercontroller.h" /** * \brief The ActiveProject class can load a QGIS project and holds its data. @@ -34,6 +35,7 @@ class ActiveProject: public QObject Q_PROPERTY( LocalProject localProject READ localProject NOTIFY localProjectChanged ) // LocalProject instance of active project, changes when project is loaded Q_PROPERTY( QgsProject *qgsProject READ qgsProject NOTIFY qgsProjectChanged ) // QgsProject instance of active project, never changes Q_PROPERTY( AutosyncController *autosyncController READ autosyncController NOTIFY autosyncControllerChanged ) + Q_PROPERTY( FilterController *filterController READ filterController NOTIFY filterControllerChanged ) Q_PROPERTY( InputMapSettings *mapSettings READ mapSettings WRITE setMapSettings NOTIFY mapSettingsChanged ) Q_PROPERTY( QString projectRole READ projectRole WRITE setProjectRole NOTIFY projectRoleChanged ) @@ -149,6 +151,11 @@ class ActiveProject: public QObject */ bool photoSketchingEnabled() const; + /** + * Returns filterController, which loads any filters setup in QGIS plugin + */ + FilterController *filterController() const; + signals: void qgsProjectChanged(); void localProjectChanged( LocalProject project ); @@ -184,6 +191,8 @@ class ActiveProject: public QObject void appStateChanged( Qt::ApplicationState state ); + void filterControllerChanged( FilterController *controller ); + public slots: // Reloads project if current project path matches given path (it's the same project) bool reloadProject( QString projectDir ); @@ -225,6 +234,7 @@ class ActiveProject: public QObject LocalProjectsManager &mLocalProjectsManager; InputMapSettings *mMapSettings = nullptr; std::unique_ptr mAutosyncController; + std::unique_ptr mFilterController; QString mProjectLoadingLog; QString mProjectRole; diff --git a/app/filtercontroller.cpp b/app/filtercontroller.cpp index 237991341..e2bb14d7b 100644 --- a/app/filtercontroller.cpp +++ b/app/filtercontroller.cpp @@ -289,7 +289,7 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons { QStringList values = filter.value.toStringList(); if ( values.isEmpty() ) - return QString(); + return {}; QStringList quotedValues; for ( const QString &v : values ) @@ -306,7 +306,7 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons // Skip invalid range where from > to if ( hasFrom && hasTo && filter.value.toDouble() > filter.valueTo.toDouble() ) { - return QString(); + return {}; } QStringList conditions; @@ -329,7 +329,7 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons { QStringList values = filter.value.toStringList(); if ( values.isEmpty() ) - return QString(); + return {}; if ( values.size() == 1 ) { @@ -349,7 +349,7 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons // Use LIKE patterns to match any position within the braced list QStringList values = filter.value.toStringList(); if ( values.isEmpty() ) - return QString(); + return {}; QStringList keyConditions; for ( const QString &key : values ) @@ -392,13 +392,13 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons return conditions.join( QStringLiteral( " AND " ) ); } - return QString(); + return {}; } QString FilterController::generateFilterExpression( const QString &layerId ) const { if ( !mAppliedFilters.contains( layerId ) ) - return QString(); + return {}; // Use .value() to get a copy in const context QMap layerFilters = mAppliedFilters.value( layerId ); @@ -414,7 +414,7 @@ QString FilterController::generateFilterExpression( const QString &layerId ) con } if ( expressions.isEmpty() ) - return QString(); + return {}; return expressions.join( QStringLiteral( " AND " ) ); } @@ -643,11 +643,11 @@ void FilterController::setDropdownFilter( const QString &layerId, const QString QVariantList FilterController::getDropdownOptions( QgsVectorLayer *layer, const QString &fieldName, const QString &searchText, int limit ) { if ( !layer ) - return QVariantList(); + return {}; int fieldIndex = layer->fields().lookupField( fieldName ); if ( fieldIndex < 0 ) - return QVariantList(); + return {}; QgsEditorWidgetSetup widgetSetup = layer->editorWidgetSetup( fieldIndex ); QString widgetType = widgetSetup.type(); @@ -670,7 +670,7 @@ QVariantList FilterController::getDropdownOptions( QgsVectorLayer *layer, const return extractValueRelationOptions( config, searchText, limit, currentlySelectedKeys ); } - return QVariantList(); + return {}; } QVariantList FilterController::extractValueMapOptions( const QVariantMap &config, const QString &searchText ) const diff --git a/app/filtercontroller.h b/app/filtercontroller.h index 8dd21be06..3e7342ea1 100644 --- a/app/filtercontroller.h +++ b/app/filtercontroller.h @@ -10,7 +10,6 @@ #ifndef FILTERCONTROLLER_H #define FILTERCONTROLLER_H -#include #include #include @@ -37,12 +36,11 @@ struct FieldFilter bool isValid() const { // For range filters (number, date), either value or valueTo being valid is enough - bool hasValue = value.isValid() && !value.isNull(); - bool hasValueTo = valueTo.isValid() && !valueTo.isNull(); + const bool hasValue = value.isValid() && !value.isNull(); + const bool hasValueTo = valueTo.isValid() && !valueTo.isNull(); return !fieldName.isEmpty() && ( hasValue || hasValueTo ); } }; -Q_DECLARE_METATYPE( FieldFilter ) /** diff --git a/app/main.cpp b/app/main.cpp index 3934d4834..e91cb18ec 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -92,7 +92,6 @@ #include "scalebarkit.h" #include "featuresmodel.h" #include "staticfeaturesmodel.h" -#include "filtercontroller.h" #include "layerfeaturesmodel.h" #include "relationfeaturesmodel.h" #include "relationreferencefeaturesmodel.h" @@ -331,7 +330,6 @@ void initDeclarative() qmlRegisterType< MapThemesModel >( "mm", 1, 0, "MapThemesModel" ); qmlRegisterType< GuidelineController >( "mm", 1, 0, "GuidelineController" ); qmlRegisterType< FeaturesModel >( "mm", 1, 0, "FeaturesModel" ); - qmlRegisterType< FilterController >( "mm", 1, 0, "FilterController" ); qmlRegisterType< StaticFeaturesModel >( "mm", 1, 0, "StaticFeaturesModel" ); qmlRegisterType< LayerFeaturesModel >( "mm", 1, 0, "LayerFeaturesModel" ); qmlRegisterType< RelationFeaturesModel >( "mm", 1, 0, "RelationFeaturesModel" ); diff --git a/app/qml/filters/MMFilterLayerSection.qml b/app/qml/filters/MMFilterLayerSection.qml index b1c948aab..23d42e859 100644 --- a/app/qml/filters/MMFilterLayerSection.qml +++ b/app/qml/filters/MMFilterLayerSection.qml @@ -11,8 +11,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import mm 1.0 as MM - import "../components" import "../components/private" as MMPrivateComponents import "../inputs" @@ -23,7 +21,6 @@ Column { required property string layerId required property string layerName - required property var filterController required property var vectorLayer spacing: __style.margin12 @@ -50,7 +47,7 @@ Column { Repeater { id: fieldsRepeater - model: root.vectorLayer ? root.filterController.getFilterableFields(root.vectorLayer) : [] + model: root.vectorLayer ? __activeProject.filterController.getFilterableFields(root.vectorLayer) : [] delegate: Column { id: fieldDelegate @@ -102,7 +99,7 @@ Column { onTextChanged: { if (!initialized || !toNumberInput.initialized) return - root.filterController.setNumberFilter(root.layerId, fieldDelegate.fieldName, text, toNumberInput.text) + __activeProject.filterController.setNumberFilter(root.layerId, fieldDelegate.fieldName, text, toNumberInput.text) } } @@ -117,7 +114,7 @@ Column { onTextChanged: { if (!initialized || !fromNumberInput.initialized) return - root.filterController.setNumberFilter(root.layerId, fieldDelegate.fieldName, fromNumberInput.text, text) + __activeProject.filterController.setNumberFilter(root.layerId, fieldDelegate.fieldName, fromNumberInput.text, text) } } } @@ -183,7 +180,7 @@ Column { if (fromDateInput.selectedDate) { fromDateInput.selectedDate = null let toDate = toDateInput.selectedDate ? toDateInput.selectedDate : null - root.filterController.setDateFilter(root.layerId, fieldDelegate.fieldName, null, toDate, fieldDelegate.hasTime) + __activeProject.filterController.setDateFilter(root.layerId, fieldDelegate.fieldName, null, toDate, fieldDelegate.hasTime) } else { fromCalendarLoader.active = true @@ -208,7 +205,7 @@ Column { onPrimaryButtonClicked: { fromDateInput.selectedDate = dateTime let toDate = toDateInput.selectedDate ? toDateInput.selectedDate : null - root.filterController.setDateFilter(root.layerId, fieldDelegate.fieldName, dateTime, toDate, fieldDelegate.hasTime) + __activeProject.filterController.setDateFilter(root.layerId, fieldDelegate.fieldName, dateTime, toDate, fieldDelegate.hasTime) } onClosed: fromCalendarLoader.active = false @@ -255,7 +252,7 @@ Column { if (toDateInput.selectedDate) { toDateInput.selectedDate = null let fromDate = fromDateInput.selectedDate ? fromDateInput.selectedDate : null - root.filterController.setDateFilter(root.layerId, fieldDelegate.fieldName, fromDate, null, fieldDelegate.hasTime) + __activeProject.filterController.setDateFilter(root.layerId, fieldDelegate.fieldName, fromDate, null, fieldDelegate.hasTime) } else { toCalendarLoader.active = true @@ -280,7 +277,7 @@ Column { onPrimaryButtonClicked: { toDateInput.selectedDate = dateTime let fromDate = fromDateInput.selectedDate ? fromDateInput.selectedDate : null - root.filterController.setDateFilter(root.layerId, fieldDelegate.fieldName, fromDate, dateTime, fieldDelegate.hasTime) + __activeProject.filterController.setDateFilter(root.layerId, fieldDelegate.fieldName, fromDate, dateTime, fieldDelegate.hasTime) } onClosed: toCalendarLoader.active = false @@ -316,7 +313,7 @@ Column { onTextChanged: { if (!initialized) return // Pass raw text to C++ - validation happens there - root.filterController.setTextFilter(root.layerId, fieldDelegate.fieldName, text) + __activeProject.filterController.setTextFilter(root.layerId, fieldDelegate.fieldName, text) } } @@ -360,9 +357,9 @@ Column { onTextClicked: dropdownDrawerLoader.active = true onRightContentClicked: { if (dropdownInput.text !== "") { - root.filterController.setDropdownFilter(root.layerId, fieldDelegate.fieldName, [], fieldDelegate.multiSelect) + __activeProject.filterController.setDropdownFilter(root.layerId, fieldDelegate.fieldName, [], fieldDelegate.multiSelect) // Refresh the fields model to clear currentValueTexts - fieldsRepeater.model = root.vectorLayer ? root.filterController.getFilterableFields(root.vectorLayer) : [] + fieldsRepeater.model = root.vectorLayer ? __activeProject.filterController.getFilterableFields(root.vectorLayer) : [] } else { dropdownDrawerLoader.active = true @@ -395,9 +392,9 @@ Column { } onSelectionFinished: function(selectedItems) { - root.filterController.setDropdownFilter(root.layerId, fieldDelegate.fieldName, selectedItems, fieldDelegate.multiSelect) + __activeProject.filterController.setDropdownFilter(root.layerId, fieldDelegate.fieldName, selectedItems, fieldDelegate.multiSelect) // Refresh the fields model to update currentValueTexts - fieldsRepeater.model = root.vectorLayer ? root.filterController.getFilterableFields(root.vectorLayer) : [] + fieldsRepeater.model = root.vectorLayer ? __activeProject.filterController.getFilterableFields(root.vectorLayer) : [] close() } @@ -418,7 +415,7 @@ Column { } function populateOptions(searchText) { - let options = root.filterController.getDropdownOptions(root.vectorLayer, fieldDelegate.fieldName, searchText, 100) + let options = __activeProject.filterController.getDropdownOptions(root.vectorLayer, fieldDelegate.fieldName, searchText, 100) dropdownListModel.clear() for (let i = 0; i < options.length; i++) { dropdownListModel.append(options[i]) diff --git a/app/qml/filters/MMFiltersPanel.qml b/app/qml/filters/MMFiltersPanel.qml index d80055a92..3532398e9 100644 --- a/app/qml/filters/MMFiltersPanel.qml +++ b/app/qml/filters/MMFiltersPanel.qml @@ -10,15 +10,11 @@ import QtQuick import QtQuick.Controls -import mm 1.0 as MM - import "../components" as MMComponents MMComponents.MMDrawer { id: root - required property var filterController - modal: false interactive: false closePolicy: Popup.CloseOnEscape @@ -50,7 +46,7 @@ MMComponents.MMDrawer { onClosed: { if ( !filtersApplied ) { // User closed without pressing "Show results" - revert pending changes - filterController.discardPendingChanges() + __activeProject.filterController.discardPendingChanges() } filtersApplied = false } @@ -64,7 +60,7 @@ MMComponents.MMDrawer { // Clear first to force UI rebuild vectorLayers = [] // Use FilterController to get vector layers - vectorLayers = filterController.getVectorLayers() + vectorLayers = __activeProject.filterController.getVectorLayers() } } @@ -74,7 +70,7 @@ MMComponents.MMDrawer { type: MMButton.Types.Tertiary text: qsTr("Reset") fontColor: __style.grapeColor - visible: filterController.hasActiveFilters + visible: __activeProject.filterController.hasActiveFilters anchors { left: parent.left @@ -83,7 +79,7 @@ MMComponents.MMDrawer { } onClicked: { - filterController.clearAllFilters() + __activeProject.filterController.clearAllFilters() filterController.applyFiltersToAllLayers() root.filtersApplied = true // Refresh the UI to clear input fields @@ -135,7 +131,7 @@ MMComponents.MMDrawer { layerId: model.layerId layerName: model.layerName - filterController: root.filterController + filterController: __activeProject.filterController vectorLayer: model.layer } } @@ -164,7 +160,7 @@ MMComponents.MMDrawer { text: qsTr("Show results") onClicked: { - filterController.applyFiltersToAllLayers() + __activeProject.filterController.applyFiltersToAllLayers() root.filtersApplied = true root.close() } From eea723e739aedc043d2084c7d5c70a2d3b0fffd1 Mon Sep 17 00:00:00 2001 From: Matej Bagar Date: Sun, 29 Mar 2026 15:03:43 +0200 Subject: [PATCH 11/12] Fix formatting --- app/activeproject.cpp | 2 +- app/filtercontroller.cpp | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/activeproject.cpp b/app/activeproject.cpp index 041b790c7..e929f12b2 100644 --- a/app/activeproject.cpp +++ b/app/activeproject.cpp @@ -671,7 +671,7 @@ bool ActiveProject::photoSketchingEnabled() const return mQgsProject->readBoolEntry( QStringLiteral( "Mergin" ), QStringLiteral( "PhotoSketching/Enabled" ), false ); } -FilterController* ActiveProject::filterController() const +FilterController *ActiveProject::filterController() const { return mFilterController.get(); } diff --git a/app/filtercontroller.cpp b/app/filtercontroller.cpp index e2bb14d7b..81fe79fd7 100644 --- a/app/filtercontroller.cpp +++ b/app/filtercontroller.cpp @@ -359,7 +359,7 @@ QString FilterController::buildFieldExpression( const FieldFilter &filter ) cons // Match all positions: only value {k}, first {k,...}, last ...,k}, middle ...,k,... keyConditions << QStringLiteral( "(%1 LIKE '{%2}' OR %1 LIKE '{%2,%%' OR %1 LIKE '%%,%2}' OR %1 LIKE '%%,%2,%%')" ) - .arg( quotedField, escapedKey ); + .arg( quotedField, escapedKey ); } return keyConditions.join( QStringLiteral( " OR " ) ); } @@ -726,7 +726,7 @@ QVariantList FilterController::extractValueRelationOptions( const QVariantMap &c QString escapedSearch = searchText; escapedSearch.replace( "'", "''" ); QString filterExpr = QStringLiteral( "LOWER(%1) LIKE '%%2%'" ) - .arg( QgsExpression::quotedColumnRef( valueFieldName ), escapedSearch.toLower() ); + .arg( QgsExpression::quotedColumnRef( valueFieldName ), escapedSearch.toLower() ); request.setFilterExpression( filterExpr ); } @@ -785,8 +785,8 @@ QVariantList FilterController::extractValueRelationOptions( const QVariantMap &c selectedRequest.setFlags( Qgis::FeatureRequestFlag::NoGeometry ); selectedRequest.setSubsetOfAttributes( QStringList( { keyFieldName, valueFieldName } ), referencedLayer->fields() ); selectedRequest.setFilterExpression( - QStringLiteral( "%1 IN (%2)" ).arg( QgsExpression::quotedColumnRef( keyFieldName ), quotedKeys.join( QStringLiteral( ", " ) ) ) - ); + QStringLiteral( "%1 IN (%2)" ).arg( QgsExpression::quotedColumnRef( keyFieldName ), quotedKeys.join( QStringLiteral( ", " ) ) ) + ); QVariantList selectedItems; QgsFeatureIterator selIt = referencedLayer->getFeatures( selectedRequest ); @@ -856,8 +856,8 @@ QStringList FilterController::lookupValueRelationTexts( const QVariantMap &confi request.setFlags( Qgis::FeatureRequestFlag::NoGeometry ); request.setSubsetOfAttributes( QStringList( { keyFieldName, valueFieldName } ), referencedLayer->fields() ); request.setFilterExpression( - QStringLiteral( "%1 IN (%2)" ).arg( QgsExpression::quotedColumnRef( keyFieldName ), quotedKeys.join( QStringLiteral( ", " ) ) ) - ); + QStringLiteral( "%1 IN (%2)" ).arg( QgsExpression::quotedColumnRef( keyFieldName ), quotedKeys.join( QStringLiteral( ", " ) ) ) + ); QgsFeatureIterator it = referencedLayer->getFeatures( request ); QgsFeature feature; From be9f1bdd260ca2aea51806f50fb9770525ab2df7 Mon Sep 17 00:00:00 2001 From: Matej Bagar Date: Tue, 31 Mar 2026 13:54:55 +0200 Subject: [PATCH 12/12] Update app/qml/filters/MMFilterLayerSection.qml Co-authored-by: Gabriel Bolbotina <80618569+gabriel-bolbotina@users.noreply.github.com> --- app/qml/filters/MMFilterLayerSection.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/qml/filters/MMFilterLayerSection.qml b/app/qml/filters/MMFilterLayerSection.qml index 23d42e859..9c5f5d0f4 100644 --- a/app/qml/filters/MMFilterLayerSection.qml +++ b/app/qml/filters/MMFilterLayerSection.qml @@ -357,7 +357,7 @@ Column { onTextClicked: dropdownDrawerLoader.active = true onRightContentClicked: { if (dropdownInput.text !== "") { - __activeProject.filterController.setDropdownFilter(root.layerId, fieldDelegate.fieldName, [], fieldDelegate.multiSelect) + __activeProject.filterController.setDropdownFilter(root.layerId, fieldDelegate.fieldName, [], fieldDelegate.multiSelect) // Refresh the fields model to clear currentValueTexts fieldsRepeater.model = root.vectorLayer ? __activeProject.filterController.getFilterableFields(root.vectorLayer) : [] }