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/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 9d887b7f0..bdb9c4c7e 100644 --- a/app/appsettings.cpp +++ b/app/appsettings.cpp @@ -17,6 +17,13 @@ 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" ), false ).toBool(); + mUsageReportConsentAsked = settings.value( QStringLiteral( "usage_report/consent_asked" ), false ).toBool(); + } + QSettings settings; settings.beginGroup( CoreUtils::QSETTINGS_APP_GROUP_NAME ); const QString path = settings.value( QStringLiteral( "defaultProject" ), "" ).toString(); @@ -391,4 +398,71 @@ 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 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 ); + } + else + { + settings.setValue( QStringLiteral( "usage_report/enabled" ), true ); + } + + 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; + 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 ); +} + +// Temporary: request an immediate usage snapshot for testing +void AppSettings::requestImmediateUsageSnapshot() +{ + emit immediateUsageSnapshotRequested(); +} diff --git a/app/appsettings.h b/app/appsettings.h index 2812a601f..ee8ff17fb 100644 --- a/app/appsettings.h +++ b/app/appsettings.h @@ -39,6 +39,8 @@ 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 ) + Q_PROPERTY( bool usageReportConsentAsked READ usageReportConsentAsked WRITE setUsageReportConsentAsked NOTIFY usageReportConsentAskedChanged ) public: // enum of haptic modes we support @@ -122,6 +124,18 @@ class AppSettings: public QObject StartupBehavior startupBehavior() const; void setStartupBehavior( StartupBehavior startupBehavior ); + 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 ); + + // Temporary: request an immediate usage snapshot (bypasses interval check) + Q_INVOKABLE void requestImmediateUsageSnapshot(); + public slots: void setReuseLastEnteredValues( bool reuseLastEnteredValues ); @@ -147,6 +161,12 @@ class AppSettings: public QObject void windowPositionChanged(); + void usageReportEnabledChanged( bool enabled ); + void usageReportConsentAskedChanged( bool asked ); + + // Temporary: emitted when QML requests an immediate snapshot + void immediateUsageSnapshotRequested(); + private: // Projects path QString mDefaultProject; @@ -183,6 +203,8 @@ class AppSettings: public QObject HapticsType mHapticsType; StartupBehavior mStartupBehavior; + bool mUsageReportEnabled = false; + bool mUsageReportConsentAsked = false; }; #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 54bae9f61..7399c7f84 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -103,6 +104,9 @@ #include "mixedattributevalue.h" #include "photosketchingcontroller.h" #include "mapsketchingcontroller.h" +#include "filter/filtercontroller.h" +#include "autosynccontroller.h" +#include "usagereportcontroller.h" #include "projectsmodel.h" #include "projectsproxymodel.h" @@ -529,6 +533,7 @@ int main( int argc, char *argv[] ) vm->registerInputExpressionFunctions(); SynchronizationManager syncManager( ma.get() ); + UsageReportController usageReportController( as, &localProjectsManager, ma.get() ); LayerTreeModelPixmapProvider *layerTreeModelPixmapProvider( new LayerTreeModelPixmapProvider ); LayerTreeFlatModelPixmapProvider *layerTreeFlatModelPixmapProvider( new LayerTreeFlatModelPixmapProvider ); @@ -640,9 +645,108 @@ int main( int argc, char *argv[] ) syncManager.syncProject( project, SyncOptions::Authorized, SyncOptions::Retry, requestOrigin ); } ); - QObject::connect( &activeProject, &ActiveProject::projectReloaded, &lambdaContext, [merginApi = ma.get(), &activeProject]() + // 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; + + // Auto-sync + QObject::connect( &activeProject, &ActiveProject::autosyncControllerChanged, &lambdaContext, [&urc]( AutosyncController * ac ) + { + if ( !ac ) return; + QObject::connect( ac, &AutosyncController::projectSyncRequested, ac, [&urc]( SyncOptions::RequestOrigin origin ) + { + if ( origin == SyncOptions::AutomaticRequest ) + urc.trackFeature( QStringLiteral( "autosync" ) ); + } ); + } ); + + // Created project + QObject::connect( &pw, &ProjectWizard::projectCreated, &lambdaContext, [&urc]( const QString &, const QString & ) + { + urc.trackFeature( QStringLiteral( "created_project" ) ); + } ); + + // Photo captured vs attached + QObject::connect( &androidUtils, &AndroidUtils::photoCaptured, &lambdaContext, [&urc]() + { + urc.incrementCounter( QStringLiteral( "captured_images" ) ); + } ); + QObject::connect( &androidUtils, &AndroidUtils::photoFromGallery, &lambdaContext, [&urc]() + { + urc.incrementCounter( QStringLiteral( "attached_images" ) ); + } ); + QObject::connect( &iosUtils, &IosUtils::photoCaptured, &lambdaContext, [&urc]() + { + urc.incrementCounter( QStringLiteral( "captured_images" ) ); + } ); + QObject::connect( &iosUtils, &IosUtils::photoFromGallery, &lambdaContext, [&urc]() + { + urc.incrementCounter( QStringLiteral( "attached_images" ) ); + } ); + + // Workspace switches + QObject::connect( ma->userInfo(), &MerginUserInfo::activeWorkspaceChanged, &lambdaContext, [&urc]() + { + urc.incrementCounter( QStringLiteral( "workspace_switches" ) ); + } ); + + // External GPS provider + QObject::connect( pk, &PositionKit::positionProviderChanged, &lambdaContext, [&urc]( AbstractPositionProvider * provider ) + { + if ( !provider ) return; + if ( provider->type() == QLatin1String( "internal" ) ) return; + + urc.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" ); + + urc.setData( QStringLiteral( "external_connection_type" ), connectionType ); + urc.setData( QStringLiteral( "external_name" ), provider->name() ); + } ); + + // Highest project role + QObject::connect( &activeProject, &ActiveProject::projectRoleChanged, &lambdaContext, [&urc, as, &activeProject]() + { + if ( !as->usageReportEnabled() ) return; + + 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 ) ) + urc.setData( QStringLiteral( "highest_role" ), role ); + } ); + + // Project load time + QObject::connect( &activeProject, &ActiveProject::loadingStarted, &lambdaContext, [&urc]() + { + urc.startLoadTimer(); + } ); + + urc.startPingTimer(); + + QObject::connect( &activeProject, &ActiveProject::projectReloaded, &lambdaContext, [merginApi = ma.get(), &activeProject, &urc]() { merginApi->reloadProjectRole( activeProject.projectFullName() ); + urc.recordLoadTime(); } ); QObject::connect( ma.get(), &MerginApi::authChanged, &lambdaContext, [merginApi = ma.get(), &activeProject]() @@ -831,6 +935,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 + usageReportController.trySubmitSnapshot(); + 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/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 new file mode 100644 index 000000000..ca64084a0 --- /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.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" ) + 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/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..6da3bd679 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 @@ -982,6 +984,16 @@ ApplicationWindow { } } + MMUsageReportDialog { + id: usageReportDialog + + Component.onCompleted: { + // Temporary: always show for testing + //if ( !AppSettings.usageReportConsentAsked ) + open() + } + } + Connections { target: __syncManager enabled: stateManager.state === "map" diff --git a/app/qml/map/MMMapController.qml b/app/qml/map/MMMapController.qml index 1abe26c70..9c71513b2 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 } @@ -880,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 @@ -1088,6 +1101,8 @@ Item { id: sketchingController mapSettings: mapCanvas.mapSettings + + onSketched: AppSettings.trackUsageFeature( "map_sketching" ) } MMHighlight { @@ -1394,6 +1409,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 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") diff --git a/app/usagereportcontroller.cpp b/app/usagereportcontroller.cpp new file mode 100644 index 000000000..802aea7e8 --- /dev/null +++ b/app/usagereportcontroller.cpp @@ -0,0 +1,268 @@ +/*************************************************************************** + * * + * 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]() + { + incrementCounter( QStringLiteral( "ping_total_count" ) ); + 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() +{ + if ( !isEnabled() ) return; + mLoadTimer.start(); +} + +void UsageReportController::recordLoadTime() +{ + if ( !isEnabled() || !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() +{ + 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; + + QSettings settings; + const QDateTime lastReported = settings.value( QStringLiteral( "usage_report/last_reported_at" ) ).toDateTime(); + const QDateTime now = QDateTime::currentDateTimeUtc(); + + if ( !force && 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( "ping_total_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( "device_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..28fa1bb2b --- /dev/null +++ b/app/usagereportcontroller.h @@ -0,0 +1,58 @@ +/*************************************************************************** + * * + * 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(); + // 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; + LocalProjectsManager *mLocalProjectsManager = nullptr; + MerginApi *mMerginApi = nullptr; + + QNetworkAccessManager mNam; + QTimer *mPingTimer = nullptr; + QElapsedTimer mLoadTimer; +}; + +#endif // USAGEREPORTCONTROLLER_H