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/appsettings.cpp b/app/appsettings.cpp index e05f3214f..244105f0d 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,47 @@ void AppSettings::setWindowPosition( const QList &newWindowPosition ) setValue( QStringLiteral( "windowPosition" ), QVariant::fromValue( newWindowPosition ) ); emit windowPositionChanged(); -} \ No newline at end of file +} + +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 ); +} + +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 ); +} diff --git a/app/appsettings.h b/app/appsettings.h index 2812a601f..01954412b 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,12 @@ class AppSettings: public QObject StartupBehavior startupBehavior() const; void setStartupBehavior( StartupBehavior startupBehavior ); + 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 ); @@ -147,6 +154,8 @@ class AppSettings: public QObject void windowPositionChanged(); + void usageReportEnabledChanged( bool enabled ); + private: // Projects path QString mDefaultProject; @@ -183,6 +192,7 @@ class AppSettings: public QObject HapticsType mHapticsType; StartupBehavior mStartupBehavior; + bool mUsageReportEnabled = true; }; #endif // APPSETTINGS_H diff --git a/app/ios/iosutils.cpp b/app/ios/iosutils.cpp index c2612978c..cec3d7989 100644 --- a/app/ios/iosutils.cpp +++ b/app/ios/iosutils.cpp @@ -18,6 +18,12 @@ IosUtils::IosUtils( QObject *parent ): QObject( parent ) QObject::connect( mImagePicker, &IOSImagePicker::imageCaptured, this, [this]( const QString & absoluteImagePath ) { emit imageSelected( absoluteImagePath, mLastCode ); + if ( mLastCode.isEmpty() ) + return; + if ( mLastSourceWasCamera ) + emit photoCaptured(); + else + emit photoFromGallery(); } ); QObject::connect( mImagePicker, &IOSImagePicker::notifyError, this, &IosUtils::notifyError ); } @@ -34,12 +40,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 81edbcbec..dc77b25f8 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -9,8 +9,16 @@ #include "mmconfig.h" +#include +#include #include +#include #include +#include +#include +#include +#include +#include #include #include #include @@ -103,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" @@ -387,6 +397,153 @@ 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 ); @@ -529,6 +686,7 @@ int main( int argc, char *argv[] ) vm->registerInputExpressionFunctions(); SynchronizationManager syncManager( ma.get() ); + QNetworkAccessManager usageReportNam; LayerTreeModelPixmapProvider *layerTreeModelPixmapProvider( new LayerTreeModelPixmapProvider ); LayerTreeFlatModelPixmapProvider *layerTreeFlatModelPixmapProvider( new LayerTreeFlatModelPixmapProvider ); @@ -633,9 +791,164 @@ int main( int argc, char *argv[] ) syncManager.syncProject( project, SyncOptions::Authorized, SyncOptions::Retry, requestOrigin ); } ); - QObject::connect( &activeProject, &ActiveProject::projectReloaded, &lambdaContext, [merginApi = ma.get(), &activeProject]() + // 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 ); + }; + + // 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(); + } ); + + // 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]() + { + 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]() @@ -830,6 +1143,9 @@ int main( int argc, char *argv[] ) QQmlComponent component( &engine, QUrl( "qrc:/com.merginmaps/imports/MMInput/main.qml" ) ); QObject *object = component.create(); + // Attempt weekly snapshot + trySubmitUsageSnapshot( &usageReportNam, as, localProjectsManager, ma.get() ); + if ( !component.errors().isEmpty() ) { qDebug( "%s", QgsApplication::showSettings().toLocal8Bit().data() ); 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(); 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/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/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() } diff --git a/app/qml/main.qml b/app/qml/main.qml index 340f88280..3b31f5cce 100644 --- a/app/qml/main.qml +++ b/app/qml/main.qml @@ -703,6 +703,8 @@ ApplicationWindow { width: window.width onEditSelected: { + if ( selectedCount > 1 ) + AppSettings.trackUsageFeature( "bulk_editing" ) let pair = map.multiEditManager.editableFeature() formsStackManager.openForm( pair, selectedCount === 1 ? "edit" : "multiEdit", "form" ); multiSelectPanel.formOpened = true diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 10148c916..30b4eeae4 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 } @@ -1088,6 +1089,8 @@ Item { id: sketchingController mapSettings: mapCanvas.mapSettings + + onSketched: AppSettings.trackUsageFeature( "map_sketching" ) } MMHighlight { @@ -1394,6 +1397,7 @@ Item { root.centeredToGPS = true internal.stakeoutTarget = featurepair state = "stakeout" + AppSettings.trackUsageFeature( "stakeout" ) } function measure() { diff --git a/app/qml/settings/MMSettingsPage.qml b/app/qml/settings/MMSettingsPage.qml index 833405f51..a7e1158d0 100644 --- a/app/qml/settings/MMSettingsPage.qml +++ b/app/qml/settings/MMSettingsPage.qml @@ -290,6 +290,17 @@ MMPage { MMLine {} + MMSettingsComponents.MMSettingsSwitch { + width: parent.width + title: qsTr("Send anonymous usage data") + description: qsTr("Helps us understand which features are used so we can improve the app. No personal data is collected.") + checked: AppSettings.usageReportEnabled + + onClicked: AppSettings.usageReportEnabled = !checked + } + + MMLine {} + MMSettingsComponents.MMSettingsItem { width: parent.width title: qsTr("Diagnostic log")