From e079f23c11555f45e73c06d9b2898976764b7cec Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 22 Jul 2026 10:48:46 +0300 Subject: [PATCH 01/19] 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 9d887b7f0..00f541c67 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 0f1836fdb..82a7dd830 100644 --- a/app/qml/settings/MMSettingsPage.qml +++ b/app/qml/settings/MMSettingsPage.qml @@ -319,6 +319,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 88f66e94c9213a57b6b8a3bcaa720ac8fec7c484 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 22 Jul 2026 11:21:04 +0300 Subject: [PATCH 02/19] 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 0f634de7a..b03376025 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,6 +150,11 @@ set(WITH_TRIMBLE_PROVIDERS "Building with Trimble GNSS position provider via Trimble Mobile Manager (mobile only)" ) +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 54bae9f61..7a55a7d09 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 ); @@ -831,6 +963,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 3255029e5..63e9edcf8 100644 --- a/cmake_templates/mmconfig.h.in +++ b/cmake_templates/mmconfig.h.in @@ -14,5 +14,7 @@ #cmakedefine01 WITH_BLUETOOTH_PROVIDERS #cmakedefine01 WITH_TRIMBLE_PROVIDERS +#cmakedefine USAGE_REPORT_KEY "@USAGE_REPORT_KEY@" + #endif From b009e702db328ea0692f73915ebafb96accab39f Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 22 Jul 2026 11:45:37 +0300 Subject: [PATCH 03/19] 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 7a55a7d09..ed778e538 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" @@ -772,9 +776,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 ba6d48de94a4fe07712bd63c722a63b09ae5dd85 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 22 Jul 2026 11:53:14 +0300 Subject: [PATCH 04/19] 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 00f541c67..90af10189 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 1abe26c70..f48f48fd0 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 f0f5d302bae3bb5a7118431c252ed8ac16e48eec Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 14 Aug 2026 11:49:07 +0300 Subject: [PATCH 05/19] Removed pothost references Added a temporary endpoint Remaned distinct_id with device_id --- CMakeLists.txt | 5 ----- app/main.cpp | 14 +++++--------- cmake_templates/mmconfig.h.in | 2 -- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b03376025..0f634de7a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,11 +150,6 @@ set(WITH_TRIMBLE_PROVIDERS "Building with Trimble GNSS position provider via Trimble Mobile Manager (mobile only)" ) -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 ed778e538..aa874bdf2 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 63e9edcf8..3255029e5 100644 --- a/cmake_templates/mmconfig.h.in +++ b/cmake_templates/mmconfig.h.in @@ -14,7 +14,5 @@ #cmakedefine01 WITH_BLUETOOTH_PROVIDERS #cmakedefine01 WITH_TRIMBLE_PROVIDERS -#cmakedefine USAGE_REPORT_KEY "@USAGE_REPORT_KEY@" - #endif From 8168f5c367f767d7b0bf90fff68dca1f57faf119 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 18 Aug 2026 18:47:00 +0300 Subject: [PATCH 06/19] Added endpoint --- app/main.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/main.cpp b/app/main.cpp index aa874bdf2..ac2bdadc5 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 c765f850189b9d79d0332cc335af36aa9c1df3bf Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Thu, 20 Aug 2026 14:24:45 +0300 Subject: [PATCH 07/19] 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 f48f48fd0..58413ae16 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 6318b7d1105d5b21e2a72757909513874fe913a2 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 25 Aug 2026 11:04:03 +0300 Subject: [PATCH 08/19] 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 ac2bdadc5..6413be0b4 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(); + } ); } ); } @@ -794,16 +854,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 c085fe6dad7f37cb6d4e1249b988eccc91e8ba5c Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 25 Aug 2026 11:34:13 +0300 Subject: [PATCH 09/19] 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 6413be0b4..23ca24117 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 ca51a6c5b604b778232cf9be242534c5387ee0d4 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Tue, 25 Aug 2026 18:17:55 +0300 Subject: [PATCH 10/19] 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 23ca24117..43a7da3a2 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 4aafb31eeb7bb2def83dbe46d533917585136381 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 26 Aug 2026 14:50:35 +0300 Subject: [PATCH 11/19] 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 43a7da3a2..ebb5265f5 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 7d311309b700eadda26afe43335d9a3163325b20 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Thu, 27 Aug 2026 09:58:16 +0300 Subject: [PATCH 12/19] 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 90af10189..30f121430 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 ebb5265f5..78f3f0a78 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 b08253729e8b390d4ac345264f39805db9b7dba7 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 28 Aug 2026 17:03:47 +0300 Subject: [PATCH 13/19] 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 58413ae16..264ce7adf 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 a2c3c2874dcb4c47feb08df61c1d6f763ede96fb Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 28 Aug 2026 20:17:43 +0300 Subject: [PATCH 14/19] 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 78f3f0a78..b7610a6a2 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(); } ); } @@ -799,9 +798,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; @@ -911,10 +910,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]() { @@ -1147,7 +1144,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() ) From 8567d838462adb3b65696ad325dc4828cb55648a Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 23 Sep 2026 15:50:19 +0200 Subject: [PATCH 15/19] Added usage report dialog --- app/appsettings.cpp | 25 +++++++++++-- app/appsettings.h | 8 ++++- app/qml/dialogs/MMUsageReportDialog.qml | 48 +++++++++++++++++++++++++ app/qml/main.qml | 9 +++++ 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 app/qml/dialogs/MMUsageReportDialog.qml diff --git a/app/appsettings.cpp b/app/appsettings.cpp index 30f121430..bc5204837 100644 --- a/app/appsettings.cpp +++ b/app/appsettings.cpp @@ -20,7 +20,8 @@ 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(); + mUsageReportEnabled = settings.value( QStringLiteral( "usage_report/enabled" ), false ).toBool(); + mUsageReportConsentAsked = settings.value( QStringLiteral( "usage_report/consent_asked" ), false ).toBool(); } QSettings settings; @@ -414,8 +415,8 @@ void AppSettings::setUsageReportEnabled( bool enabled ) QSettings settings; if ( !mUsageReportEnabled ) { - // Opt-out: clear all accumulated data but keep the enabled flag - settings.beginGroup( QStringLiteral( "usage_report" ) ); + // Opt-out: clear accumulated data but preserve enabled and consent_asked flags + settings.beginGroup( QStringLiteral( "usage_report/data" ) ); settings.remove( QString() ); settings.endGroup(); settings.setValue( QStringLiteral( "usage_report/enabled" ), false ); @@ -428,6 +429,24 @@ void AppSettings::setUsageReportEnabled( bool enabled ) emit usageReportEnabledChanged( mUsageReportEnabled ); } +bool AppSettings::usageReportConsentAsked() const +{ + return mUsageReportConsentAsked; +} + +void AppSettings::setUsageReportConsentAsked( bool asked ) +{ + if ( mUsageReportConsentAsked == asked ) + return; + + mUsageReportConsentAsked = asked; + + QSettings settings; + settings.setValue( QStringLiteral( "usage_report/consent_asked" ), asked ); + + emit usageReportConsentAskedChanged( mUsageReportConsentAsked ); +} + void AppSettings::trackUsageFeature( const QString &key ) { if ( !mUsageReportEnabled ) return; diff --git a/app/appsettings.h b/app/appsettings.h index 01954412b..d12b89802 100644 --- a/app/appsettings.h +++ b/app/appsettings.h @@ -40,6 +40,7 @@ class AppSettings: public QObject 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 ) + Q_PROPERTY( bool usageReportConsentAsked READ usageReportConsentAsked WRITE setUsageReportConsentAsked NOTIFY usageReportConsentAskedChanged ) public: // enum of haptic modes we support @@ -126,6 +127,9 @@ class AppSettings: public QObject bool usageReportEnabled() const; void setUsageReportEnabled( bool enabled ); + bool usageReportConsentAsked() const; + void setUsageReportConsentAsked( bool asked ); + Q_INVOKABLE void trackUsageFeature( const QString &key ); Q_INVOKABLE void incrementUsageCounter( const QString &key ); @@ -155,6 +159,7 @@ class AppSettings: public QObject void windowPositionChanged(); void usageReportEnabledChanged( bool enabled ); + void usageReportConsentAskedChanged( bool asked ); private: // Projects path @@ -192,7 +197,8 @@ class AppSettings: public QObject HapticsType mHapticsType; StartupBehavior mStartupBehavior; - bool mUsageReportEnabled = true; + bool mUsageReportEnabled = false; + bool mUsageReportConsentAsked = false; }; #endif // APPSETTINGS_H diff --git a/app/qml/dialogs/MMUsageReportDialog.qml b/app/qml/dialogs/MMUsageReportDialog.qml new file mode 100644 index 000000000..92f7aa1fa --- /dev/null +++ b/app/qml/dialogs/MMUsageReportDialog.qml @@ -0,0 +1,48 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +import QtQuick + +import "../components" as MMComponents + +MMComponents.MMDrawerDialog { + id: root + + interactive: true + + imageSource: __style.attentionImage + title: qsTr( "Help us improve Mergin Maps" ) + description: qsTr( "Share anonymous usage data so we can focus on the features that matter most to you. No personal or project data is ever collected." ) + primaryButton.text: qsTr( "Yes, count me in" ) + secondaryButton.text: qsTr( "No, thanks" ) + + additionalContent: MMComponents.MMText { + width: parent.width + text: qsTr( "You can change this anytime in Settings." ) + font: __style.p6 + color: __style.mediumGreyColor + horizontalAlignment: Text.AlignHCenter + } + + onPrimaryButtonClicked: { + AppSettings.usageReportEnabled = true + AppSettings.usageReportConsentAsked = true + close() + } + + onSecondaryButtonClicked: { + AppSettings.usageReportConsentAsked = true + close() + } + + onClosed: { + if ( !AppSettings.usageReportConsentAsked ) + AppSettings.usageReportConsentAsked = true + } +} diff --git a/app/qml/main.qml b/app/qml/main.qml index 3b31f5cce..632c72963 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -984,6 +984,15 @@ ApplicationWindow { } } + MMUsageReportDialog { + id: usageReportDialog + + Component.onCompleted: { + if ( !AppSettings.usageReportConsentAsked ) + open() + } + } + Connections { target: __syncManager enabled: stateManager.state === "map" From c793c41732b661912e006bcd52a12fec82363db6 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 23 Sep 2026 16:23:55 +0200 Subject: [PATCH 16/19] Added Usage report dialog in CMakeLists Updated the dialog image --- app/qml/CMakeLists.txt | 1 + app/qml/dialogs/MMUsageReportDialog.qml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/qml/CMakeLists.txt b/app/qml/CMakeLists.txt index 83ef85a09..e92565095 100644 --- a/app/qml/CMakeLists.txt +++ b/app/qml/CMakeLists.txt @@ -82,6 +82,7 @@ set(MM_QML dialogs/MMPositionTrackingDialog.qml dialogs/MMProjectLimitDialog.qml dialogs/MMRemoveProjectDialog.qml + dialogs/MMUsageReportDialog.qml dialogs/MMWelcomeToNewDesignDialog.qml dialogs/MMSplittingFailedDialog.qml dialogs/MMStorageLimitDialog.qml diff --git a/app/qml/dialogs/MMUsageReportDialog.qml b/app/qml/dialogs/MMUsageReportDialog.qml index 92f7aa1fa..ca64084a0 100644 --- a/app/qml/dialogs/MMUsageReportDialog.qml +++ b/app/qml/dialogs/MMUsageReportDialog.qml @@ -16,7 +16,7 @@ MMComponents.MMDrawerDialog { interactive: true - imageSource: __style.attentionImage + imageSource: __style.positiveMMSymbolImage title: qsTr( "Help us improve Mergin Maps" ) description: qsTr( "Share anonymous usage data so we can focus on the features that matter most to you. No personal or project data is ever collected." ) primaryButton.text: qsTr( "Yes, count me in" ) From 310eec63fff4eb9d893576d993491069295e4f71 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 23 Sep 2026 17:01:06 +0200 Subject: [PATCH 17/19] Created usage report controller Moved the logic out of main --- app/main.cpp | 278 ++++------------------------------ app/usagereportcontroller.cpp | 252 ++++++++++++++++++++++++++++++ app/usagereportcontroller.h | 55 +++++++ 3 files changed, 339 insertions(+), 246 deletions(-) create mode 100644 app/usagereportcontroller.cpp create mode 100644 app/usagereportcontroller.h diff --git a/app/main.cpp b/app/main.cpp index b7610a6a2..24d64d002 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -9,16 +9,9 @@ #include "mmconfig.h" -#include -#include #include -#include #include -#include -#include -#include -#include -#include +#include #include #include #include @@ -113,6 +106,7 @@ #include "mapsketchingcontroller.h" #include "filter/filtercontroller.h" #include "autosynccontroller.h" +#include "usagereportcontroller.h" #include "projectsmodel.h" #include "projectsproxymodel.h" @@ -397,153 +391,6 @@ void addQmlImportPath( QQmlEngine &engine ) #endif } -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 - -/** - * 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, the snapshot is ignored, the data is preserved for the next attempt - */ -static void trySubmitUsageSnapshot( QNetworkAccessManager *nam, AppSettings *as, - LocalProjectsManager &localProjectsManager, MerginApi *merginApi ) -{ - if ( !as->usageReportEnabled() ) - 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 telemetry UUID exists (separate from the device UUID) - QString telemetryId = settings.value( QStringLiteral( "usage_report/telemetry_id" ) ).toString(); - if ( telemetryId.isEmpty() ) - { - telemetryId = CoreUtils::uuidWithoutBraces( QUuid::createUuid() ); - settings.setValue( QStringLiteral( "usage_report/telemetry_id" ), telemetryId ); - } - - // 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() ); - - // 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() ); - properties.insert( QStringLiteral( "plan_name" ), - merginApi->subscriptionInfo() ? merginApi->subscriptionInfo()->planAlias() : QString() ); - - // 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 ); - 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 ) - 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" ) ); - - // Last reported at - properties.insert( QStringLiteral( "last_reported_at" ), lastReported.isValid() ? lastReported.toString( Qt::ISODate ) : QString() ); - - const QJsonObject body - { - { QStringLiteral( "telemetry_id" ), telemetryId }, - { QStringLiteral( "timestamp" ), now.toString( Qt::ISODate ) }, - { 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 ); - - 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: ignore, data preserved for next attempt - reply->deleteLater(); - } ); -} - int main( int argc, char *argv[] ) { QgsApplication app( argc, argv, true ); @@ -686,7 +533,7 @@ int main( int argc, char *argv[] ) vm->registerInputExpressionFunctions(); SynchronizationManager syncManager( ma.get() ); - QNetworkAccessManager usageReportNam; + UsageReportController usageReportController( as, &localProjectsManager, ma.get() ); LayerTreeModelPixmapProvider *layerTreeModelPixmapProvider( new LayerTreeModelPixmapProvider ); LayerTreeFlatModelPixmapProvider *layerTreeFlatModelPixmapProvider( new LayerTreeFlatModelPixmapProvider ); @@ -798,77 +645,57 @@ int main( int argc, char *argv[] ) syncManager.syncProject( project, SyncOptions::Authorized, SyncOptions::Retry, requestOrigin ); } ); - // Gather dynamic data via signal connections - // Helper lambdas for writing to QSettings usage_report/data/* namespace. - // Always check 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 ); - }; + // Gather dynamic usage data via signal connections + UsageReportController &urc = usageReportController; // Auto-sync - QObject::connect( &activeProject, &ActiveProject::autosyncControllerChanged, &lambdaContext, [trackFeature]( AutosyncController * ac ) + QObject::connect( &activeProject, &ActiveProject::autosyncControllerChanged, &lambdaContext, [&urc]( AutosyncController * ac ) { if ( !ac ) return; - QObject::connect( ac, &AutosyncController::projectSyncRequested, ac, [trackFeature]( SyncOptions::RequestOrigin origin ) + QObject::connect( ac, &AutosyncController::projectSyncRequested, ac, [&urc]( SyncOptions::RequestOrigin origin ) { if ( origin == SyncOptions::AutomaticRequest ) - trackFeature( QStringLiteral( "autosync" ) ); + urc.trackFeature( QStringLiteral( "autosync" ) ); } ); } ); // Created project - QObject::connect( &pw, &ProjectWizard::projectCreated, &lambdaContext, [trackFeature]( const QString &, const QString & ) + QObject::connect( &pw, &ProjectWizard::projectCreated, &lambdaContext, [&urc]( const QString &, const QString & ) { - trackFeature( QStringLiteral( "created_project" ) ); + urc.trackFeature( QStringLiteral( "created_project" ) ); } ); // Photo captured vs attached - QObject::connect( &androidUtils, &AndroidUtils::photoCaptured, &lambdaContext, [incrementCounter]() + QObject::connect( &androidUtils, &AndroidUtils::photoCaptured, &lambdaContext, [&urc]() { - incrementCounter( QStringLiteral( "captured_images" ) ); + urc.incrementCounter( QStringLiteral( "captured_images" ) ); } ); - QObject::connect( &androidUtils, &AndroidUtils::photoFromGallery, &lambdaContext, [incrementCounter]() + QObject::connect( &androidUtils, &AndroidUtils::photoFromGallery, &lambdaContext, [&urc]() { - incrementCounter( QStringLiteral( "attached_images" ) ); + urc.incrementCounter( QStringLiteral( "attached_images" ) ); } ); - QObject::connect( &iosUtils, &IosUtils::photoCaptured, &lambdaContext, [incrementCounter]() + QObject::connect( &iosUtils, &IosUtils::photoCaptured, &lambdaContext, [&urc]() { - incrementCounter( QStringLiteral( "captured_images" ) ); + urc.incrementCounter( QStringLiteral( "captured_images" ) ); } ); - QObject::connect( &iosUtils, &IosUtils::photoFromGallery, &lambdaContext, [incrementCounter]() + QObject::connect( &iosUtils, &IosUtils::photoFromGallery, &lambdaContext, [&urc]() { - incrementCounter( QStringLiteral( "attached_images" ) ); + urc.incrementCounter( QStringLiteral( "attached_images" ) ); } ); // Workspace switches - QObject::connect( ma->userInfo(), &MerginUserInfo::activeWorkspaceChanged, &lambdaContext, [incrementCounter]() + QObject::connect( ma->userInfo(), &MerginUserInfo::activeWorkspaceChanged, &lambdaContext, [&urc]() { - incrementCounter( QStringLiteral( "workspace_switches" ) ); + urc.incrementCounter( QStringLiteral( "workspace_switches" ) ); } ); // External GPS provider - QObject::connect( pk, &PositionKit::positionProviderChanged, &lambdaContext, [trackFeature, setUsageData]( AbstractPositionProvider * provider ) + QObject::connect( pk, &PositionKit::positionProviderChanged, &lambdaContext, [&urc]( AbstractPositionProvider * provider ) { if ( !provider ) return; if ( provider->type() == QLatin1String( "internal" ) ) return; - trackFeature( QStringLiteral( "external_gps" ) ); + urc.trackFeature( QStringLiteral( "external_gps" ) ); const QString id = provider->id(); QString connectionType; @@ -879,12 +706,12 @@ int main( int argc, char *argv[] ) else connectionType = QStringLiteral( "network" ); - setUsageData( QStringLiteral( "external_connection_type" ), connectionType ); - setUsageData( QStringLiteral( "external_name" ), provider->name() ); + urc.setData( QStringLiteral( "external_connection_type" ), connectionType ); + urc.setData( QStringLiteral( "external_name" ), provider->name() ); } ); // Highest project role - QObject::connect( &activeProject, &ActiveProject::projectRoleChanged, &lambdaContext, [setUsageData, &activeProject]() + QObject::connect( &activeProject, &ActiveProject::projectRoleChanged, &lambdaContext, [&urc, &activeProject]() { const QString role = activeProject.projectRole(); auto roleRank = []( const QString & r ) -> int @@ -900,62 +727,21 @@ int main( int argc, char *argv[] ) QSettings s; const QString current = s.value( QStringLiteral( "usage_report/data/highest_role" ) ).toString(); if ( roleRank( role ) > roleRank( current ) ) - setUsageData( QStringLiteral( "highest_role" ), role ); + urc.setData( QStringLiteral( "highest_role" ), role ); } ); // Project load time - auto projectLoadTimer = std::make_shared(); - QObject::connect( &activeProject, &ActiveProject::loadingStarted, &lambdaContext, [projectLoadTimer]() - { - projectLoadTimer->start(); - } ); - - // 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]() + QObject::connect( &activeProject, &ActiveProject::loadingStarted, &lambdaContext, [&urc]() { - 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(); - } ); + urc.startLoadTimer(); } ); - 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 ); - } ); + urc.startPingTimer(); - QObject::connect( &activeProject, &ActiveProject::projectReloaded, &lambdaContext, [merginApi = ma.get(), &activeProject, incrementCounter, projectLoadTimer]() + QObject::connect( &activeProject, &ActiveProject::projectReloaded, &lambdaContext, [merginApi = ma.get(), &activeProject, &urc]() { 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(); - } + urc.recordLoadTime(); } ); QObject::connect( ma.get(), &MerginApi::authChanged, &lambdaContext, [merginApi = ma.get(), &activeProject]() @@ -1145,7 +931,7 @@ int main( int argc, char *argv[] ) QObject *object = component.create(); // Attempt weekly snapshot - trySubmitUsageSnapshot( &usageReportNam, as, localProjectsManager, ma.get() ); + usageReportController.trySubmitSnapshot(); if ( !component.errors().isEmpty() ) { diff --git a/app/usagereportcontroller.cpp b/app/usagereportcontroller.cpp new file mode 100644 index 000000000..5f37a1a38 --- /dev/null +++ b/app/usagereportcontroller.cpp @@ -0,0 +1,252 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "usagereportcontroller.h" +#include "appsettings.h" +#include "coreutils.h" +#include "inpututils.h" +#include "merginapi.h" +#include "merginservertype.h" +#include "merginsubscriptioninfo.h" +#include "localprojectsmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 + +UsageReportController::UsageReportController( AppSettings *appSettings, + LocalProjectsManager *localProjectsManager, + MerginApi *merginApi, + QObject *parent ) + : QObject( parent ) + , mAppSettings( appSettings ) + , mLocalProjectsManager( localProjectsManager ) + , mMerginApi( merginApi ) +{ + mPingTimer = new QTimer( this ); + connect( mPingTimer, &QTimer::timeout, this, [this]() + { + if ( !isEnabled() || mMerginApi->apiRoot().isEmpty() ) return; + + QUrl url( mMerginApi->apiRoot() ); + QNetworkRequest request( url ); + request.setAttribute( QNetworkRequest::Http2AllowedAttribute, false ); + + QNetworkReply *reply = mNam.head( request ); + connect( reply, &QNetworkReply::finished, reply, [this, reply]() + { + if ( reply->error() == QNetworkReply::NoError ) + incrementCounter( QStringLiteral( "ping_success_count" ) ); + else + incrementCounter( QStringLiteral( "ping_fail_count" ) ); + reply->deleteLater(); + } ); + } ); + + connect( mAppSettings, &AppSettings::usageReportEnabledChanged, this, [this]( bool enabled ) + { + if ( !enabled ) + mPingTimer->stop(); + else + mPingTimer->start( 5 * 60 * 1000 ); + } ); +} + +bool UsageReportController::isEnabled() const +{ + return mAppSettings && mAppSettings->usageReportEnabled(); +} + +void UsageReportController::trackFeature( const QString &key ) +{ + if ( !isEnabled() ) return; + QSettings().setValue( QStringLiteral( "usage_report/data/" ) + key, true ); +} + +void UsageReportController::incrementCounter( const QString &key, int amount ) +{ + if ( !isEnabled() ) return; + QSettings s; + const QString fullKey = QStringLiteral( "usage_report/data/" ) + key; + s.setValue( fullKey, s.value( fullKey, 0 ).toInt() + amount ); +} + +void UsageReportController::setData( const QString &key, const QVariant &value ) +{ + if ( !isEnabled() ) return; + QSettings().setValue( QStringLiteral( "usage_report/data/" ) + key, value ); +} + +void UsageReportController::startLoadTimer() +{ + mLoadTimer.start(); +} + +void UsageReportController::recordLoadTime() +{ + if ( !mLoadTimer.isValid() ) return; + + const qint64 elapsed = mLoadTimer.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 ); + mLoadTimer.invalidate(); +} + +void UsageReportController::startPingTimer() +{ + // TODO: check the new endpoint and decide on the interval + mPingTimer->start( 5 * 60 * 1000 ); // 5 minutes +} + +void UsageReportController::trySubmitSnapshot() +{ + if ( !isEnabled() ) + 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 telemetry UUID exists (separate from the device UUID) + QString telemetryId = settings.value( QStringLiteral( "usage_report/telemetry_id" ) ).toString(); + if ( telemetryId.isEmpty() ) + { + telemetryId = CoreUtils::uuidWithoutBraces( QUuid::createUuid() ); + settings.setValue( QStringLiteral( "usage_report/telemetry_id" ), telemetryId ); + } + + // 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" ), mLocalProjectsManager->projects().count() ); + + // External provider count + int externalProviderCount = 0; + const QVariantList providers = mAppSettings->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( mMerginApi->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" ), mMerginApi->apiVersion() ); + properties.insert( QStringLiteral( "plan_name" ), + mMerginApi->subscriptionInfo() ? mMerginApi->subscriptionInfo()->planAlias() : QString() ); + + // 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 ); + 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 ) + 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" ) ); + + // Last reported at + properties.insert( QStringLiteral( "last_reported_at" ), lastReported.isValid() ? lastReported.toString( Qt::ISODate ) : QString() ); + + const QJsonObject body + { + { QStringLiteral( "telemetry_id" ), telemetryId }, + { QStringLiteral( "timestamp" ), now.toString( Qt::ISODate ) }, + { 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 ); + + QNetworkReply *reply = mNam.post( request, QJsonDocument( body ).toJson( QJsonDocument::Compact ) ); + 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: ignore, data preserved for next attempt + reply->deleteLater(); + } ); +} diff --git a/app/usagereportcontroller.h b/app/usagereportcontroller.h new file mode 100644 index 000000000..24a07a7cd --- /dev/null +++ b/app/usagereportcontroller.h @@ -0,0 +1,55 @@ +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef USAGEREPORTCONTROLLER_H +#define USAGEREPORTCONTROLLER_H + +#include +#include +#include +#include + +class AppSettings; +class LocalProjectsManager; +class MerginApi; + +class UsageReportController : public QObject +{ + Q_OBJECT + + public: + explicit UsageReportController( AppSettings *appSettings, + LocalProjectsManager *localProjectsManager, + MerginApi *merginApi, + QObject *parent = nullptr ); + + void trackFeature( const QString &key ); + void incrementCounter( const QString &key, int amount = 1 ); + void setData( const QString &key, const QVariant &value ); + + void startLoadTimer(); + void recordLoadTime(); + + void trySubmitSnapshot(); + + void startPingTimer(); + + private: + bool isEnabled() const; + + AppSettings *mAppSettings = nullptr; + LocalProjectsManager *mLocalProjectsManager = nullptr; + MerginApi *mMerginApi = nullptr; + + QNetworkAccessManager mNam; + QTimer *mPingTimer = nullptr; + QElapsedTimer mLoadTimer; +}; + +#endif // USAGEREPORTCONTROLLER_H From e6ca5cf7e3c54949fc1649f8dbc552d0b22f81a6 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 25 Sep 2026 13:30:48 +0200 Subject: [PATCH 18/19] Updated CMake file Small changes in the usage reporter controller --- app/CMakeLists.txt | 2 ++ app/usagereportcontroller.cpp | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index ed910b431..df877eb8e 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -92,6 +92,7 @@ set(MM_SRCS streamingintervaltype.cpp synchronizationerror.cpp synchronizationmanager.cpp + usagereportcontroller.cpp valuerelationcontroller.cpp valuerelationfeaturesmodel.cpp variablesmanager.cpp @@ -190,6 +191,7 @@ set(MM_HDRS synchronizationerror.h synchronizationmanager.h synchronizationoptions.h + usagereportcontroller.h valuerelationcontroller.h valuerelationfeaturesmodel.h variablesmanager.h diff --git a/app/usagereportcontroller.cpp b/app/usagereportcontroller.cpp index 5f37a1a38..53f5640a7 100644 --- a/app/usagereportcontroller.cpp +++ b/app/usagereportcontroller.cpp @@ -131,7 +131,7 @@ void UsageReportController::trySubmitSnapshot() if ( telemetryId.isEmpty() ) { telemetryId = CoreUtils::uuidWithoutBraces( QUuid::createUuid() ); - settings.setValue( QStringLiteral( "usage_report/telemetry_id" ), telemetryId ); + settings.setValue( QStringLiteral( "usage_report/device_id" ), telemetryId ); } // Collect static data @@ -223,7 +223,7 @@ void UsageReportController::trySubmitSnapshot() const QJsonObject body { - { QStringLiteral( "telemetry_id" ), telemetryId }, + { QStringLiteral( "device_id" ), telemetryId }, { QStringLiteral( "timestamp" ), now.toString( Qt::ISODate ) }, { QStringLiteral( "properties" ), QJsonObject::fromVariantMap( properties ) } }; From 9e97ea8ac85c6754fa2233f64090b24367c71fdd Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 30 Sep 2026 20:04:30 +0300 Subject: [PATCH 19/19] Added temporary button, drawer for testing Added total ping count --- app/appsettings.cpp | 6 ++++++ app/appsettings.h | 6 ++++++ app/main.cpp | 7 ++++++- app/qml/main.qml | 5 +++-- app/qml/map/MMMapController.qml | 12 ++++++++++++ app/usagereportcontroller.cpp | 22 +++++++++++++++++++--- app/usagereportcontroller.h | 3 +++ 7 files changed, 55 insertions(+), 6 deletions(-) diff --git a/app/appsettings.cpp b/app/appsettings.cpp index bc5204837..bdb9c4c7e 100644 --- a/app/appsettings.cpp +++ b/app/appsettings.cpp @@ -460,3 +460,9 @@ void AppSettings::incrementUsageCounter( const QString &key ) const QString fullKey = QStringLiteral( "usage_report/data/" ) + key; s.setValue( fullKey, s.value( fullKey, 0 ).toInt() + 1 ); } + +// Temporary: request an immediate usage snapshot for testing +void AppSettings::requestImmediateUsageSnapshot() +{ + emit immediateUsageSnapshotRequested(); +} diff --git a/app/appsettings.h b/app/appsettings.h index d12b89802..ee8ff17fb 100644 --- a/app/appsettings.h +++ b/app/appsettings.h @@ -133,6 +133,9 @@ class AppSettings: public QObject Q_INVOKABLE void trackUsageFeature( const QString &key ); Q_INVOKABLE void incrementUsageCounter( const QString &key ); + // Temporary: request an immediate usage snapshot (bypasses interval check) + Q_INVOKABLE void requestImmediateUsageSnapshot(); + public slots: void setReuseLastEnteredValues( bool reuseLastEnteredValues ); @@ -161,6 +164,9 @@ class AppSettings: public QObject void usageReportEnabledChanged( bool enabled ); void usageReportConsentAskedChanged( bool asked ); + // Temporary: emitted when QML requests an immediate snapshot + void immediateUsageSnapshotRequested(); + private: // Projects path QString mDefaultProject; diff --git a/app/main.cpp b/app/main.cpp index 24d64d002..7399c7f84 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -645,6 +645,9 @@ int main( int argc, char *argv[] ) syncManager.syncProject( project, SyncOptions::Authorized, SyncOptions::Retry, requestOrigin ); } ); + // Temporary: immediate snapshot trigger from QML test button + QObject::connect( as, &AppSettings::immediateUsageSnapshotRequested, &usageReportController, &UsageReportController::forceSubmitSnapshot ); + // Gather dynamic usage data via signal connections UsageReportController &urc = usageReportController; @@ -711,8 +714,10 @@ int main( int argc, char *argv[] ) } ); // Highest project role - QObject::connect( &activeProject, &ActiveProject::projectRoleChanged, &lambdaContext, [&urc, &activeProject]() + QObject::connect( &activeProject, &ActiveProject::projectRoleChanged, &lambdaContext, [&urc, as, &activeProject]() { + if ( !as->usageReportEnabled() ) return; + const QString role = activeProject.projectRole(); auto roleRank = []( const QString & r ) -> int { diff --git a/app/qml/main.qml b/app/qml/main.qml index 632c72963..6da3bd679 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -988,8 +988,9 @@ ApplicationWindow { id: usageReportDialog Component.onCompleted: { - if ( !AppSettings.usageReportConsentAsked ) - open() + // Temporary: always show for testing + //if ( !AppSettings.usageReportConsentAsked ) + open() } } diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 264ce7adf..9c71513b2 100644 --- a/app/qml/map/MMMapController.qml +++ b/app/qml/map/MMMapController.qml @@ -881,6 +881,18 @@ Item { } } + // Temporary: test button to send analytics snapshot immediately + MMMapButton { + id: sendAnalyticsButton + + iconSource: __style.uploadIcon + + onClicked: { + AppSettings.requestImmediateUsageSnapshot() + __notificationModel.addSuccess( qsTr( "Usage snapshot sent" ) ) + } + } + MMMapButton { id: gpsButton diff --git a/app/usagereportcontroller.cpp b/app/usagereportcontroller.cpp index 53f5640a7..802aea7e8 100644 --- a/app/usagereportcontroller.cpp +++ b/app/usagereportcontroller.cpp @@ -49,6 +49,7 @@ UsageReportController::UsageReportController( AppSettings *appSettings, QNetworkReply *reply = mNam.head( request ); connect( reply, &QNetworkReply::finished, reply, [this, reply]() { + incrementCounter( QStringLiteral( "ping_total_count" ) ); if ( reply->error() == QNetworkReply::NoError ) incrementCounter( QStringLiteral( "ping_success_count" ) ); else @@ -93,12 +94,13 @@ void UsageReportController::setData( const QString &key, const QVariant &value ) void UsageReportController::startLoadTimer() { + if ( !isEnabled() ) return; mLoadTimer.start(); } void UsageReportController::recordLoadTime() { - if ( !mLoadTimer.isValid() ) return; + if ( !isEnabled() || !mLoadTimer.isValid() ) return; const qint64 elapsed = mLoadTimer.elapsed(); QSettings s; @@ -110,11 +112,24 @@ void UsageReportController::recordLoadTime() void UsageReportController::startPingTimer() { + if ( !isEnabled() ) return; // TODO: check the new endpoint and decide on the interval mPingTimer->start( 5 * 60 * 1000 ); // 5 minutes } +// Temporary: bypass interval check for testing +void UsageReportController::forceSubmitSnapshot() +{ + submitSnapshot( true ); +} + +// Temporary: indirection to support forceSubmitSnapshot — collapse back to trySubmitSnapshot() when removing void UsageReportController::trySubmitSnapshot() +{ + submitSnapshot( false ); +} + +void UsageReportController::submitSnapshot( bool force ) { if ( !isEnabled() ) return; @@ -123,7 +138,7 @@ void UsageReportController::trySubmitSnapshot() 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 ) + if ( !force && lastReported.isValid() && lastReported.secsTo( now ) < USAGE_REPORT_INTERVAL_SECS ) return; // Ensure telemetry UUID exists (separate from the device UUID) @@ -131,7 +146,7 @@ void UsageReportController::trySubmitSnapshot() if ( telemetryId.isEmpty() ) { telemetryId = CoreUtils::uuidWithoutBraces( QUuid::createUuid() ); - settings.setValue( QStringLiteral( "usage_report/device_id" ), telemetryId ); + settings.setValue( QStringLiteral( "usage_report/telemetry_id" ), telemetryId ); } // Collect static data @@ -194,6 +209,7 @@ void UsageReportController::trySubmitSnapshot() properties.insert( QStringLiteral( "workspace_switches" ), 0 ); properties.insert( QStringLiteral( "ping_success_count" ), 0 ); properties.insert( QStringLiteral( "ping_fail_count" ), 0 ); + properties.insert( QStringLiteral( "ping_total_count" ), 0 ); properties.insert( QStringLiteral( "avg_project_load_time_ms" ), 0 ); // String data diff --git a/app/usagereportcontroller.h b/app/usagereportcontroller.h index 24a07a7cd..28fa1bb2b 100644 --- a/app/usagereportcontroller.h +++ b/app/usagereportcontroller.h @@ -37,10 +37,13 @@ class UsageReportController : public QObject void recordLoadTime(); void trySubmitSnapshot(); + // Temporary: bypass interval check for testing + void forceSubmitSnapshot(); void startPingTimer(); private: + void submitSnapshot( bool force ); // Temporary: remove force param when removing forceSubmitSnapshot bool isEnabled() const; AppSettings *mAppSettings = nullptr;