diff --git a/scripts/langindex.json b/scripts/langindex.json index a0e8aad4b..8a607bd94 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2347,9 +2347,9 @@ "core.settings.disabled": "lesson", "core.settings.disallowed": "message", "core.settings.displayformat": "local_moodlemobileapp", + "core.settings.enableanalytics": "local_moodlemobileapp", + "core.settings.enableanalyticsdescription": "local_moodlemobileapp", "core.settings.enabledownloadsection": "local_moodlemobileapp", - "core.settings.enablefirebaseanalytics": "local_moodlemobileapp", - "core.settings.enablefirebaseanalyticsdescription": "local_moodlemobileapp", "core.settings.enablerichtexteditor": "local_moodlemobileapp", "core.settings.enablerichtexteditordescription": "local_moodlemobileapp", "core.settings.encryptedpushsupported": "local_moodlemobileapp", diff --git a/src/core/classes/delegate.ts b/src/core/classes/delegate.ts index 799f0cc17..adc2023f0 100644 --- a/src/core/classes/delegate.ts +++ b/src/core/classes/delegate.ts @@ -207,6 +207,15 @@ export class CoreDelegate { return enabled ? this.enabledHandlers[name] !== undefined : this.handlers[name] !== undefined; } + /** + * Check if the delegate has at least 1 registered handler (not necessarily enabled). + * + * @returns If there is at least 1 handler. + */ + hasHandlers(): boolean { + return Object.keys(this.handlers).length > 0; + } + /** * Check if a time belongs to the last update handlers call. * This is to handle the cases where updateHandlers don't finish in the same order as they're called. diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 0fe84afb1..84ade6ecc 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -33,7 +33,7 @@ import { import { CoreDomUtils, ToastDuration } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; -import { CoreUrlUtils, CoreUrlParams } from '@services/utils/url'; +import { CoreUrlUtils } from '@services/utils/url'; import { CoreUtils, CoreUtilsOpenInBrowserOptions } from '@services/utils/utils'; import { CoreConstants } from '@/core/constants'; import { SQLiteDB } from '@classes/sqlitedb'; @@ -63,6 +63,7 @@ import { firstValueFrom } from '../utils/rxjs'; import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config'; import { CoreLoginHelper } from '@features/login/services/login-helper'; +import { CorePath } from '@singletons/path'; /** * QR Code type enumeration. @@ -1598,8 +1599,8 @@ export class CoreSite { * @param anchor Anchor text if needed. * @returns URL with params. */ - createSiteUrl(path: string, params?: CoreUrlParams, anchor?: string): string { - return CoreUrlUtils.addParamsToUrl(this.siteUrl + path, params, anchor); + createSiteUrl(path: string, params?: Record, anchor?: string): string { + return CoreUrlUtils.addParamsToUrl(CorePath.concatenatePaths(this.siteUrl, path), params, anchor); } /** diff --git a/src/core/features/course/services/log-helper.ts b/src/core/features/course/services/log-helper.ts index 83b2ac5be..052149036 100644 --- a/src/core/features/course/services/log-helper.ts +++ b/src/core/features/course/services/log-helper.ts @@ -19,7 +19,6 @@ import { CoreSites } from '@services/sites'; import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; -import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; import { makeSingleton } from '@singletons'; import { ACTIVITY_LOG_TABLE, CoreCourseActivityLogDBRecord } from './database/log'; import { CoreStatusWithWarningsWSResponse } from '@services/ws'; @@ -190,7 +189,6 @@ export class CoreCourseLogHelperProvider { /** * Perform log online. Data will be saved offline for syncing. - * It also triggers a Firebase view_item event. * * @param ws WS name. * @param data Data to send to the WS. @@ -198,9 +196,10 @@ export class CoreCourseLogHelperProvider { * @param componentId Component ID. * @param name Name of the viewed item. * @param category Category of the viewed item. - * @param eventData Data to pass to the Firebase event. + * @param eventData Data to pass to the analytics event. * @param siteId Site ID. If not defined, current site. * @returns Promise resolved when done. + * @deprecated since 4.3. Please use CoreCourseLogHelper.log instead. */ logSingle( ws: string, @@ -209,26 +208,24 @@ export class CoreCourseLogHelperProvider { componentId: number, name?: string, category?: string, - eventData?: Record, + eventData?: Record, siteId?: string, ): Promise { - CorePushNotifications.logViewEvent(componentId, name, category, ws, eventData, siteId); - return this.log(ws, data, component, componentId, siteId); } /** * Perform log online. Data will be saved offline for syncing. - * It also triggers a Firebase view_item_list event. * * @param ws WS name. * @param data Data to send to the WS. * @param component Component name. * @param componentId Component ID. * @param category Category of the viewed item. - * @param eventData Data to pass to the Firebase event. + * @param eventData Data to pass to the analytics event. * @param siteId Site ID. If not defined, current site. * @returns Promise resolved when done. + * @deprecated since 4.3. Please use CoreCourseLogHelper.log instead. */ logList( ws: string, @@ -236,11 +233,9 @@ export class CoreCourseLogHelperProvider { component: string, componentId: number, category: string, - eventData?: Record, + eventData?: Record, siteId?: string, ): Promise { - CorePushNotifications.logViewListEvent(category, ws, eventData, siteId); - return this.log(ws, data, component, componentId, siteId); } diff --git a/src/core/features/pushnotifications/services/pushnotifications.ts b/src/core/features/pushnotifications/services/pushnotifications.ts index 234e54825..b5b656d6c 100644 --- a/src/core/features/pushnotifications/services/pushnotifications.ts +++ b/src/core/features/pushnotifications/services/pushnotifications.ts @@ -47,6 +47,7 @@ import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/da import { CoreObject } from '@singletons/object'; import { lazyMap, LazyMap } from '@/core/utils/lazy-map'; import { CorePlatform } from '@services/platform'; +import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; /** * Service to handle push notifications. @@ -133,8 +134,11 @@ export class CorePushNotificationsProvider { CoreLocalNotifications.registerClick( CorePushNotificationsProvider.COMPONENT, (notification) => { - // Log notification open event. - this.logEvent('moodle_notification_open', notification, true); + CoreAnalytics.logEvent({ + eventName: 'moodle_notification_open', + type: CoreAnalyticsEventType.PUSH_NOTIFICATION, + data: notification, + }); this.notificationClicked(notification); }, @@ -145,8 +149,11 @@ export class CorePushNotificationsProvider { 'clear', CorePushNotificationsProvider.COMPONENT, (notification) => { - // Log notification dismissed event. - this.logEvent('moodle_notification_dismiss', notification, true); + CoreAnalytics.logEvent({ + eventName: 'moodle_notification_dismiss', + type: CoreAnalyticsEventType.PUSH_NOTIFICATION, + data: notification, + }); }, ); } @@ -248,26 +255,14 @@ export class CorePushNotificationsProvider { } /** - * Enable or disable Firebase analytics. + * Enable or disable analytics. * * @param enable Whether to enable or disable. * @returns Promise resolved when done. + * @deprecated since 4.3. Use CoreAnalytics.enableAnalytics instead. */ async enableAnalytics(enable: boolean): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const win = window; // This feature is only present in our fork of the plugin. - - if (!CoreConstants.CONFIG.enableanalytics || !win.PushNotification?.enableAnalytics) { - return; - } - - await new Promise(resolve => { - win.PushNotification.enableAnalytics(resolve, (error) => { - this.logger.error('Error enabling or disabling Firebase analytics', enable, error); - - resolve(); - }, !!enable); - }); + return CoreAnalytics.enableAnalytics(enable); } /** @@ -340,37 +335,35 @@ export class CorePushNotificationsProvider { } /** - * Log a firebase event. + * Log an analytics event. * - * @param name Name of the event. + * @param eventName Name of the event. * @param data Data of the event. - * @param filter Whether to filter the data. This is useful when logging a full notification. * @returns Promise resolved when done. This promise is never rejected. + * @deprecated since 4.3. Use CoreAnalytics.logEvent instead. */ - async logEvent(name: string, data: Record, filter?: boolean): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const win = window; // This feature is only present in our fork of the plugin. - - if (!CoreConstants.CONFIG.enableanalytics || !win.PushNotification?.logEvent) { - return; + async logEvent(eventName: string, data: Record): Promise { + if (eventName !== 'view_item' && eventName !== 'view_item_list') { + return CoreAnalytics.logEvent({ + type: CoreAnalyticsEventType.PUSH_NOTIFICATION, + eventName, + data, + }); } - // Check if the analytics is enabled by the user. - const enabled = await CoreConfig.get(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true); - if (!enabled) { - return; - } + const name = data.name ? String(data.name) : ''; + delete data.name; - await new Promise(resolve => { - win.PushNotification.logEvent(resolve, (error) => { - this.logger.error('Error logging firebase event', name, error); - resolve(); - }, name, data, !!filter); + return CoreAnalytics.logEvent({ + type: eventName === 'view_item' ? CoreAnalyticsEventType.VIEW_ITEM : CoreAnalyticsEventType.VIEW_ITEM_LIST, + ws: data.moodleaction ?? '', + name, + data, }); } /** - * Log a firebase view_item event. + * Log an analytics VIEW_ITEM_LIST event. * * @param itemId The item ID. * @param itemName The item name. @@ -379,57 +372,46 @@ export class CorePushNotificationsProvider { * @param data Other data to pass to the event. * @param siteId Site ID. If not defined, current site. * @returns Promise resolved when done. This promise is never rejected. + * @deprecated since 4.3. Use CoreAnalytics.logEvent instead. */ logViewEvent( itemId: number | string | undefined, itemName: string | undefined, itemCategory: string | undefined, wsName: string, - data?: Record, + data?: Record, siteId?: string, ): Promise { data = data || {}; - - // Add "moodle" to the name of all extra params. - data = CoreUtils.prefixKeys(data, 'moodle'); + data.id = itemId; + data.name = itemName; + data.category = itemCategory; data.moodleaction = wsName; - data.moodlesiteid = siteId || CoreSites.getCurrentSiteId(); - if (itemId) { - data.item_id = itemId; - } - if (itemName) { - data.item_name = itemName; - } - if (itemCategory) { - data.item_category = itemCategory; - } - - return this.logEvent('view_item', data, false); + return this.logEvent('view_item', data); } /** - * Log a firebase view_item_list event. + * Log an analytics view item list event. * * @param itemCategory The item category. * @param wsName Name of the WS. * @param data Other data to pass to the event. * @param siteId Site ID. If not defined, current site. * @returns Promise resolved when done. This promise is never rejected. + * @deprecated since 4.3. Use CoreAnalytics.logEvent instead. */ - logViewListEvent(itemCategory: string, wsName: string, data?: Record, siteId?: string): Promise { + logViewListEvent( + itemCategory: string, + wsName: string, + data?: Record, + siteId?: string, + ): Promise { data = data || {}; - - // Add "moodle" to the name of all extra params. - data = CoreUtils.prefixKeys(data, 'moodle'); data.moodleaction = wsName; - data.moodlesiteid = siteId || CoreSites.getCurrentSiteId(); + data.category = itemCategory; - if (itemCategory) { - data.item_category = itemCategory; - } - - return this.logEvent('view_item_list', data, false); + return this.logEvent('view_item_list', data); } /** diff --git a/src/core/features/settings/lang.json b/src/core/features/settings/lang.json index 2680792cb..5af24834b 100644 --- a/src/core/features/settings/lang.json +++ b/src/core/features/settings/lang.json @@ -32,9 +32,9 @@ "disabled": "Disabled", "disallowed": "Locked off", "displayformat": "Display format", + "enableanalytics": "Enable analytics", + "enableanalyticsdescription": "If enabled, the app will collect anonymous data usage.", "enabledownloadsection": "Enable download sections", - "enablefirebaseanalytics": "Enable Firebase analytics", - "enablefirebaseanalyticsdescription": "If enabled, the app will collect anonymous data usage.", "enablerichtexteditor": "Enable text editor", "enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.", "encryptedpushsupported": "Encrypted push notifications supported", diff --git a/src/core/features/settings/pages/general/general.html b/src/core/features/settings/pages/general/general.html index 9cc006e57..6be8e054e 100644 --- a/src/core/features/settings/pages/general/general.html +++ b/src/core/features/settings/pages/general/general.html @@ -76,8 +76,8 @@ -

{{ 'core.settings.enablefirebaseanalytics' | translate }}

-

{{ 'core.settings.enablefirebaseanalyticsdescription' | translate }}

+

{{ 'core.settings.enableanalytics' | translate }}

+

{{ 'core.settings.enableanalyticsdescription' | translate }}

diff --git a/src/core/features/settings/pages/general/general.ts b/src/core/features/settings/pages/general/general.ts index 874028c8e..88a498b77 100644 --- a/src/core/features/settings/pages/general/general.ts +++ b/src/core/features/settings/pages/general/general.ts @@ -27,6 +27,7 @@ import { CoreUtils } from '@services/utils/utils'; import { AlertButton } from '@ionic/angular'; import { CoreNavigator } from '@services/navigator'; import { CorePlatform } from '@services/platform'; +import { CoreAnalytics } from '@services/analytics'; /** * Page that displays the general settings. @@ -101,7 +102,7 @@ export class CoreSettingsGeneralPage { this.debugDisplay = await CoreConfig.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false); - this.analyticsSupported = CoreConstants.CONFIG.enableanalytics; + this.analyticsSupported = CoreAnalytics.hasHandlers(); if (this.analyticsSupported) { this.analyticsEnabled = await CoreConfig.get(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true); } diff --git a/src/core/services/analytics.ts b/src/core/services/analytics.ts new file mode 100644 index 000000000..4d79a9a54 --- /dev/null +++ b/src/core/services/analytics.ts @@ -0,0 +1,160 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { makeSingleton } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { CoreSites } from './sites'; +import { CoreConfig, CoreConfigProvider } from './config'; +import { CoreConstants } from '../constants'; +import { CoreUrlUtils } from './utils/url'; + +/** + * Helper service to support analytics. + */ +@Injectable({ providedIn: 'root' }) +export class CoreAnalyticsService extends CoreDelegate { + + constructor() { + super('CoreAnalyticsService', true); + + CoreEvents.on(CoreConfigProvider.ENVIRONMENT_UPDATED, () => this.updateHandlers()); + CoreEvents.on(CoreEvents.LOGOUT, () => this.clearSiteHandlers()); + } + + /** + * Clear current site handlers. Reserved for core use. + */ + protected clearSiteHandlers(): void { + this.enabledHandlers = {}; + } + + /** + * Enable or disable analytics for all handlers. + * + * @param enable Whether to enable or disable. + * @returns Promise resolved when done. + */ + async enableAnalytics(enable: boolean): Promise { + try { + await Promise.all(Object.values(this.handlers).map(handler => handler.enableAnalytics?.(enable))); + } catch (error) { + this.logger.error(`Error ${enable ? 'enabling' : 'disabling'} analytics`, error); + } + } + + /** + * Log an event for the current site. + * + * @param event Event data. + */ + async logEvent(event: CoreAnalyticsViewEvent | CoreAnalyticsPushEvent): Promise { + const site = CoreSites.getCurrentSite(); + if (!site) { + return; + } + + // Check if analytics is enabled by the user. + const enabled = await CoreConfig.get(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true); + if (!enabled) { + return; + } + + const treatedEvent: CoreAnalyticsEvent = { + ...event, + siteId: site.getId(), + }; + if ('url' in treatedEvent && treatedEvent.url) { + if (!CoreUrlUtils.isAbsoluteURL(treatedEvent.url)) { + treatedEvent.url = site.createSiteUrl(treatedEvent.url); + } else if (!site.containsUrl(treatedEvent.url)) { + // URL belongs to a different site, ignore the event. + return; + } + } + + try { + await Promise.all(Object.values(this.enabledHandlers).map(handler => handler.logEvent(treatedEvent))); + } catch (error) { + this.logger.error('Error logging event', event, error); + } + } + +} + +export const CoreAnalytics = makeSingleton(CoreAnalyticsService); + +/** + * Interface that all analytics handlers must implement. + */ +export interface CoreAnalyticsHandler extends CoreDelegateHandler { + + /** + * Log an event. + * + * @param event Event data. + */ + logEvent(event: CoreAnalyticsEvent): Promise; + + /** + * Enable or disable analytics. + * + * @param enable Whether to enable or disable. + * @returns Promise resolved when done. + */ + enableAnalytics?(enable: boolean): Promise; + +} + +/** + * Possible types of events. + */ +export enum CoreAnalyticsEventType { + VIEW_ITEM = 'view_item', // View some page or data that mainly contains one item. + VIEW_ITEM_LIST = 'view_item_list', // View some page or data that mainly contains a list of items. + PUSH_NOTIFICATION = 'push_notification', // Event related to push notifications. +} + +/** + * Event data, including calculated data. + */ +export type CoreAnalyticsEvent = (CoreAnalyticsViewEvent | CoreAnalyticsPushEvent) & { + siteId: string; +}; + +/** + * Data specific for the VIEW_ITEM and VIEW_LIST events. + */ +export type CoreAnalyticsViewEvent = { + type: CoreAnalyticsEventType.VIEW_ITEM | CoreAnalyticsEventType.VIEW_ITEM_LIST; + ws: string; // Name of the WS used to log the data in LMS or to obtain the data if there is no log WS. + name: string; // Name of the item or page viewed. + url?: string; // Moodle URL. You can use the URL without the domain, e.g. /mod/foo/view.php. + data?: { + id?: number | string; // ID of the item viewed (if any). + category?: string; // Category of the data viewed (if any). + [key: string]: string | number | boolean | undefined; + }; +}; + +/** + * Data specific for the PUSH_NOTIFICATION events. + */ +export type CoreAnalyticsPushEvent = { + type: CoreAnalyticsEventType.PUSH_NOTIFICATION; + eventName: string; // Name of the event. + data: CorePushNotificationsNotificationBasicData; +}; diff --git a/src/types/config.d.ts b/src/types/config.d.ts index cd32bce2d..49743638d 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -50,7 +50,6 @@ export interface EnvironmentConfig { forcedefaultlanguage: boolean; privacypolicy: string; notificoncolor: string; - enableanalytics: boolean; enableonboarding: boolean; forceColorScheme: CoreColorScheme; forceLoginLogo: boolean; diff --git a/upgrade.txt b/upgrade.txt index e0b022b49..06cc09197 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -5,6 +5,7 @@ information provided here is intended especially for developers. - CoreSiteBasicInfo fullName attribute has changed to fullname and avatar to userpictureurl to match user fields. - Font Awesome icon library has been updated to 6.4.0. But nothing has changed, only version number. + - The analytics system in the app has been refactored and some functions that could trigger analytics calls no longer do it, now you need to use CoreAnalytics instead. Some functions in CoreCourseLogHelper and CorePushNotificationsProvider have been deprecated. === 4.2.0 ===