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 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 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/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/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/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` diff --git a/app/activeproject.cpp b/app/activeproject.cpp index 6f1859b0c..e929f12b2 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..81fe79fd7 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 ) @@ -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 " ) ); } @@ -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 @@ -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; 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/i18n/input_ca.qm b/app/i18n/input_ca.qm index 8b3681222..5279f80f7 100644 Binary files a/app/i18n/input_ca.qm and b/app/i18n/input_ca.qm differ diff --git a/app/i18n/input_en.qm b/app/i18n/input_en.qm index 61969a250..666e47cd0 100644 Binary files a/app/i18n/input_en.qm and b/app/i18n/input_en.qm differ diff --git a/app/i18n/input_et.qm b/app/i18n/input_et.qm index 45c1cc3c7..c537a2535 100644 Binary files a/app/i18n/input_et.qm and b/app/i18n/input_et.qm differ diff --git a/app/i18n/input_et_EE.qm b/app/i18n/input_et_EE.qm index bf5eb8547..dd682896a 100644 Binary files a/app/i18n/input_et_EE.qm and b/app/i18n/input_et_EE.qm differ diff --git a/app/i18n/input_fi.qm b/app/i18n/input_fi.qm index 6e7e0e13a..30fff2e1c 100644 Binary files a/app/i18n/input_fi.qm and b/app/i18n/input_fi.qm differ diff --git a/app/i18n/input_fi_FI.qm b/app/i18n/input_fi_FI.qm index ec6651172..1a106a023 100644 Binary files a/app/i18n/input_fi_FI.qm and b/app/i18n/input_fi_FI.qm differ diff --git a/app/i18n/input_hu.qm b/app/i18n/input_hu.qm index 5163085f2..51da9c8d1 100644 Binary files a/app/i18n/input_hu.qm and b/app/i18n/input_hu.qm differ diff --git a/app/i18n/input_sk.qm b/app/i18n/input_sk.qm index 97a8a0115..c1108b1ce 100644 Binary files a/app/i18n/input_sk.qm and b/app/i18n/input_sk.qm differ diff --git a/app/i18n/input_sk_SK.qm b/app/i18n/input_sk_SK.qm index 95368c812..ef723a1e0 100644 Binary files a/app/i18n/input_sk_SK.qm and b/app/i18n/input_sk_SK.qm differ 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..9c5f5d0f4 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() } 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() ) ) { 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() } } 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": [ { 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..0f1149cf5 --- /dev/null +++ b/vcpkg/ports/qgis/qgis4-project-properties.patch @@ -0,0 +1,236 @@ +diff --git a/src/core/project/qgsproject.cpp b/src/core/project/qgsproject.cpp +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 ) + // 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; + } + +@@ -1311,20 +1296,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 + * +@@ -3967,10 +3952,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..7691c1b5d53 100644 +--- a/src/core/project/qgsprojectproperty.cpp ++++ b/src/core/project/qgsprojectproperty.cpp +@@ -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 ) ); + } + } + +diff --git a/tests/src/python/test_qgsproject.py b/tests/src/python/test_qgsproject.py +index 4da8f330941..752110de78a 100644 +--- a/tests/src/python/test_qgsproject.py ++++ b/tests/src/python/test_qgsproject.py +@@ -63,84 +63,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..6d1854015 --- /dev/null +++ b/vcpkg/ports/qgis/qgis4_url_encoding.patch @@ -0,0 +1,785 @@ +diff --git a/src/core/network/qgshttpheaders.cpp b/src/core/network/qgshttpheaders.cpp +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 + { + 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 689690e4003..aaef521e652 100644 +--- a/src/core/qgsdatasourceuri.cpp ++++ b/src/core/qgsdatasourceuri.cpp +@@ -711,7 +711,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 e1cfb604055..abd3c19a0fc 100644 +--- a/src/core/qgsmaplayer.cpp ++++ b/src/core/qgsmaplayer.cpp +@@ -3277,8 +3277,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/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 856d5077c15..401e58747d8 100644 +--- a/tests/src/app/testqgsidentify.cpp ++++ b/tests/src/app/testqgsidentify.cpp +@@ -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" ) ); +- 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 409b059b488..436216ede80 100644 +--- a/tests/src/core/testqgsdatasourceuri.cpp ++++ b/tests/src/core/testqgsdatasourceuri.cpp +@@ -37,6 +37,7 @@ class TestQgsDataSourceUri : public QObject + void checkParameterKeys(); + void checkRemovePassword(); + void checkUnicodeUri(); ++ void checkUriInUri(); + }; + + void TestQgsDataSourceUri::checkparser_data() +@@ -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" ) ); +- 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" ) ); +@@ -611,6 +612,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 9c2df3cc20e..78bc5f8be81 100644 +--- a/tests/src/core/testqgshttpheaders.cpp ++++ b/tests/src/core/testqgshttpheaders.cpp +@@ -147,11 +147,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'" ); +@@ -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" ); +- 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 47e6f2d5a6e..e9d527b186f 100644 +--- a/tests/src/core/testqgsmaplayer.cpp ++++ b/tests/src/core/testqgsmaplayer.cpp +@@ -32,6 +32,7 @@ + #include "qgsmaplayerstore.h" + #include "qgsproject.h" + #include "qgsxmlutils.h" ++#include "qgsvectortilelayer.h" + + /** + * \ingroup UnitTests +@@ -54,6 +55,8 @@ class TestQgsMapLayer : public QObject + void testId(); + void formatName(); + ++ void generalHtmlMetadata(); ++ + void setBlendMode(); + + void isInScaleRange_data(); +@@ -150,6 +153,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 4a5f82f0b0d..99c0b503c30 100644 +--- a/tests/src/core/testqgsvectortilelayer.cpp ++++ b/tests/src/core/testqgsvectortilelayer.cpp +@@ -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 ++ 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 ) ); +@@ -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" ) ); +- 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() ); + +@@ -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" ) ); +- 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 ) ); +@@ -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" ) ); +- 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() ); + +@@ -328,8 +331,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() ); +@@ -337,7 +341,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 ); +@@ -377,15 +381,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 +@@ -421,7 +425,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 ); + } + +@@ -443,23 +448,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 ) ); +@@ -485,7 +490,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() ); +@@ -493,7 +500,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 d736bfcc38f..3cbaf2578fd 100644 +--- a/tests/src/providers/testqgswmsprovider.cpp ++++ b/tests/src/providers/testqgswmsprovider.cpp +@@ -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 ); + +- 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_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 ++++ 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()