From c24b0acdacf08ff8780ad5bc90cfd8032b0b0734 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 22 Jul 2026 10:48:46 +0300 Subject: [PATCH 01/15] Add usage report opt-out toggle to AppSettings Add usageReportEnabled property to AppSettings with QSettings persistence under usage_report/enabled. On opt-out, all accumulated usage_report/* data is cleared but the enabled flag is preserved. Wire the toggle to the settings page UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/appsettings.cpp | 35 +++++++++++++++++++++++++++++ app/appsettings.h | 7 ++++++ app/qml/settings/MMSettingsPage.qml | 11 +++++++++ 3 files changed, 53 insertions(+) diff --git a/app/appsettings.cpp b/app/appsettings.cpp index e05f3214f..0198c73a1 100644 --- a/app/appsettings.cpp +++ b/app/appsettings.cpp @@ -17,6 +17,12 @@ const QString AppSettings::POSITION_PROVIDERS_GROUP = QStringLiteral( "inputApp/ AppSettings::AppSettings( QObject *parent ): QObject( parent ) { + // Usage report settings live outside the app group + { + QSettings settings; + mUsageReportEnabled = settings.value( QStringLiteral( "usage_report/enabled" ), true ).toBool(); + } + QSettings settings; settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); const QString path = settings.value( QStringLiteral( "defaultProject" ), "" ).toString(); @@ -391,4 +397,33 @@ void AppSettings::setWindowPosition( const QList &newWindowPosition ) setValue( QStringLiteral( "windowPosition" ), QVariant::fromValue( newWindowPosition ) ); emit windowPositionChanged(); +} + +bool AppSettings::usageReportEnabled() const +{ + return mUsageReportEnabled; +} + +void AppSettings::setUsageReportEnabled( bool enabled ) +{ + if ( mUsageReportEnabled == enabled ) + return; + + mUsageReportEnabled = enabled; + + QSettings settings; + if ( !mUsageReportEnabled ) + { + // Opt-out: clear all accumulated data but keep the enabled flag + settings.beginGroup( QStringLiteral( "usage_report" ) ); + settings.remove( QString() ); + settings.endGroup(); + settings.setValue( QStringLiteral( "usage_report/enabled" ), false ); + } + else + { + settings.setValue( QStringLiteral( "usage_report/enabled" ), true ); + } + + emit usageReportEnabledChanged( mUsageReportEnabled ); } \ No newline at end of file diff --git a/app/appsettings.h b/app/appsettings.h index 2812a601f..0a2cee6f7 100644 --- a/app/appsettings.h +++ b/app/appsettings.h @@ -39,6 +39,7 @@ class AppSettings: public QObject Q_PROPERTY( QList windowPosition READ windowPosition WRITE setWindowPosition NOTIFY windowPositionChanged ) Q_PROPERTY( HapticsType hapticsType READ hapticsType WRITE setHapticsType NOTIFY hapticsTypeChanged ) Q_PROPERTY( StartupBehavior startupBehavior READ startupBehavior WRITE setStartupBehavior NOTIFY startupBehaviorChanged ) + Q_PROPERTY( bool usageReportEnabled READ usageReportEnabled WRITE setUsageReportEnabled NOTIFY usageReportEnabledChanged ) public: // enum of haptic modes we support @@ -122,6 +123,9 @@ class AppSettings: public QObject StartupBehavior startupBehavior() const; void setStartupBehavior( StartupBehavior startupBehavior ); + bool usageReportEnabled() const; + void setUsageReportEnabled( bool enabled ); + public slots: void setReuseLastEnteredValues( bool reuseLastEnteredValues ); @@ -147,6 +151,8 @@ class AppSettings: public QObject void windowPositionChanged(); + void usageReportEnabledChanged( bool enabled ); + private: // Projects path QString mDefaultProject; @@ -183,6 +189,7 @@ class AppSettings: public QObject HapticsType mHapticsType; StartupBehavior mStartupBehavior; + bool mUsageReportEnabled = true; }; #endif // APPSETTINGS_H diff --git a/app/qml/settings/MMSettingsPage.qml b/app/qml/settings/MMSettingsPage.qml index 833405f51..a7e1158d0 100644 --- a/app/qml/settings/MMSettingsPage.qml +++ b/app/qml/settings/MMSettingsPage.qml @@ -290,6 +290,17 @@ MMPage { MMLine {} + MMSettingsComponents.MMSettingsSwitch { + width: parent.width + title: qsTr("Send anonymous usage data") + description: qsTr("Helps us understand which features are used so we can improve the app. No personal data is collected.") + checked: AppSettings.usageReportEnabled + + onClicked: AppSettings.usageReportEnabled = !checked + } + + MMLine {} + MMSettingsComponents.MMSettingsItem { width: parent.width title: qsTr("Diagnostic log") From 81f531a954cb7383a7d3d62dd856285678f77d50 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 22 Jul 2026 11:21:04 +0300 Subject: [PATCH 02/15] Add usage snapshot infrastructure to main.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add USAGE_REPORT_KEY CMake variable and mmconfig.h.in define. Add trySubmitUsageSnapshot() function in main.cpp that: - checks if a weekly snapshot is due - collects static data (device, app, server info) - merges accumulated dynamic data from QSettings - sends a single HTTP POST to the EU endpoint - resets dynamic data on success, silently ignores errors Called once after app initialization. No signal connections yet — dynamic data accumulation will be wired in the next commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- CMakeLists.txt | 5 ++ app/main.cpp | 135 ++++++++++++++++++++++++++++++++++ cmake_templates/mmconfig.h.in | 2 + 3 files changed, 142 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index c9ab2ff00..9b70b6aa0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,6 +143,11 @@ set(HAVE_BLUETOOTH CACHE BOOL "Building with bluetooth position provider" ) +set(USAGE_REPORT_KEY + "" + CACHE STRING "API key for anonymous usage reporting" +) + set(USE_KEYCHAIN FALSE CACHE diff --git a/app/main.cpp b/app/main.cpp index 81edbcbec..e12013dc7 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -9,8 +9,14 @@ #include "mmconfig.h" +#include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -387,6 +393,131 @@ void addQmlImportPath( QQmlEngine &engine ) #endif } +#ifndef USAGE_REPORT_KEY +#define USAGE_REPORT_KEY "" +#endif + +static const QString USAGE_REPORT_API_KEY = QStringLiteral( USAGE_REPORT_KEY ); +static const int USAGE_REPORT_INTERVAL_SECS = 7 * 24 * 3600; // 1 week + +/** + * Attempt to send a weekly usage snapshot if one is due. + * Collects static device/app data, merges accumulated dynamic data from + * QSettings, and sends a single HTTP POST. On success, dynamic data is + * reset and last_reported_at is updated. On failure, silently ignored. + */ +static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, + LocalProjectsManager &localProjectsManager, MerginApi *merginApi ) +{ + if ( !as->usageReportEnabled() || USAGE_REPORT_API_KEY.isEmpty() ) + return; + + QSettings settings; + const QDateTime lastReported = settings.value( QStringLiteral( "usage_report/last_reported_at" ) ).toDateTime(); + const QDateTime now = QDateTime::currentDateTimeUtc(); + + if ( lastReported.isValid() && lastReported.secsTo( now ) < USAGE_REPORT_INTERVAL_SECS ) + return; + + // Ensure device UUID exists + QString deviceUuid = settings.value( QStringLiteral( "usage_report/device_uuid" ) ).toString(); + if ( deviceUuid.isEmpty() ) + { + deviceUuid = CoreUtils::deviceUuid(); + settings.setValue( QStringLiteral( "usage_report/device_uuid" ), deviceUuid ); + } + + // Collect static data + QVariantMap properties; + properties.insert( QStringLiteral( "app_language" ), QLocale().name() ); + properties.insert( QStringLiteral( "system_language" ), QLocale::system().name() ); + properties.insert( QStringLiteral( "device_manufacturer" ), InputUtils::getManufacturer() ); + properties.insert( QStringLiteral( "device_model" ), InputUtils::getDeviceModel() ); + properties.insert( QStringLiteral( "app_version" ), CoreUtils::appVersion() ); + properties.insert( QStringLiteral( "platform" ), InputUtils::appPlatform() ); + properties.insert( QStringLiteral( "os_version" ), QSysInfo::productVersion() ); + properties.insert( QStringLiteral( "project_count" ), localProjectsManager.projects().count() ); + properties.insert( QStringLiteral( "autosync_enabled" ), as->autosyncAllowed() ); + + // External provider count + int externalProviderCount = 0; + const QVariantList providers = as->savedPositionProviders(); + for ( const QVariant &v : providers ) + { + const QStringList p = v.toStringList(); + if ( p.size() >= 3 && p[2] != QLatin1String( "internal" ) && p[2] != QLatin1String( "simulated" ) ) + externalProviderCount++; + } + properties.insert( QStringLiteral( "num_external_providers" ), externalProviderCount ); + + // Server info + const auto serverType = static_cast( merginApi->serverType() ); + QString serverTypeStr; + switch ( serverType ) + { + case MerginServerType::SAAS: serverTypeStr = QStringLiteral( "saas" ); break; + case MerginServerType::EE: serverTypeStr = QStringLiteral( "ee" ); break; + case MerginServerType::CE: serverTypeStr = QStringLiteral( "ce" ); break; + default: serverTypeStr = QStringLiteral( "old" ); break; + } + properties.insert( QStringLiteral( "server_type" ), serverTypeStr ); + properties.insert( QStringLiteral( "server_version" ), merginApi->apiVersion() ); + if ( merginApi->subscriptionInfo() ) + properties.insert( QStringLiteral( "plan_name" ), merginApi->subscriptionInfo()->planAlias() ); + + // Merge accumulated dynamic data + settings.beginGroup( QStringLiteral( "usage_report/data" ) ); + const QStringList keys = settings.childKeys(); + for ( const QString &key : keys ) + properties.insert( key, settings.value( key ) ); + settings.endGroup(); + + // Compute average project load time from running totals + const qint64 totalMs = properties.value( QStringLiteral( "total_load_time_ms" ), 0 ).toLongLong(); + const int loadCount = properties.value( QStringLiteral( "load_count" ), 0 ).toInt(); + if ( loadCount > 0 ) + properties.insert( QStringLiteral( "avg_project_load_time_ms" ), totalMs / loadCount ); + properties.remove( QStringLiteral( "total_load_time_ms" ) ); + properties.remove( QStringLiteral( "load_count" ) ); + + // Suppress IP collection + properties.insert( QStringLiteral( "$ip" ), QString() ); + + // Last reported at + properties.insert( QStringLiteral( "last_reported_at" ), lastReported.isValid() ? lastReported.toString( Qt::ISODate ) : QString() ); + + const QJsonObject body + { + { QStringLiteral( "api_key" ), USAGE_REPORT_API_KEY }, + { QStringLiteral( "event" ), QStringLiteral( "usage_snapshot" ) }, + { QStringLiteral( "distinct_id" ), deviceUuid }, + { QStringLiteral( "timestamp" ), now.toString( Qt::ISODate ) }, + { QStringLiteral( "properties" ), QJsonObject::fromVariantMap( properties ) } + }; + + QUrl url( QStringLiteral( "https://eu.i.posthog.com/capture/" ) ); + QNetworkRequest request( url ); + request.setHeader( QNetworkRequest::ContentTypeHeader, QStringLiteral( "application/json" ) ); + request.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); + + QNetworkReply *reply = nam->post( request, QJsonDocument( body ).toJson( QJsonDocument::Compact ) ); + QObject::connect( reply, &QNetworkReply::finished, reply, [reply]() + { + if ( reply->error() == QNetworkReply::NoError ) + { + QSettings s; + // Reset dynamic data + s.beginGroup( QStringLiteral( "usage_report/data" ) ); + s.remove( QString() ); + s.endGroup(); + // Update last reported + s.setValue( QStringLiteral( "usage_report/last_reported_at" ), QDateTime::currentDateTimeUtc() ); + } + // On network error: silently ignore — data preserved for next attempt + reply->deleteLater(); + } ); +} + int main( int argc, char *argv[] ) { QgsApplication app( argc, argv, true ); @@ -529,6 +660,7 @@ int main( int argc, char *argv[] ) vm->registerInputExpressionFunctions(); SynchronizationManager syncManager( ma.get() ); + QNetworkAccessManager usageReportNam; LayerTreeModelPixmapProvider *layerTreeModelPixmapProvider( new LayerTreeModelPixmapProvider ); LayerTreeFlatModelPixmapProvider *layerTreeFlatModelPixmapProvider( new LayerTreeFlatModelPixmapProvider ); @@ -830,6 +962,9 @@ int main( int argc, char *argv[] ) QQmlComponent component( &engine, QUrl( "qrc:/com.merginmaps/imports/MMInput/main.qml" ) ); QObject *object = component.create(); + // Usage reporting: attempt weekly snapshot + trySubmitUsageSnapshot( &usageReportNam, as, localProjectsManager, ma.get() ); + if ( !component.errors().isEmpty() ) { qDebug( "%s", QgsApplication::showSettings().toLocal8Bit().data() ); diff --git a/cmake_templates/mmconfig.h.in b/cmake_templates/mmconfig.h.in index 181bfc603..f5dc4f290 100644 --- a/cmake_templates/mmconfig.h.in +++ b/cmake_templates/mmconfig.h.in @@ -13,5 +13,7 @@ #cmakedefine HAVE_BLUETOOTH +#cmakedefine USAGE_REPORT_KEY "@USAGE_REPORT_KEY@" + #endif From 9b9ef267b0759c98f97d6f57b9d04d96bf49f937 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 22 Jul 2026 11:45:37 +0300 Subject: [PATCH 03/15] Connect C++ signals for usage data accumulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire feature class signals in main.cpp to lambdas that write usage data to QSettings under usage_report/data/*. Signals connected: - FilterController::hasFiltersActivatedChanged → filtering - AutosyncController::projectSyncRequested → autosync - ProjectWizard::projectCreated → created_project - AndroidUtils/IosUtils::photoCaptured/photoFromGallery → image counts - MerginUserInfo::activeWorkspaceChanged → workspace_switches - PositionKit::positionProviderChanged → external GPS tracking - ActiveProject::projectRoleChanged → highest_role - ActiveProject::loadingStarted/projectReloaded → project load time New signals added: - MapSketchingController::sketched() for map sketching detection - AndroidUtils/IosUtils::photoCaptured()/photoFromGallery() to distinguish camera capture from gallery attachment Connectivity ping timer: HEAD request every 5 minutes, counts success/failure in QSettings. Note: map measuring, map sketching, photo sketching, reuse last value, and bulk editing are QML-instantiated objects — these will be tracked via AppSettings helpers in the next commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/androidutils.cpp | 2 + app/androidutils.h | 2 + app/ios/iosutils.cpp | 6 ++ app/ios/iosutils.h | 3 + app/main.cpp | 173 ++++++++++++++++++++++++++++++++- app/mapsketchingcontroller.cpp | 2 + app/mapsketchingcontroller.h | 1 + 7 files changed, 188 insertions(+), 1 deletion(-) diff --git a/app/androidutils.cpp b/app/androidutils.cpp index c6fe3c9e6..6e232c7de 100644 --- a/app/androidutils.cpp +++ b/app/androidutils.cpp @@ -386,6 +386,7 @@ void AndroidUtils::handleActivityResult( const int receiverRequestCode, const in QJniObject::fromString( mTargetPath ).object() ) .toString(); emit imageSelected( newUri, mLastCode ); + emit photoFromGallery(); } else if ( receiverRequestCode == CAMERA_CODE && resultCode == RESULT_OK ) { @@ -394,6 +395,7 @@ void AndroidUtils::handleActivityResult( const int receiverRequestCode, const in const QString absolutePath = absolutePathJNI.toString(); emit imageSelected( absolutePath, mLastCode ); + emit photoCaptured(); } else { diff --git a/app/androidutils.h b/app/androidutils.h index b98e7a44f..8cd9f4e74 100644 --- a/app/androidutils.h +++ b/app/androidutils.h @@ -81,6 +81,8 @@ class AndroidUtils: public QObject signals: void imageSelected( QString imagePath, QString code ); + void photoCaptured(); + void photoFromGallery(); void bluetoothEnabled( bool state ); void notifyInfo( const QString &msg ); void notifyError( const QString &msg ); diff --git a/app/ios/iosutils.cpp b/app/ios/iosutils.cpp index c2612978c..96ae1f76e 100644 --- a/app/ios/iosutils.cpp +++ b/app/ios/iosutils.cpp @@ -18,6 +18,10 @@ IosUtils::IosUtils( QObject *parent ): QObject( parent ) QObject::connect( mImagePicker, &IOSImagePicker::imageCaptured, this, [this]( const QString & absoluteImagePath ) { emit imageSelected( absoluteImagePath, mLastCode ); + if ( mLastSourceWasCamera ) + emit photoCaptured(); + else + emit photoFromGallery(); } ); QObject::connect( mImagePicker, &IOSImagePicker::notifyError, this, &IosUtils::notifyError ); } @@ -34,12 +38,14 @@ bool IosUtils::isIos() const void IosUtils::callImagePicker( const QString &targetPath, const QString &code ) { mLastCode = code; + mLastSourceWasCamera = false; mImagePicker->showImagePicker( targetPath ); } void IosUtils::callCamera( const QString &targetPath, const QString &code ) { mLastCode = code; + mLastSourceWasCamera = true; mImagePicker->callCamera( targetPath, mPositionKit, mCompass ); } diff --git a/app/ios/iosutils.h b/app/ios/iosutils.h index 1a6e06aa9..77f910462 100644 --- a/app/ios/iosutils.h +++ b/app/ios/iosutils.h @@ -51,6 +51,8 @@ class IosUtils: public QObject signals: void imageSelected( const QString &imagePath, const QString &code ); + void photoCaptured(); + void photoFromGallery(); void notifyError( const QString &message ); void positionKitChanged(); void compassChanged(); @@ -61,6 +63,7 @@ class IosUtils: public QObject Compass *mCompass = nullptr; QString mLastCode; + bool mLastSourceWasCamera = false; /** * Calls the objective-c function to disable idle timer to prevent screen from sleeping. */ diff --git a/app/main.cpp b/app/main.cpp index e12013dc7..bc4f4e676 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -10,7 +10,9 @@ #include "mmconfig.h" #include +#include #include +#include #include #include #include @@ -109,6 +111,8 @@ #include "mixedattributevalue.h" #include "photosketchingcontroller.h" #include "mapsketchingcontroller.h" +#include "filter/filtercontroller.h" +#include "autosynccontroller.h" #include "projectsmodel.h" #include "projectsproxymodel.h" @@ -765,9 +769,176 @@ int main( int argc, char *argv[] ) syncManager.syncProject( project, SyncOptions::Authorized, SyncOptions::Retry, requestOrigin ); } ); - QObject::connect( &activeProject, &ActiveProject::projectReloaded, &lambdaContext, [merginApi = ma.get(), &activeProject]() + // ── Usage reporting: accumulate dynamic data via signal connections ── + // Helper lambdas for writing to QSettings usage_report/data/* namespace. + // All connections guard on usageReportEnabled before writing. + auto trackFeature = [as]( const QString & key ) + { + if ( !as->usageReportEnabled() ) return; + QSettings().setValue( QStringLiteral( "usage_report/data/" ) + key, true ); + }; + + auto incrementCounter = [as]( const QString & key, int amount = 1 ) + { + if ( !as->usageReportEnabled() ) return; + QSettings s; + const QString fullKey = QStringLiteral( "usage_report/data/" ) + key; + s.setValue( fullKey, s.value( fullKey, 0 ).toInt() + amount ); + }; + + auto setUsageData = [as]( const QString & key, const QVariant & value ) + { + if ( !as->usageReportEnabled() ) return; + QSettings().setValue( QStringLiteral( "usage_report/data/" ) + key, value ); + }; + + // Filtering + QObject::connect( &activeProject, &ActiveProject::filterControllerChanged, &lambdaContext, [trackFeature]( FilterController * fc ) + { + if ( !fc ) return; + QObject::connect( fc, &FilterController::hasFiltersActivatedChanged, fc, [trackFeature]() + { + trackFeature( QStringLiteral( "filtering" ) ); + } ); + } ); + + // Auto-sync + QObject::connect( &activeProject, &ActiveProject::autosyncControllerChanged, &lambdaContext, [trackFeature]( AutosyncController * ac ) + { + if ( !ac ) return; + QObject::connect( ac, &AutosyncController::projectSyncRequested, ac, [trackFeature]( SyncOptions::RequestOrigin origin ) + { + if ( origin == SyncOptions::AutomaticRequest ) + trackFeature( QStringLiteral( "autosync" ) ); + } ); + } ); + + // Created project + QObject::connect( &pw, &ProjectWizard::projectCreated, &lambdaContext, [trackFeature]( const QString &, const QString & ) + { + trackFeature( QStringLiteral( "created_project" ) ); + } ); + + // Photo captured vs attached + QObject::connect( &androidUtils, &AndroidUtils::photoCaptured, &lambdaContext, [incrementCounter]() + { + incrementCounter( QStringLiteral( "captured_images" ) ); + } ); + QObject::connect( &androidUtils, &AndroidUtils::photoFromGallery, &lambdaContext, [incrementCounter]() + { + incrementCounter( QStringLiteral( "attached_images" ) ); + } ); + QObject::connect( &iosUtils, &IosUtils::photoCaptured, &lambdaContext, [incrementCounter]() + { + incrementCounter( QStringLiteral( "captured_images" ) ); + } ); + QObject::connect( &iosUtils, &IosUtils::photoFromGallery, &lambdaContext, [incrementCounter]() + { + incrementCounter( QStringLiteral( "attached_images" ) ); + } ); + + // Workspace switches + QObject::connect( ma->userInfo(), &MerginUserInfo::activeWorkspaceChanged, &lambdaContext, [incrementCounter]() + { + incrementCounter( QStringLiteral( "workspace_switches" ) ); + } ); + + // External GPS provider + QObject::connect( pk, &PositionKit::positionProviderChanged, &lambdaContext, [trackFeature, setUsageData]( AbstractPositionProvider * provider ) + { + if ( !provider ) return; + if ( provider->type() == QLatin1String( "internal" ) ) return; + + trackFeature( QStringLiteral( "external_gps" ) ); + + const QString id = provider->id(); + QString connectionType; + if ( id == QLatin1String( "simulated" ) ) + connectionType = QStringLiteral( "mock" ); + else if ( id.contains( QLatin1Char( ':' ) ) ) + connectionType = QStringLiteral( "bluetooth" ); + else + connectionType = QStringLiteral( "network" ); + + setUsageData( QStringLiteral( "external_connection_type" ), connectionType ); + setUsageData( QStringLiteral( "external_name" ), provider->name() ); + } ); + + // Highest project role + QObject::connect( &activeProject, &ActiveProject::projectRoleChanged, &lambdaContext, [setUsageData, &activeProject]() + { + const QString role = activeProject.projectRole(); + auto roleRank = []( const QString & r ) -> int + { + if ( r == QLatin1String( "owner" ) ) return 5; + if ( r == QLatin1String( "admin" ) ) return 4; + if ( r == QLatin1String( "writer" ) ) return 3; + if ( r == QLatin1String( "reader" ) ) return 2; + if ( r == QLatin1String( "guest" ) ) return 1; + return 0; + }; + + QSettings s; + const QString current = s.value( QStringLiteral( "usage_report/data/highest_role" ) ).toString(); + if ( roleRank( role ) > roleRank( current ) ) + setUsageData( QStringLiteral( "highest_role" ), role ); + } ); + + // Project load time + auto projectLoadTimer = std::make_shared(); + QObject::connect( &activeProject, &ActiveProject::loadingStarted, &lambdaContext, [projectLoadTimer]() + { + projectLoadTimer->start(); + } ); + + // Map sketching (via MapSketchingController — QML_ELEMENT, connect per instance) + // Connected alongside projectReloaded below since sketching controller is project-scoped. + + // Connectivity ping: HEAD request every 5 minutes + QTimer *pingTimer = new QTimer( &lambdaContext ); + QObject::connect( pingTimer, &QTimer::timeout, &lambdaContext, [&usageReportNam, merginApi = ma.get(), as, incrementCounter]() + { + if ( !as->usageReportEnabled() || merginApi->apiRoot().isEmpty() ) return; + + QUrl url( merginApi->apiRoot() ); + QNetworkRequest request( url ); + request.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); + + QNetworkReply *reply = usageReportNam.head( request ); + QObject::connect( reply, &QNetworkReply::finished, reply, [reply, incrementCounter]() + { + if ( reply->error() == QNetworkReply::NoError ) + incrementCounter( QStringLiteral( "ping_success_count" ) ); + else + incrementCounter( QStringLiteral( "ping_fail_count" ) ); + reply->deleteLater(); + } ); + } ); + pingTimer->start( 5 * 60 * 1000 ); // 5 minutes + + // Stop ping and clear data on opt-out + QObject::connect( as, &AppSettings::usageReportEnabledChanged, &lambdaContext, [pingTimer]( bool enabled ) + { + if ( !enabled ) + pingTimer->stop(); + else + pingTimer->start( 5 * 60 * 1000 ); + } ); + + QObject::connect( &activeProject, &ActiveProject::projectReloaded, &lambdaContext, [merginApi = ma.get(), &activeProject, incrementCounter, projectLoadTimer]() { merginApi->reloadProjectRole( activeProject.projectFullName() ); + + // Record project load time + if ( projectLoadTimer->isValid() ) + { + const qint64 elapsed = projectLoadTimer->elapsed(); + QSettings s; + const QString prefix = QStringLiteral( "usage_report/data/" ); + s.setValue( prefix + QStringLiteral( "total_load_time_ms" ), s.value( prefix + QStringLiteral( "total_load_time_ms" ), 0 ).toLongLong() + elapsed ); + s.setValue( prefix + QStringLiteral( "load_count" ), s.value( prefix + QStringLiteral( "load_count" ), 0 ).toInt() + 1 ); + projectLoadTimer->invalidate(); + } } ); QObject::connect( ma.get(), &MerginApi::authChanged, &lambdaContext, [merginApi = ma.get(), &activeProject]() diff --git a/app/mapsketchingcontroller.cpp b/app/mapsketchingcontroller.cpp index 288a1ef95..a01cd27c3 100644 --- a/app/mapsketchingcontroller.cpp +++ b/app/mapsketchingcontroller.cpp @@ -125,6 +125,8 @@ void MapSketchingController::finishDigitizing() mLayer->addFeature( feature ); mLayer->endEditCommand(); } + + emit sketched(); } void MapSketchingController::redo() const diff --git a/app/mapsketchingcontroller.h b/app/mapsketchingcontroller.h index ab0488571..93dc10391 100644 --- a/app/mapsketchingcontroller.h +++ b/app/mapsketchingcontroller.h @@ -43,6 +43,7 @@ class MapSketchingController : public QObject Q_INVOKABLE QStringList availableColors() const; signals: + void sketched(); void highlightGeometryChanged(); void activeColorChanged(); void mapSettingsChanged(); From 7bacbd9cee2450966efdfb272ce2b883f2db5158 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 22 Jul 2026 11:53:14 +0300 Subject: [PATCH 04/15] Track QML-initiated features via AppSettings helpers Add trackUsageFeature() and incrementUsageCounter() to AppSettings for features whose C++ controllers are QML-instantiated per-use objects with no persistent instance to connect to in main.cpp. Tracked from QML: - map_measuring (MMMapController) - map_sketching (MMMapController) - photo_sketching (MMFormPhotoSketchingPageDialog) - reuse_last_value (MMFormStackController, on form save) - bulk_editing (main.qml, on multi-edit with >1 feature) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/appsettings.cpp | 14 ++++++++++++++ app/appsettings.h | 3 +++ app/qml/form/MMFormStackController.qml | 2 ++ .../components/MMFormPhotoSketchingPageDialog.qml | 1 + app/qml/main.qml | 2 ++ app/qml/map/MMMapController.qml | 2 ++ 6 files changed, 24 insertions(+) diff --git a/app/appsettings.cpp b/app/appsettings.cpp index 0198c73a1..31e529e7c 100644 --- a/app/appsettings.cpp +++ b/app/appsettings.cpp @@ -426,4 +426,18 @@ void AppSettings::setUsageReportEnabled( bool enabled ) } emit usageReportEnabledChanged( mUsageReportEnabled ); +} + +void AppSettings::trackUsageFeature( const QString &key ) +{ + if ( !mUsageReportEnabled ) return; + QSettings().setValue( QStringLiteral( "usage_report/data/" ) + key, true ); +} + +void AppSettings::incrementUsageCounter( const QString &key ) +{ + if ( !mUsageReportEnabled ) return; + QSettings s; + const QString fullKey = QStringLiteral( "usage_report/data/" ) + key; + s.setValue( fullKey, s.value( fullKey, 0 ).toInt() + 1 ); } \ No newline at end of file diff --git a/app/appsettings.h b/app/appsettings.h index 0a2cee6f7..01954412b 100644 --- a/app/appsettings.h +++ b/app/appsettings.h @@ -126,6 +126,9 @@ class AppSettings: public QObject bool usageReportEnabled() const; void setUsageReportEnabled( bool enabled ); + Q_INVOKABLE void trackUsageFeature( const QString &key ); + Q_INVOKABLE void incrementUsageCounter( const QString &key ); + public slots: void setReuseLastEnteredValues( bool reuseLastEnteredValues ); diff --git a/app/qml/form/MMFormStackController.qml b/app/qml/form/MMFormStackController.qml index 134b796e1..a718bfe5c 100644 --- a/app/qml/form/MMFormStackController.qml +++ b/app/qml/form/MMFormStackController.qml @@ -305,6 +305,8 @@ Item { } onSaveRequested: { + if ( AppSettings.reuseLastEnteredValues ) + AppSettings.trackUsageFeature( "reuse_last_value" ) formsStack.syncWhenFormCloses = true } diff --git a/app/qml/form/components/MMFormPhotoSketchingPageDialog.qml b/app/qml/form/components/MMFormPhotoSketchingPageDialog.qml index e9678639d..73137194b 100644 --- a/app/qml/form/components/MMFormPhotoSketchingPageDialog.qml +++ b/app/qml/form/components/MMFormPhotoSketchingPageDialog.qml @@ -78,6 +78,7 @@ Dialog { bgndColor: __style.grassColor onClicked: { + AppSettings.trackUsageFeature( "photo_sketching" ) root.close() root.controller.backupSketches() } diff --git a/app/qml/main.qml b/app/qml/main.qml index 340f88280..3b31f5cce 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -703,6 +703,8 @@ ApplicationWindow { width: window.width onEditSelected: { + if ( selectedCount > 1 ) + AppSettings.trackUsageFeature( "bulk_editing" ) let pair = map.multiEditManager.editableFeature() formsStackManager.openForm( pair, selectedCount === 1 ? "edit" : "multiEdit", "form" ); multiSelectPanel.formOpened = true diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 10148c916..c1aebcb31 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -171,6 +171,7 @@ Item { case "measure": { root.showInfoTextMessage( qsTr( "Add points to measure distance, close the shape to measure area" ) ) root.hideHighlight() + AppSettings.trackUsageFeature( "map_measuring" ) root.measureStarted() break } @@ -182,6 +183,7 @@ Item { } case "sketch": { + AppSettings.trackUsageFeature( "map_sketching" ) root.showInfoTextMessage( qsTr( "Select a colour and start sketching on the map. Use two fingers to move or zoom the map." ) ) root.drawStarted() break From 7554c5b5975cfbf53923d37b6dc448e707a020ee Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 14 Aug 2026 11:49:07 +0300 Subject: [PATCH 05/15] Removed pothost references Added a temporary endpoint Remaned distinct_id with device_id --- CMakeLists.txt | 4 ---- app/main.cpp | 14 +++++--------- cmake_templates/mmconfig.h.in | 2 -- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b70b6aa0..38c2909ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,10 +143,6 @@ set(HAVE_BLUETOOTH CACHE BOOL "Building with bluetooth position provider" ) -set(USAGE_REPORT_KEY - "" - CACHE STRING "API key for anonymous usage reporting" -) set(USE_KEYCHAIN FALSE diff --git a/app/main.cpp b/app/main.cpp index bc4f4e676..0fb04f35c 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -397,11 +397,8 @@ void addQmlImportPath( QQmlEngine &engine ) #endif } -#ifndef USAGE_REPORT_KEY -#define USAGE_REPORT_KEY "" -#endif - -static const QString USAGE_REPORT_API_KEY = QStringLiteral( USAGE_REPORT_KEY ); +// TODO: update to the final production endpoint once it exists +static const QString USAGE_REPORT_ENDPOINT = QStringLiteral( "https://meta.merginmaps.com/analytics" ); static const int USAGE_REPORT_INTERVAL_SECS = 7 * 24 * 3600; // 1 week /** @@ -413,7 +410,7 @@ static const int USAGE_REPORT_INTERVAL_SECS = 7 * 24 * 3600; // 1 week static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, LocalProjectsManager &localProjectsManager, MerginApi *merginApi ) { - if ( !as->usageReportEnabled() || USAGE_REPORT_API_KEY.isEmpty() ) + if ( !as->usageReportEnabled() ) return; QSettings settings; @@ -492,14 +489,13 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, const QJsonObject body { - { QStringLiteral( "api_key" ), USAGE_REPORT_API_KEY }, { QStringLiteral( "event" ), QStringLiteral( "usage_snapshot" ) }, - { QStringLiteral( "distinct_id" ), deviceUuid }, + { QStringLiteral( "device_id" ), deviceUuid }, { QStringLiteral( "timestamp" ), now.toString( Qt::ISODate ) }, { QStringLiteral( "properties" ), QJsonObject::fromVariantMap( properties ) } }; - QUrl url( QStringLiteral( "https://eu.i.posthog.com/capture/" ) ); + QUrl url( USAGE_REPORT_ENDPOINT ); QNetworkRequest request( url ); request.setHeader( QNetworkRequest::ContentTypeHeader, QStringLiteral( "application/json" ) ); request.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); diff --git a/cmake_templates/mmconfig.h.in b/cmake_templates/mmconfig.h.in index f5dc4f290..181bfc603 100644 --- a/cmake_templates/mmconfig.h.in +++ b/cmake_templates/mmconfig.h.in @@ -13,7 +13,5 @@ #cmakedefine HAVE_BLUETOOTH -#cmakedefine USAGE_REPORT_KEY "@USAGE_REPORT_KEY@" - #endif From 1a7ad50e52e9eb72f9fc63f8101b97a354511e5e Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 14 Aug 2026 12:53:15 +0300 Subject: [PATCH 06/15] Removed space for cmake format --- CMakeLists.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 38c2909ad..c9ab2ff00 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,7 +143,6 @@ set(HAVE_BLUETOOTH CACHE BOOL "Building with bluetooth position provider" ) - set(USE_KEYCHAIN FALSE CACHE From 3fcf78caff8c8024c0b9cd8570f82b3ba65f4ddf Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 18 Aug 2026 18:47:00 +0300 Subject: [PATCH 07/15] Added endpoint --- app/main.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/main.cpp b/app/main.cpp index 0fb04f35c..288817e80 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -397,8 +397,7 @@ void addQmlImportPath( QQmlEngine &engine ) #endif } -// TODO: update to the final production endpoint once it exists -static const QString USAGE_REPORT_ENDPOINT = QStringLiteral( "https://meta.merginmaps.com/analytics" ); +static const QString USAGE_REPORT_ENDPOINT = QStringLiteral( "https://meta.merginmaps.com/dev/telemetry.json" ); static const int USAGE_REPORT_INTERVAL_SECS = 7 * 24 * 3600; // 1 week /** From 863a460a769131169fce4450af12c669cf559759 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Thu, 20 Aug 2026 14:24:45 +0300 Subject: [PATCH 08/15] Added stakeout into the analytics --- app/qml/map/MMMapController.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index c1aebcb31..fb5078960 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -1396,6 +1396,7 @@ Item { root.centeredToGPS = true internal.stakeoutTarget = featurepair state = "stakeout" + AppSettings.trackUsageFeature( "stakeout" ) } function measure() { From dc1aec273ece5f94580ec598559f6722e40fd2c8 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 25 Aug 2026 11:04:03 +0300 Subject: [PATCH 09/15] Corrected logic for sending snapshot Added layer and feature search analytics Updated filtering analytics --- app/main.cpp | 124 ++++++++++++++++++-------- app/qml/filters/MMFiltersDrawer.qml | 1 + app/qml/layers/MMFeaturesListPage.qml | 6 +- app/qml/layers/MMLayersController.qml | 1 + 4 files changed, 94 insertions(+), 38 deletions(-) diff --git a/app/main.cpp b/app/main.cpp index 288817e80..defe6ef2a 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -402,9 +402,13 @@ static const int USAGE_REPORT_INTERVAL_SECS = 7 * 24 * 3600; // 1 week /** * Attempt to send a weekly usage snapshot if one is due. - * Collects static device/app data, merges accumulated dynamic data from - * QSettings, and sends a single HTTP POST. On success, dynamic data is - * reset and last_reported_at is updated. On failure, silently ignored. + * + * Two-step process: + * 1. GET the config URL to discover the actual telemetry endpoint + * 2. POST the snapshot payload to that endpoint + * + * On success, dynamic data is reset and last_reported_at is updated. + * On any failure (config fetch or POST), silently ignored. */ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, LocalProjectsManager &localProjectsManager, MerginApi *merginApi ) @@ -437,7 +441,6 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, properties.insert( QStringLiteral( "platform" ), InputUtils::appPlatform() ); properties.insert( QStringLiteral( "os_version" ), QSysInfo::productVersion() ); properties.insert( QStringLiteral( "project_count" ), localProjectsManager.projects().count() ); - properties.insert( QStringLiteral( "autosync_enabled" ), as->autosyncAllowed() ); // External provider count int externalProviderCount = 0; @@ -462,10 +465,42 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, } properties.insert( QStringLiteral( "server_type" ), serverTypeStr ); properties.insert( QStringLiteral( "server_version" ), merginApi->apiVersion() ); - if ( merginApi->subscriptionInfo() ) - properties.insert( QStringLiteral( "plan_name" ), merginApi->subscriptionInfo()->planAlias() ); - - // Merge accumulated dynamic data + properties.insert( QStringLiteral( "plan_name" ), + merginApi->subscriptionInfo() ? merginApi->subscriptionInfo()->planAlias() : QString() ); + + // Default values for all dynamic fields — ensures every key is always present + // in the snapshot. Actual values from QSettings will overwrite these below. + + // Boolean feature flags + properties.insert( QStringLiteral( "filtering" ), false ); + properties.insert( QStringLiteral( "map_sketching" ), false ); + properties.insert( QStringLiteral( "map_measuring" ), false ); + properties.insert( QStringLiteral( "external_gps" ), false ); + properties.insert( QStringLiteral( "autosync" ), false ); + properties.insert( QStringLiteral( "reuse_last_value" ), false ); + properties.insert( QStringLiteral( "bulk_editing" ), false ); + properties.insert( QStringLiteral( "photo_sketching" ), false ); + properties.insert( QStringLiteral( "created_project" ), false ); + properties.insert( QStringLiteral( "stakeout" ), false ); + properties.insert( QStringLiteral( "layers_search" ), false ); + properties.insert( QStringLiteral( "features_search" ), false ); + + // Numeric counters + properties.insert( QStringLiteral( "captured_images" ), 0 ); + properties.insert( QStringLiteral( "attached_images" ), 0 ); + properties.insert( QStringLiteral( "workspace_switches" ), 0 ); + properties.insert( QStringLiteral( "ping_success_count" ), 0 ); + properties.insert( QStringLiteral( "ping_fail_count" ), 0 ); + properties.insert( QStringLiteral( "avg_project_load_time_ms" ), 0 ); + + // String data + properties.insert( QStringLiteral( "external_connection_type" ), QString() ); + properties.insert( QStringLiteral( "external_name" ), QString() ); + properties.insert( QStringLiteral( "highest_role" ), QString() ); + // TODO: populate from GET /v2/workspaces//service once the endpoint is extended + properties.insert( QStringLiteral( "industry" ), QString() ); + + // Merge accumulated dynamic data (overwrites defaults with actual values) settings.beginGroup( QStringLiteral( "usage_report/data" ) ); const QStringList keys = settings.childKeys(); for ( const QString &key : keys ) @@ -480,9 +515,6 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, properties.remove( QStringLiteral( "total_load_time_ms" ) ); properties.remove( QStringLiteral( "load_count" ) ); - // Suppress IP collection - properties.insert( QStringLiteral( "$ip" ), QString() ); - // Last reported at properties.insert( QStringLiteral( "last_reported_at" ), lastReported.isValid() ? lastReported.toString( Qt::ISODate ) : QString() ); @@ -494,26 +526,54 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, { QStringLiteral( "properties" ), QJsonObject::fromVariantMap( properties ) } }; - QUrl url( USAGE_REPORT_ENDPOINT ); - QNetworkRequest request( url ); - request.setHeader( QNetworkRequest::ContentTypeHeader, QStringLiteral( "application/json" ) ); - request.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); + // Step 1: GET the config to discover the telemetry endpoint URL + QUrl configUrl( USAGE_REPORT_CONFIG_URL ); + QNetworkRequest configRequest( configUrl ); + configRequest.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); - QNetworkReply *reply = nam->post( request, QJsonDocument( body ).toJson( QJsonDocument::Compact ) ); - QObject::connect( reply, &QNetworkReply::finished, reply, [reply]() + QNetworkReply *configReply = nam->get( configRequest ); + QObject::connect( configReply, &QNetworkReply::finished, configReply, [configReply, nam, body]() { - if ( reply->error() == QNetworkReply::NoError ) + if ( configReply->error() != QNetworkReply::NoError ) { - QSettings s; - // Reset dynamic data - s.beginGroup( QStringLiteral( "usage_report/data" ) ); - s.remove( QString() ); - s.endGroup(); - // Update last reported - s.setValue( QStringLiteral( "usage_report/last_reported_at" ), QDateTime::currentDateTimeUtc() ); + configReply->deleteLater(); + return; + } + + const QJsonDocument configDoc = QJsonDocument::fromJson( configReply->readAll() ); + configReply->deleteLater(); + + const QString endpointUrl = configDoc.object() + .value( QStringLiteral( "telemetry" ) ).toObject() + .value( QStringLiteral( "endpoint_url" ) ).toString(); + + if ( endpointUrl.isEmpty() ) + { + return; } - // On network error: silently ignore — data preserved for next attempt - reply->deleteLater(); + + // Step 2: POST the snapshot to the discovered endpoint + QUrl url( endpointUrl ); + QNetworkRequest request( url ); + request.setHeader( QNetworkRequest::ContentTypeHeader, QStringLiteral( "application/json" ) ); + request.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); + + QNetworkReply *reply = nam->post( request, QJsonDocument( body ).toJson( QJsonDocument::Compact ) ); + QObject::connect( reply, &QNetworkReply::finished, reply, [reply]() + { + if ( reply->error() == QNetworkReply::NoError ) + { + QSettings s; + // Reset dynamic data + s.beginGroup( QStringLiteral( "usage_report/data" ) ); + s.remove( QString() ); + s.endGroup(); + // Update last reported + s.setValue( QStringLiteral( "usage_report/last_reported_at" ), QDateTime::currentDateTimeUtc() ); + } + // On network error: silently ignore — data preserved for next attempt + reply->deleteLater(); + } ); } ); } @@ -787,16 +847,6 @@ int main( int argc, char *argv[] ) QSettings().setValue( QStringLiteral( "usage_report/data/" ) + key, value ); }; - // Filtering - QObject::connect( &activeProject, &ActiveProject::filterControllerChanged, &lambdaContext, [trackFeature]( FilterController * fc ) - { - if ( !fc ) return; - QObject::connect( fc, &FilterController::hasFiltersActivatedChanged, fc, [trackFeature]() - { - trackFeature( QStringLiteral( "filtering" ) ); - } ); - } ); - // Auto-sync QObject::connect( &activeProject, &ActiveProject::autosyncControllerChanged, &lambdaContext, [trackFeature]( AutosyncController * ac ) { diff --git a/app/qml/filters/MMFiltersDrawer.qml b/app/qml/filters/MMFiltersDrawer.qml index 672830f51..54b299289 100644 --- a/app/qml/filters/MMFiltersDrawer.qml +++ b/app/qml/filters/MMFiltersDrawer.qml @@ -204,6 +204,7 @@ MMComponents.MMDrawer { onClicked: { __activeProject.filterController.processFilters(internal.filterValues) + AppSettings.trackUsageFeature( "filtering" ) root.close() } } diff --git a/app/qml/layers/MMFeaturesListPage.qml b/app/qml/layers/MMFeaturesListPage.qml index 3d8e03970..952f70114 100644 --- a/app/qml/layers/MMFeaturesListPage.qml +++ b/app/qml/layers/MMFeaturesListPage.qml @@ -42,7 +42,11 @@ MMComponents.MMPage { width: parent.width delayedSearch: true - onSearchTextChanged: featuresModel.searchExpression = searchBar.text + onSearchTextChanged: { + featuresModel.searchExpression = searchBar.text + if ( searchBar.text.length > 0 ) + AppSettings.trackUsageFeature( "features_search" ) + } } MMFilterComponents.MMFilterBanner { diff --git a/app/qml/layers/MMLayersController.qml b/app/qml/layers/MMLayersController.qml index 14fa79ff6..c117755d5 100644 --- a/app/qml/layers/MMLayersController.qml +++ b/app/qml/layers/MMLayersController.qml @@ -103,6 +103,7 @@ Item { } onSearchBarClicked: function() { + AppSettings.trackUsageFeature( "layers_search" ) let item = pagesStackView.push( searchLayersPage, {}, StackView.Immediate ) item.forceActiveFocus() } From 08575b182becdfbcbf408eadf521cc82c375ef18 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 25 Aug 2026 11:34:13 +0300 Subject: [PATCH 10/15] Minor code fix --- app/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.cpp b/app/main.cpp index defe6ef2a..5850cd655 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -527,7 +527,7 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, }; // Step 1: GET the config to discover the telemetry endpoint URL - QUrl configUrl( USAGE_REPORT_CONFIG_URL ); + QUrl configUrl( USAGE_REPORT_ENDPOINT ); QNetworkRequest configRequest( configUrl ); configRequest.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); From bbe06f0f1c47af2a8fa333243467cef83f92fa95 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 25 Aug 2026 18:17:55 +0300 Subject: [PATCH 11/15] Updated endpoint URL --- app/main.cpp | 63 ++++++++++++++-------------------------------------- 1 file changed, 17 insertions(+), 46 deletions(-) diff --git a/app/main.cpp b/app/main.cpp index 5850cd655..a1fb64788 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -397,7 +397,7 @@ void addQmlImportPath( QQmlEngine &engine ) #endif } -static const QString USAGE_REPORT_ENDPOINT = QStringLiteral( "https://meta.merginmaps.com/dev/telemetry.json" ); +static const QString USAGE_REPORT_ENDPOINT = QStringLiteral( "https://api.merginmaps.com/mobile/usage-statistics" ); static const int USAGE_REPORT_INTERVAL_SECS = 7 * 24 * 3600; // 1 week /** @@ -520,60 +520,31 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, const QJsonObject body { - { QStringLiteral( "event" ), QStringLiteral( "usage_snapshot" ) }, { QStringLiteral( "device_id" ), deviceUuid }, { QStringLiteral( "timestamp" ), now.toString( Qt::ISODate ) }, { QStringLiteral( "properties" ), QJsonObject::fromVariantMap( properties ) } }; - // Step 1: GET the config to discover the telemetry endpoint URL - QUrl configUrl( USAGE_REPORT_ENDPOINT ); - QNetworkRequest configRequest( configUrl ); - configRequest.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); + QUrl url( USAGE_REPORT_ENDPOINT ); + QNetworkRequest request( url ); + request.setHeader( QNetworkRequest::ContentTypeHeader, QStringLiteral( "application/json" ) ); + request.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); - QNetworkReply *configReply = nam->get( configRequest ); - QObject::connect( configReply, &QNetworkReply::finished, configReply, [configReply, nam, body]() + QNetworkReply *reply = nam->post( request, QJsonDocument( body ).toJson( QJsonDocument::Compact ) ); + QObject::connect( reply, &QNetworkReply::finished, reply, [reply]() { - if ( configReply->error() != QNetworkReply::NoError ) + if ( reply->error() == QNetworkReply::NoError ) { - configReply->deleteLater(); - return; - } - - const QJsonDocument configDoc = QJsonDocument::fromJson( configReply->readAll() ); - configReply->deleteLater(); - - const QString endpointUrl = configDoc.object() - .value( QStringLiteral( "telemetry" ) ).toObject() - .value( QStringLiteral( "endpoint_url" ) ).toString(); - - if ( endpointUrl.isEmpty() ) - { - return; + QSettings s; + // Reset dynamic data + s.beginGroup( QStringLiteral( "usage_report/data" ) ); + s.remove( QString() ); + s.endGroup(); + // Update last reported + s.setValue( QStringLiteral( "usage_report/last_reported_at" ), QDateTime::currentDateTimeUtc() ); } - - // Step 2: POST the snapshot to the discovered endpoint - QUrl url( endpointUrl ); - QNetworkRequest request( url ); - request.setHeader( QNetworkRequest::ContentTypeHeader, QStringLiteral( "application/json" ) ); - request.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); - - QNetworkReply *reply = nam->post( request, QJsonDocument( body ).toJson( QJsonDocument::Compact ) ); - QObject::connect( reply, &QNetworkReply::finished, reply, [reply]() - { - if ( reply->error() == QNetworkReply::NoError ) - { - QSettings s; - // Reset dynamic data - s.beginGroup( QStringLiteral( "usage_report/data" ) ); - s.remove( QString() ); - s.endGroup(); - // Update last reported - s.setValue( QStringLiteral( "usage_report/last_reported_at" ), QDateTime::currentDateTimeUtc() ); - } - // On network error: silently ignore — data preserved for next attempt - reply->deleteLater(); - } ); + // On network error: silently ignore — data preserved for next attempt + reply->deleteLater(); } ); } From d070d62d67baeba170b6104a951fa6792ab2c487 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 26 Aug 2026 14:50:35 +0300 Subject: [PATCH 12/15] Added telemetry uuid --- app/main.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/main.cpp b/app/main.cpp index a1fb64788..971aa6606 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -423,12 +423,12 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, if ( lastReported.isValid() && lastReported.secsTo( now ) < USAGE_REPORT_INTERVAL_SECS ) return; - // Ensure device UUID exists - QString deviceUuid = settings.value( QStringLiteral( "usage_report/device_uuid" ) ).toString(); - if ( deviceUuid.isEmpty() ) + // Ensure telemetry UUID exists (separate from the device UUID) + QString telemetryId = settings.value( QStringLiteral( "usage_report/telemetry_id" ) ).toString(); + if ( telemetryId.isEmpty() ) { - deviceUuid = CoreUtils::deviceUuid(); - settings.setValue( QStringLiteral( "usage_report/device_uuid" ), deviceUuid ); + telemetryId = CoreUtils::uuidWithoutBraces( QUuid::createUuid() ); + settings.setValue( QStringLiteral( "usage_report/telemetry_id" ), telemetryId ); } // Collect static data @@ -520,7 +520,7 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, const QJsonObject body { - { QStringLiteral( "device_id" ), deviceUuid }, + { QStringLiteral( "telemetry_id" ), telemetryId }, { QStringLiteral( "timestamp" ), now.toString( Qt::ISODate ) }, { QStringLiteral( "properties" ), QJsonObject::fromVariantMap( properties ) } }; From 47d2272435df09147d94c72efe210780658ca929 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Thu, 27 Aug 2026 09:58:16 +0300 Subject: [PATCH 13/15] Updated comments --- app/appsettings.cpp | 2 +- app/ios/iosutils.cpp | 2 ++ app/main.cpp | 9 +++------ 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/appsettings.cpp b/app/appsettings.cpp index 31e529e7c..244105f0d 100644 --- a/app/appsettings.cpp +++ b/app/appsettings.cpp @@ -440,4 +440,4 @@ void AppSettings::incrementUsageCounter( const QString &key ) QSettings s; const QString fullKey = QStringLiteral( "usage_report/data/" ) + key; s.setValue( fullKey, s.value( fullKey, 0 ).toInt() + 1 ); -} \ No newline at end of file +} diff --git a/app/ios/iosutils.cpp b/app/ios/iosutils.cpp index 96ae1f76e..8fa82fd4a 100644 --- a/app/ios/iosutils.cpp +++ b/app/ios/iosutils.cpp @@ -18,6 +18,8 @@ IosUtils::IosUtils( QObject *parent ): QObject( parent ) QObject::connect( mImagePicker, &IOSImagePicker::imageCaptured, this, [this]( const QString & absoluteImagePath ) { emit imageSelected( absoluteImagePath, mLastCode ); + if ( mLastCode.isEmpty() ) + return; // no pending request — ignore if ( mLastSourceWasCamera ) emit photoCaptured(); else diff --git a/app/main.cpp b/app/main.cpp index 971aa6606..8fe817757 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -402,13 +402,10 @@ static const int USAGE_REPORT_INTERVAL_SECS = 7 * 24 * 3600; // 1 week /** * Attempt to send a weekly usage snapshot if one is due. - * - * Two-step process: - * 1. GET the config URL to discover the actual telemetry endpoint - * 2. POST the snapshot payload to that endpoint - * + * Collects static device/app data, merges accumulated dynamic data from + * QSettings, and POSTs a single JSON payload to the telemetry endpoint. * On success, dynamic data is reset and last_reported_at is updated. - * On any failure (config fetch or POST), silently ignored. + * On failure, silently ignored — data is preserved for the next attempt. */ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, LocalProjectsManager &localProjectsManager, MerginApi *merginApi ) From 53a7c353039d9376aca70d32a44984fed60fe09b Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 28 Aug 2026 17:03:47 +0300 Subject: [PATCH 14/15] Added analytics after sketch is done --- app/qml/map/MMMapController.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index fb5078960..30b4eeae4 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -183,7 +183,6 @@ Item { } case "sketch": { - AppSettings.trackUsageFeature( "map_sketching" ) root.showInfoTextMessage( qsTr( "Select a colour and start sketching on the map. Use two fingers to move or zoom the map." ) ) root.drawStarted() break @@ -1090,6 +1089,8 @@ Item { id: sketchingController mapSettings: mapCanvas.mapSettings + + onSketched: AppSettings.trackUsageFeature( "map_sketching" ) } MMHighlight { From d35edcc454ceb7594d80e16355abe50ceb114c2a Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 28 Aug 2026 20:17:43 +0300 Subject: [PATCH 15/15] Updated comments in main --- app/ios/iosutils.cpp | 2 +- app/main.cpp | 23 ++++++++++------------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/app/ios/iosutils.cpp b/app/ios/iosutils.cpp index 8fa82fd4a..cec3d7989 100644 --- a/app/ios/iosutils.cpp +++ b/app/ios/iosutils.cpp @@ -19,7 +19,7 @@ IosUtils::IosUtils( QObject *parent ): QObject( parent ) { emit imageSelected( absoluteImagePath, mLastCode ); if ( mLastCode.isEmpty() ) - return; // no pending request — ignore + return; if ( mLastSourceWasCamera ) emit photoCaptured(); else diff --git a/app/main.cpp b/app/main.cpp index 8fe817757..dc77b25f8 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -402,10 +402,9 @@ static const int USAGE_REPORT_INTERVAL_SECS = 7 * 24 * 3600; // 1 week /** * Attempt to send a weekly usage snapshot if one is due. - * Collects static device/app data, merges accumulated dynamic data from - * QSettings, and POSTs a single JSON payload to the telemetry endpoint. - * On success, dynamic data is reset and last_reported_at is updated. - * On failure, silently ignored — data is preserved for the next attempt. + * Collects static device/app data, merges accumulated dynamic data from QSettings, and POSTs a single JSON payload to the telemetry endpoint + * On success, dynamic data is reset and last_reported_at is updated + * On failure, the snapshot is ignored, the data is preserved for the next attempt */ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, LocalProjectsManager &localProjectsManager, MerginApi *merginApi ) @@ -465,8 +464,8 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, properties.insert( QStringLiteral( "plan_name" ), merginApi->subscriptionInfo() ? merginApi->subscriptionInfo()->planAlias() : QString() ); - // Default values for all dynamic fields — ensures every key is always present - // in the snapshot. Actual values from QSettings will overwrite these below. + // Default values for all dynamic fields, this ensures every key is always present in the snapshot + // Actual values from QSettings will overwrite these below // Boolean feature flags properties.insert( QStringLiteral( "filtering" ), false ); @@ -540,7 +539,7 @@ static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, // Update last reported s.setValue( QStringLiteral( "usage_report/last_reported_at" ), QDateTime::currentDateTimeUtc() ); } - // On network error: silently ignore — data preserved for next attempt + // On network error: ignore, data preserved for next attempt reply->deleteLater(); } ); } @@ -792,9 +791,9 @@ int main( int argc, char *argv[] ) syncManager.syncProject( project, SyncOptions::Authorized, SyncOptions::Retry, requestOrigin ); } ); - // ── Usage reporting: accumulate dynamic data via signal connections ── + // Gather dynamic data via signal connections // Helper lambdas for writing to QSettings usage_report/data/* namespace. - // All connections guard on usageReportEnabled before writing. + // Always check usageReportEnabled before writing. auto trackFeature = [as]( const QString & key ) { if ( !as->usageReportEnabled() ) return; @@ -904,10 +903,8 @@ int main( int argc, char *argv[] ) projectLoadTimer->start(); } ); - // Map sketching (via MapSketchingController — QML_ELEMENT, connect per instance) - // Connected alongside projectReloaded below since sketching controller is project-scoped. - // Connectivity ping: HEAD request every 5 minutes + // TODO: check the new endpoint and decide on the interval QTimer *pingTimer = new QTimer( &lambdaContext ); QObject::connect( pingTimer, &QTimer::timeout, &lambdaContext, [&usageReportNam, merginApi = ma.get(), as, incrementCounter]() { @@ -1146,7 +1143,7 @@ int main( int argc, char *argv[] ) QQmlComponent component( &engine, QUrl( "qrc:/com.merginmaps/imports/MMInput/main.qml" ) ); QObject *object = component.create(); - // Usage reporting: attempt weekly snapshot + // Attempt weekly snapshot trySubmitUsageSnapshot( &usageReportNam, as, localProjectsManager, ma.get() ); if ( !component.errors().isEmpty() )