MOBILE-4368 core: Implement CoreAnalytics service

main
Dani Palou 2023-06-22 11:08:28 +02:00 committed by Alfonso Salces
parent 718f95b403
commit f9eb1f8462
11 changed files with 236 additions and 88 deletions

View File

@ -2347,9 +2347,9 @@
"core.settings.disabled": "lesson", "core.settings.disabled": "lesson",
"core.settings.disallowed": "message", "core.settings.disallowed": "message",
"core.settings.displayformat": "local_moodlemobileapp", "core.settings.displayformat": "local_moodlemobileapp",
"core.settings.enableanalytics": "local_moodlemobileapp",
"core.settings.enableanalyticsdescription": "local_moodlemobileapp",
"core.settings.enabledownloadsection": "local_moodlemobileapp", "core.settings.enabledownloadsection": "local_moodlemobileapp",
"core.settings.enablefirebaseanalytics": "local_moodlemobileapp",
"core.settings.enablefirebaseanalyticsdescription": "local_moodlemobileapp",
"core.settings.enablerichtexteditor": "local_moodlemobileapp", "core.settings.enablerichtexteditor": "local_moodlemobileapp",
"core.settings.enablerichtexteditordescription": "local_moodlemobileapp", "core.settings.enablerichtexteditordescription": "local_moodlemobileapp",
"core.settings.encryptedpushsupported": "local_moodlemobileapp", "core.settings.encryptedpushsupported": "local_moodlemobileapp",

View File

@ -207,6 +207,15 @@ export class CoreDelegate<HandlerType extends CoreDelegateHandler> {
return enabled ? this.enabledHandlers[name] !== undefined : this.handlers[name] !== undefined; 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. * 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. * This is to handle the cases where updateHandlers don't finish in the same order as they're called.

View File

@ -33,7 +33,7 @@ import {
import { CoreDomUtils, ToastDuration } from '@services/utils/dom'; import { CoreDomUtils, ToastDuration } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time'; 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 { CoreUtils, CoreUtilsOpenInBrowserOptions } from '@services/utils/utils';
import { CoreConstants } from '@/core/constants'; import { CoreConstants } from '@/core/constants';
import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDB } from '@classes/sqlitedb';
@ -63,6 +63,7 @@ import { firstValueFrom } from '../utils/rxjs';
import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreSiteError } from '@classes/errors/siteerror';
import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config'; import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config';
import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreLoginHelper } from '@features/login/services/login-helper';
import { CorePath } from '@singletons/path';
/** /**
* QR Code type enumeration. * QR Code type enumeration.
@ -1598,8 +1599,8 @@ export class CoreSite {
* @param anchor Anchor text if needed. * @param anchor Anchor text if needed.
* @returns URL with params. * @returns URL with params.
*/ */
createSiteUrl(path: string, params?: CoreUrlParams, anchor?: string): string { createSiteUrl(path: string, params?: Record<string, unknown>, anchor?: string): string {
return CoreUrlUtils.addParamsToUrl(this.siteUrl + path, params, anchor); return CoreUrlUtils.addParamsToUrl(CorePath.concatenatePaths(this.siteUrl, path), params, anchor);
} }
/** /**

View File

@ -19,7 +19,6 @@ import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time'; import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { ACTIVITY_LOG_TABLE, CoreCourseActivityLogDBRecord } from './database/log'; import { ACTIVITY_LOG_TABLE, CoreCourseActivityLogDBRecord } from './database/log';
import { CoreStatusWithWarningsWSResponse } from '@services/ws'; import { CoreStatusWithWarningsWSResponse } from '@services/ws';
@ -190,7 +189,6 @@ export class CoreCourseLogHelperProvider {
/** /**
* Perform log online. Data will be saved offline for syncing. * Perform log online. Data will be saved offline for syncing.
* It also triggers a Firebase view_item event.
* *
* @param ws WS name. * @param ws WS name.
* @param data Data to send to the WS. * @param data Data to send to the WS.
@ -198,9 +196,10 @@ export class CoreCourseLogHelperProvider {
* @param componentId Component ID. * @param componentId Component ID.
* @param name Name of the viewed item. * @param name Name of the viewed item.
* @param category Category 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. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done. * @returns Promise resolved when done.
* @deprecated since 4.3. Please use CoreCourseLogHelper.log instead.
*/ */
logSingle( logSingle(
ws: string, ws: string,
@ -209,26 +208,24 @@ export class CoreCourseLogHelperProvider {
componentId: number, componentId: number,
name?: string, name?: string,
category?: string, category?: string,
eventData?: Record<string, unknown>, eventData?: Record<string, string | number | boolean | undefined>,
siteId?: string, siteId?: string,
): Promise<void> { ): Promise<void> {
CorePushNotifications.logViewEvent(componentId, name, category, ws, eventData, siteId);
return this.log(ws, data, component, componentId, siteId); return this.log(ws, data, component, componentId, siteId);
} }
/** /**
* Perform log online. Data will be saved offline for syncing. * Perform log online. Data will be saved offline for syncing.
* It also triggers a Firebase view_item_list event.
* *
* @param ws WS name. * @param ws WS name.
* @param data Data to send to the WS. * @param data Data to send to the WS.
* @param component Component name. * @param component Component name.
* @param componentId Component ID. * @param componentId Component ID.
* @param category Category 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. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done. * @returns Promise resolved when done.
* @deprecated since 4.3. Please use CoreCourseLogHelper.log instead.
*/ */
logList( logList(
ws: string, ws: string,
@ -236,11 +233,9 @@ export class CoreCourseLogHelperProvider {
component: string, component: string,
componentId: number, componentId: number,
category: string, category: string,
eventData?: Record<string, unknown>, eventData?: Record<string, string | number | boolean | undefined>,
siteId?: string, siteId?: string,
): Promise<void> { ): Promise<void> {
CorePushNotifications.logViewListEvent(category, ws, eventData, siteId);
return this.log(ws, data, component, componentId, siteId); return this.log(ws, data, component, componentId, siteId);
} }

View File

@ -47,6 +47,7 @@ import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/da
import { CoreObject } from '@singletons/object'; import { CoreObject } from '@singletons/object';
import { lazyMap, LazyMap } from '@/core/utils/lazy-map'; import { lazyMap, LazyMap } from '@/core/utils/lazy-map';
import { CorePlatform } from '@services/platform'; import { CorePlatform } from '@services/platform';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
/** /**
* Service to handle push notifications. * Service to handle push notifications.
@ -133,8 +134,11 @@ export class CorePushNotificationsProvider {
CoreLocalNotifications.registerClick<CorePushNotificationsNotificationBasicData>( CoreLocalNotifications.registerClick<CorePushNotificationsNotificationBasicData>(
CorePushNotificationsProvider.COMPONENT, CorePushNotificationsProvider.COMPONENT,
(notification) => { (notification) => {
// Log notification open event. CoreAnalytics.logEvent({
this.logEvent('moodle_notification_open', notification, true); eventName: 'moodle_notification_open',
type: CoreAnalyticsEventType.PUSH_NOTIFICATION,
data: notification,
});
this.notificationClicked(notification); this.notificationClicked(notification);
}, },
@ -145,8 +149,11 @@ export class CorePushNotificationsProvider {
'clear', 'clear',
CorePushNotificationsProvider.COMPONENT, CorePushNotificationsProvider.COMPONENT,
(notification) => { (notification) => {
// Log notification dismissed event. CoreAnalytics.logEvent({
this.logEvent('moodle_notification_dismiss', notification, true); 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. * @param enable Whether to enable or disable.
* @returns Promise resolved when done. * @returns Promise resolved when done.
* @deprecated since 4.3. Use CoreAnalytics.enableAnalytics instead.
*/ */
async enableAnalytics(enable: boolean): Promise<void> { async enableAnalytics(enable: boolean): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any return CoreAnalytics.enableAnalytics(enable);
const win = <any> window; // This feature is only present in our fork of the plugin.
if (!CoreConstants.CONFIG.enableanalytics || !win.PushNotification?.enableAnalytics) {
return;
}
await new Promise<void>(resolve => {
win.PushNotification.enableAnalytics(resolve, (error) => {
this.logger.error('Error enabling or disabling Firebase analytics', enable, error);
resolve();
}, !!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 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. * @returns Promise resolved when done. This promise is never rejected.
* @deprecated since 4.3. Use CoreAnalytics.logEvent instead.
*/ */
async logEvent(name: string, data: Record<string, unknown>, filter?: boolean): Promise<void> { async logEvent(eventName: string, data: Record<string, string | number | boolean | undefined>): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (eventName !== 'view_item' && eventName !== 'view_item_list') {
const win = <any> window; // This feature is only present in our fork of the plugin. return CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.PUSH_NOTIFICATION,
if (!CoreConstants.CONFIG.enableanalytics || !win.PushNotification?.logEvent) { eventName,
return; data,
});
} }
// Check if the analytics is enabled by the user. const name = data.name ? String(data.name) : '';
const enabled = await CoreConfig.get<boolean>(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true); delete data.name;
if (!enabled) {
return;
}
await new Promise<void>(resolve => { return CoreAnalytics.logEvent({
win.PushNotification.logEvent(resolve, (error) => { type: eventName === 'view_item' ? CoreAnalyticsEventType.VIEW_ITEM : CoreAnalyticsEventType.VIEW_ITEM_LIST,
this.logger.error('Error logging firebase event', name, error); ws: <string> data.moodleaction ?? '',
resolve(); name,
}, name, data, !!filter); data,
}); });
} }
/** /**
* Log a firebase view_item event. * Log an analytics VIEW_ITEM_LIST event.
* *
* @param itemId The item ID. * @param itemId The item ID.
* @param itemName The item name. * @param itemName The item name.
@ -379,57 +372,46 @@ export class CorePushNotificationsProvider {
* @param data Other data to pass to the event. * @param data Other data to pass to the event.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done. This promise is never rejected. * @returns Promise resolved when done. This promise is never rejected.
* @deprecated since 4.3. Use CoreAnalytics.logEvent instead.
*/ */
logViewEvent( logViewEvent(
itemId: number | string | undefined, itemId: number | string | undefined,
itemName: string | undefined, itemName: string | undefined,
itemCategory: string | undefined, itemCategory: string | undefined,
wsName: string, wsName: string,
data?: Record<string, unknown>, data?: Record<string, string | number | boolean | undefined>,
siteId?: string, siteId?: string,
): Promise<void> { ): Promise<void> {
data = data || {}; data = data || {};
data.id = itemId;
// Add "moodle" to the name of all extra params. data.name = itemName;
data = CoreUtils.prefixKeys(data, 'moodle'); data.category = itemCategory;
data.moodleaction = wsName; data.moodleaction = wsName;
data.moodlesiteid = siteId || CoreSites.getCurrentSiteId();
if (itemId) { return this.logEvent('view_item', data);
data.item_id = itemId;
}
if (itemName) {
data.item_name = itemName;
}
if (itemCategory) {
data.item_category = itemCategory;
}
return this.logEvent('view_item', data, false);
} }
/** /**
* Log a firebase view_item_list event. * Log an analytics view item list event.
* *
* @param itemCategory The item category. * @param itemCategory The item category.
* @param wsName Name of the WS. * @param wsName Name of the WS.
* @param data Other data to pass to the event. * @param data Other data to pass to the event.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @returns Promise resolved when done. This promise is never rejected. * @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<string, unknown>, siteId?: string): Promise<void> { logViewListEvent(
itemCategory: string,
wsName: string,
data?: Record<string, string | number | boolean | undefined>,
siteId?: string,
): Promise<void> {
data = data || {}; data = data || {};
// Add "moodle" to the name of all extra params.
data = CoreUtils.prefixKeys(data, 'moodle');
data.moodleaction = wsName; data.moodleaction = wsName;
data.moodlesiteid = siteId || CoreSites.getCurrentSiteId(); data.category = itemCategory;
if (itemCategory) { return this.logEvent('view_item_list', data);
data.item_category = itemCategory;
}
return this.logEvent('view_item_list', data, false);
} }
/** /**

View File

@ -32,9 +32,9 @@
"disabled": "Disabled", "disabled": "Disabled",
"disallowed": "Locked off", "disallowed": "Locked off",
"displayformat": "Display format", "displayformat": "Display format",
"enableanalytics": "Enable analytics",
"enableanalyticsdescription": "If enabled, the app will collect anonymous data usage.",
"enabledownloadsection": "Enable download sections", "enabledownloadsection": "Enable download sections",
"enablefirebaseanalytics": "Enable Firebase analytics",
"enablefirebaseanalyticsdescription": "If enabled, the app will collect anonymous data usage.",
"enablerichtexteditor": "Enable text editor", "enablerichtexteditor": "Enable text editor",
"enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.", "enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.",
"encryptedpushsupported": "Encrypted push notifications supported", "encryptedpushsupported": "Encrypted push notifications supported",

View File

@ -76,8 +76,8 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="analyticsSupported"> <ion-item class="ion-text-wrap" *ngIf="analyticsSupported">
<ion-label> <ion-label>
<p class="item-heading">{{ 'core.settings.enablefirebaseanalytics' | translate }}</p> <p class="item-heading">{{ 'core.settings.enableanalytics' | translate }}</p>
<p>{{ 'core.settings.enablefirebaseanalyticsdescription' | translate }}</p> <p>{{ 'core.settings.enableanalyticsdescription' | translate }}</p>
</ion-label> </ion-label>
<ion-toggle [(ngModel)]="analyticsEnabled" (ionChange)="analyticsEnabledChanged($event)"></ion-toggle> <ion-toggle [(ngModel)]="analyticsEnabled" (ionChange)="analyticsEnabledChanged($event)"></ion-toggle>
</ion-item> </ion-item>

View File

@ -27,6 +27,7 @@ import { CoreUtils } from '@services/utils/utils';
import { AlertButton } from '@ionic/angular'; import { AlertButton } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CorePlatform } from '@services/platform'; import { CorePlatform } from '@services/platform';
import { CoreAnalytics } from '@services/analytics';
/** /**
* Page that displays the general settings. * Page that displays the general settings.
@ -101,7 +102,7 @@ export class CoreSettingsGeneralPage {
this.debugDisplay = await CoreConfig.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false); this.debugDisplay = await CoreConfig.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false);
this.analyticsSupported = CoreConstants.CONFIG.enableanalytics; this.analyticsSupported = CoreAnalytics.hasHandlers();
if (this.analyticsSupported) { if (this.analyticsSupported) {
this.analyticsEnabled = await CoreConfig.get(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true); this.analyticsEnabled = await CoreConfig.get(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true);
} }

View File

@ -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<CoreAnalyticsHandler> {
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<void> {
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<void> {
const site = CoreSites.getCurrentSite();
if (!site) {
return;
}
// Check if analytics is enabled by the user.
const enabled = await CoreConfig.get<boolean>(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<void>;
/**
* Enable or disable analytics.
*
* @param enable Whether to enable or disable.
* @returns Promise resolved when done.
*/
enableAnalytics?(enable: boolean): Promise<void>;
}
/**
* 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;
};

View File

@ -50,7 +50,6 @@ export interface EnvironmentConfig {
forcedefaultlanguage: boolean; forcedefaultlanguage: boolean;
privacypolicy: string; privacypolicy: string;
notificoncolor: string; notificoncolor: string;
enableanalytics: boolean;
enableonboarding: boolean; enableonboarding: boolean;
forceColorScheme: CoreColorScheme; forceColorScheme: CoreColorScheme;
forceLoginLogo: boolean; forceLoginLogo: boolean;

View File

@ -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. - 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. - 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 === === 4.2.0 ===