diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index 2219597d1..ff237f5d9 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -24,6 +24,7 @@ import { CoreSettingsModule } from './settings/settings.module'; import { CoreSiteHomeModule } from './sitehome/sitehome.module'; import { CoreTagModule } from './tag/tag.module'; import { CoreUserModule } from './user/user.module'; +import { CorePushNotificationsModule } from './pushnotifications/pushnotifications.module'; @NgModule({ imports: [ @@ -37,6 +38,7 @@ import { CoreUserModule } from './user/user.module'; CoreSiteHomeModule, CoreTagModule, CoreUserModule, + CorePushNotificationsModule, ], }) export class CoreFeaturesModule {} diff --git a/src/core/features/pushnotifications/pushnotifications.module.ts b/src/core/features/pushnotifications/pushnotifications.module.ts new file mode 100644 index 000000000..ceeda4e7a --- /dev/null +++ b/src/core/features/pushnotifications/pushnotifications.module.ts @@ -0,0 +1,35 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CorePushNotifications } from './services/pushnotifications'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => async () => { + await CorePushNotifications.instance.initialize(); + }, + }, + ], +}) +export class CorePushNotificationsModule {} diff --git a/src/core/features/pushnotifications/services/database/pushnotifications.ts b/src/core/features/pushnotifications/services/database/pushnotifications.ts new file mode 100644 index 000000000..c71bc8a08 --- /dev/null +++ b/src/core/features/pushnotifications/services/database/pushnotifications.ts @@ -0,0 +1,143 @@ +// (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 { CoreAppSchema } from '@services/app'; +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CorePushNotificationsProvider service. + * Keep "addon" in some names for backwards compatibility. + */ +export const BADGE_TABLE_NAME = 'addon_pushnotifications_badge'; +export const PENDING_UNREGISTER_TABLE_NAME = 'addon_pushnotifications_pending_unregister'; +export const REGISTERED_DEVICES_TABLE_NAME = 'addon_pushnotifications_registered_devices'; +export const APP_SCHEMA: CoreAppSchema = { + name: 'CorePushNotificationsProvider', + version: 1, + tables: [ + { + name: BADGE_TABLE_NAME, + columns: [ + { + name: 'siteid', + type: 'TEXT', + }, + { + name: 'addon', + type: 'TEXT', + }, + { + name: 'number', + type: 'INTEGER', + }, + ], + primaryKeys: ['siteid', 'addon'], + }, + { + name: PENDING_UNREGISTER_TABLE_NAME, + columns: [ + { + name: 'siteid', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'siteurl', + type: 'TEXT', + }, + { + name: 'token', + type: 'TEXT', + }, + { + name: 'info', + type: 'TEXT', + }, + ], + }, + ], +}; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonPushNotificationsProvider', + version: 1, + tables: [ + { + name: REGISTERED_DEVICES_TABLE_NAME, + columns: [ + { + name: 'appid', + type: 'TEXT', + }, + { + name: 'uuid', + type: 'TEXT', + }, + { + name: 'name', + type: 'TEXT', + }, + { + name: 'model', + type: 'TEXT', + }, + { + name: 'platform', + type: 'TEXT', + }, + { + name: 'version', + type: 'TEXT', + }, + { + name: 'pushid', + type: 'TEXT', + }, + ], + primaryKeys: ['appid', 'uuid'], + }, + ], +}; + + +/** + * Data stored in DB for badge. + */ +export type CorePushNotificationsBadgeDBRecord = { + siteid: string; + addon: string; + number: number; // eslint-disable-line id-blacklist +}; + +/** + * Data stored in DB for pending unregisters. + */ +export type CorePushNotificationsPendingUnregisterDBRecord = { + siteid: string; + siteurl: string; + token: string; + info: string; +}; + +/** + * Data stored in DB for registered devices. + */ +export type CorePushNotificationsRegisteredDeviceDBRecord = { + appid: string; // App ID. + uuid: string; // Device UUID. + name: string; // Device name. + model: string; // Device model. + platform: string; // Device platform. + version: string; // Device version. + pushid: string; // Push ID. +}; diff --git a/src/core/features/pushnotifications/services/push-delegate.ts b/src/core/features/pushnotifications/services/push-delegate.ts new file mode 100644 index 000000000..2fb972b6d --- /dev/null +++ b/src/core/features/pushnotifications/services/push-delegate.ts @@ -0,0 +1,218 @@ +// (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 { Subject } from 'rxjs'; + +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CorePushNotificationsNotificationBasicData } from './pushnotifications'; + +/** + * Interface that all click handlers must implement. + */ +export interface CorePushNotificationsClickHandler { + /** + * A name to identify the handler. + */ + name: string; + + /** + * Handler's priority. The highest priority is treated first. + */ + priority?: number; + + /** + * Name of the feature this handler is related to. + * It will be used to check if the feature is disabled (@see CoreSite.isFeatureDisabled). + */ + featureName?: string; + + /** + * Check if a notification click is handled by this handler. + * + * @param notification The notification to check. + * @return Whether the notification click is handled by this handler. + */ + handles(notification: CorePushNotificationsNotificationBasicData): Promise; + + /** + * Handle the notification click. + * + * @param notification The notification to check. + * @return Promise resolved when done. + */ + handleClick(notification: CorePushNotificationsNotificationBasicData): Promise; +} + +/** + * Service to handle push notifications actions to perform when clicked and received. + */ +@Injectable({ providedIn: 'root' }) +export class CorePushNotificationsDelegateService { + + protected logger: CoreLogger; + protected observables: { [s: string]: Subject } = {}; + protected clickHandlers: { [s: string]: CorePushNotificationsClickHandler } = {}; + protected counterHandlers: Record = {}; + + constructor() { + this.logger = CoreLogger.getInstance('CorePushNotificationsDelegate'); + this.observables['receive'] = new Subject(); + } + + /** + * Function called when a push notification is clicked. Sends notification to handlers. + * + * @param notification Notification clicked. + * @return Promise resolved when done. + */ + async clicked(notification: CorePushNotificationsNotificationBasicData): Promise { + if (!notification) { + return; + } + + let handlers: CorePushNotificationsClickHandler[] = []; + + const promises = Object.values(this.clickHandlers).map(async (handler) => { + // Check if the handler is disabled for the site. + const disabled = await this.isFeatureDisabled(handler, notification.site); + + if (disabled) { + return; + } + + // Check if the handler handles the notification. + const handles = await handler.handles(notification); + if (handles) { + handlers.push(handler); + } + }); + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(promises)); + + // Sort by priority. + handlers = handlers.sort((a, b) => (a.priority || 0) <= (b.priority || 0) ? 1 : -1); + + // Execute the first one. + handlers[0]?.handleClick(notification); + } + + /** + * Check if a handler's feature is disabled for a certain site. + * + * @param handler Handler to check. + * @param siteId The site ID to check. + * @return Promise resolved with boolean: whether the handler feature is disabled. + */ + protected async isFeatureDisabled(handler: CorePushNotificationsClickHandler, siteId?: string): Promise { + if (!siteId) { + // Notification doesn't belong to a site. Assume all handlers are enabled. + return false; + } else if (handler.featureName) { + // Check if the feature is disabled. + return CoreSites.instance.isFeatureDisabled(handler.featureName, siteId); + } else { + return false; + } + } + + /** + * Function called when a push notification is received in foreground (cannot tell when it's received in background). + * Sends notification to all handlers. + * + * @param notification Notification received. + */ + received(notification: CorePushNotificationsNotificationBasicData): void { + this.observables['receive'].next(notification); + } + + /** + * Register a push notifications observable for a certain event. Right now, only receive is supported. + * let observer = pushNotificationsDelegate.on('receive').subscribe((notification) => { + * ... + * observer.unsuscribe(); + * + * @param eventName Only receive is permitted. + * @return Observer to subscribe. + */ + on(eventName: string): Subject { + if (typeof this.observables[eventName] == 'undefined') { + const eventNames = Object.keys(this.observables).join(', '); + this.logger.warn(`'${eventName}' event name is not allowed. Use one of the following: '${eventNames}'.`); + + return new Subject(); + } + + return > this.observables[eventName]; + } + + /** + * Register a click handler. + * + * @param handler The handler to register. + * @return True if registered successfully, false otherwise. + */ + registerClickHandler(handler: CorePushNotificationsClickHandler): boolean { + if (typeof this.clickHandlers[handler.name] !== 'undefined') { + this.logger.log(`Addon '${handler.name}' already registered`); + + return false; + } + + this.logger.log(`Registered addon '${handler.name}'`); + this.clickHandlers[handler.name] = handler; + handler.priority = handler.priority || 0; + + return true; + } + + /** + * Register a push notifications handler for update badge counter. + * + * @param name Handler's name. + */ + registerCounterHandler(name: string): void { + if (typeof this.counterHandlers[name] == 'undefined') { + this.logger.debug(`Registered handler '${name}' as badge counter handler.`); + this.counterHandlers[name] = name; + } else { + this.logger.log(`Handler '${name}' as badge counter handler already registered.`); + } + } + + /** + * Check if a counter handler is present. + * + * @param name Handler's name. + * @return If handler name is present. + */ + isCounterHandlerRegistered(name: string): boolean { + return typeof this.counterHandlers[name] != 'undefined'; + } + + /** + * Get all counter badge handlers. + * + * @return with all the handler names. + */ + getCounterHandlers(): Record { + return this.counterHandlers; + } + +} + +export class CorePushNotificationsDelegate extends makeSingleton(CorePushNotificationsDelegateService) {} diff --git a/src/core/features/pushnotifications/services/pushnotifications.ts b/src/core/features/pushnotifications/services/pushnotifications.ts new file mode 100644 index 000000000..0d239834c --- /dev/null +++ b/src/core/features/pushnotifications/services/pushnotifications.ts @@ -0,0 +1,910 @@ +// (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 { ILocalNotification } from '@ionic-native/local-notifications'; +import { NotificationEventResponse, PushOptions, RegistrationEventResponse } from '@ionic-native/push/ngx'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CorePushNotificationsDelegate } from './push-delegate'; +import { CoreLocalNotifications } from '@services/local-notifications'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreConfig } from '@services/config'; +import { CoreConstants } from '@/core/constants'; +import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreSite, CoreSiteInfo } from '@classes/site'; +import { makeSingleton, Badge, Push, Device, Translate, Platform, ApplicationInit, NgZone } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreEvents } from '@singletons/events'; +import { + APP_SCHEMA, + BADGE_TABLE_NAME, + PENDING_UNREGISTER_TABLE_NAME, + REGISTERED_DEVICES_TABLE_NAME, + CorePushNotificationsPendingUnregisterDBRecord, + CorePushNotificationsRegisteredDeviceDBRecord, + CorePushNotificationsBadgeDBRecord, +} from './database/pushnotifications'; +import { CoreError } from '@classes/errors/error'; +import { CoreWSExternalWarning } from '@services/ws'; + +/** + * Service to handle push notifications. + */ +@Injectable({ providedIn: 'root' }) +export class CorePushNotificationsProvider { + + static readonly COMPONENT = 'CorePushNotificationsProvider'; + + protected logger: CoreLogger; + protected pushID?: string; + + // Variables for DB. + protected appDB: Promise; + protected resolveAppDB!: (appDB: SQLiteDB) => void; + + constructor() { + this.appDB = new Promise(resolve => this.resolveAppDB = resolve); + this.logger = CoreLogger.getInstance('CorePushNotificationsProvider'); + } + + /** + * Initialize the service. + * + * @return Promise resolved when done. + */ + async initialize(): Promise { + await Promise.all([ + this.initializeDatabase(), + this.initializeDefaultChannel(), + ]); + + // Now register the device to receive push notifications. Don't block for this. + this.registerDevice(); + + CoreEvents.on(CoreEvents.NOTIFICATION_SOUND_CHANGED, () => { + // Notification sound has changed, register the device again to update the sound setting. + this.registerDevice(); + }); + + // Register device on Moodle site when login. + CoreEvents.on(CoreEvents.LOGIN, async () => { + try { + await this.registerDeviceOnMoodle(); + } catch (error) { + this.logger.warn('Can\'t register device', error); + } + }); + + CoreEvents.on(CoreEvents.SITE_DELETED, async (site: CoreSite) => { + try { + await Promise.all([ + this.unregisterDeviceOnMoodle(site), + this.cleanSiteCounters(site.getId()), + ]); + } catch (error) { + this.logger.warn('Can\'t unregister device', error); + } + }); + + // Listen for local notification clicks (generated by the app). + CoreLocalNotifications.instance.registerClick( + CorePushNotificationsProvider.COMPONENT, + (notification) => { + // Log notification open event. + this.logEvent('moodle_notification_open', notification, true); + + this.notificationClicked(notification); + }, + ); + + // Listen for local notification dismissed events. + CoreLocalNotifications.instance.registerObserver( + 'clear', + CorePushNotificationsProvider.COMPONENT, + (notification) => { + // Log notification dismissed event. + this.logEvent('moodle_notification_dismiss', notification, true); + }, + ); + } + + /** + * Initialize the default channel for Android. + * + * @return Promise resolved when done. + */ + protected async initializeDefaultChannel(): Promise { + await Platform.instance.ready(); + + // Create the default channel. + this.createDefaultChannel(); + + Translate.instance.onLangChange.subscribe(() => { + // Update the channel name. + this.createDefaultChannel(); + }); + } + + /** + * Initialize database. + * + * @return Promise resolved when done. + */ + protected async initializeDatabase(): Promise { + try { + await CoreApp.instance.createTablesFromSchema(APP_SCHEMA); + } catch (e) { + // Ignore errors. + } + + this.resolveAppDB(CoreApp.instance.getDB()); + } + + /** + * Check whether the device can be registered in Moodle to receive push notifications. + * + * @return Whether the device can be registered in Moodle. + */ + canRegisterOnMoodle(): boolean { + return !!this.pushID && CoreApp.instance.isMobile(); + } + + /** + * Delete all badge records for a given site. + * + * @param siteId Site ID. + * @return Resolved when done. + */ + async cleanSiteCounters(siteId: string): Promise { + try { + const db = await this.appDB; + await db.deleteRecords(BADGE_TABLE_NAME, { siteid: siteId } ); + } finally { + this.updateAppCounter(); + } + } + + /** + * Create the default push channel. It is used to change the name. + * + * @return Promise resolved when done. + */ + protected async createDefaultChannel(): Promise { + if (!CoreApp.instance.isAndroid()) { + return; + } + + try { + await Push.instance.createChannel({ + id: 'PushPluginChannel', + description: Translate.instance.instant('core.misc'), + importance: 4, + }); + } catch (error) { + this.logger.error('Error changing push channel name', error); + } + } + + /** + * Enable or disable Firebase analytics. + * + * @param enable Whether to enable or disable. + * @return Promise resolved when done. + */ + 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; + } + + const deferred = CoreUtils.instance.promiseDefer(); + + win.PushNotification.enableAnalytics(deferred.resolve, (error) => { + this.logger.error('Error enabling or disabling Firebase analytics', enable, error); + deferred.resolve(); + }, !!enable); + + await deferred.promise; + } + + /** + * Returns options for push notifications based on device. + * + * @return Promise with the push options resolved when done. + */ + protected async getOptions(): Promise { + let soundEnabled = true; + + if (CoreLocalNotifications.instance.canDisableSound()) { + soundEnabled = await CoreConfig.instance.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true); + } + + return { + android: { + sound: !!soundEnabled, + icon: 'smallicon', + iconColor: CoreConstants.CONFIG.notificoncolor, + }, + ios: { + alert: 'true', + badge: true, + sound: !!soundEnabled, + }, + windows: { + sound: !!soundEnabled, + }, + }; + } + + /** + * Get the pushID for this device. + * + * @return Push ID. + */ + getPushId(): string | undefined { + return this.pushID; + } + + /** + * Get data to register the device in Moodle. + * + * @return Data. + */ + protected getRegisterData(): CoreUserAddUserDeviceWSParams { + if (!this.pushID) { + throw new CoreError('Cannot get register data because pushID is not set.'); + } + + return { + appid: CoreConstants.CONFIG.app_id, + name: Device.instance.manufacturer || '', + model: Device.instance.model, + platform: Device.instance.platform + '-fcm', + version: Device.instance.version, + pushid: this.pushID, + uuid: Device.instance.uuid, + }; + } + + /** + * Get Sitebadge counter from the database. + * + * @param siteId Site ID. + * @return Promise resolved with the stored badge counter for the site. + */ + getSiteCounter(siteId: string): Promise { + return this.getAddonBadge(siteId); + } + + /** + * Log a firebase event. + * + * @param name 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. + * @return Promise resolved when done. This promise is never rejected. + */ + 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; + } + + // Check if the analytics is enabled by the user. + const enabled = await CoreConfig.instance.get(CoreConstants.SETTINGS_ANALYTICS_ENABLED, true); + if (!enabled) { + return; + } + + const deferred = CoreUtils.instance.promiseDefer(); + + win.PushNotification.logEvent(deferred.resolve, (error) => { + this.logger.error('Error logging firebase event', name, error); + deferred.resolve(); + }, name, data, !!filter); + + await deferred.promise; + } + + /** + * Log a firebase view_item event. + * + * @param itemId The item ID. + * @param itemName The item name. + * @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. + * @return Promise resolved when done. This promise is never rejected. + */ + logViewEvent( + itemId: number | string | undefined, + itemName: string | undefined, + itemCategory: string | undefined, + wsName: string, + data?: Record, + siteId?: string, + ): Promise { + data = data || {}; + + // Add "moodle" to the name of all extra params. + data = CoreUtils.instance.prefixKeys(data, 'moodle'); + data.moodleaction = wsName; + data.moodlesiteid = siteId || CoreSites.instance.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); + } + + /** + * Log a firebase 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. + * @return Promise resolved when done. This promise is never rejected. + */ + logViewListEvent(itemCategory: string, wsName: string, data?: Record, siteId?: string): Promise { + data = data || {}; + + // Add "moodle" to the name of all extra params. + data = CoreUtils.instance.prefixKeys(data, 'moodle'); + data.moodleaction = wsName; + data.moodlesiteid = siteId || CoreSites.instance.getCurrentSiteId(); + + if (itemCategory) { + data.item_category = itemCategory; + } + + return this.logEvent('view_item_list', data, false); + } + + /** + * Function called when a push notification is clicked. Redirect the user to the right state. + * + * @param notification Notification. + * @return Promise resolved when done. + */ + async notificationClicked(data: CorePushNotificationsNotificationBasicData): Promise { + await ApplicationInit.instance.donePromise; + + CorePushNotificationsDelegate.instance.clicked(data); + } + + /** + * This function is called when we receive a Notification from APNS or a message notification from GCM. + * The app can be in foreground or background, + * if we are in background this code is executed when we open the app clicking in the notification bar. + * + * @param notification Notification received. + */ + async onMessageReceived(notification: NotificationEventResponse): Promise { + const rawData: CorePushNotificationsNotificationBasicRawData = notification ? notification.additionalData : {}; + + // Parse some fields and add some extra data. + const data: CorePushNotificationsNotificationBasicData = Object.assign(rawData, { + title: notification.title, + message: notification.message, + customdata: typeof rawData.customdata == 'string' ? + CoreTextUtils.instance.parseJSON>(rawData.customdata, {}) : rawData.customdata, + }); + + let site: CoreSite | undefined; + + if (data.site) { + site = await CoreSites.instance.getSite(data.site); + } else if (data.siteurl) { + site = await CoreSites.instance.getSiteByUrl(data.siteurl); + } + + data.site = site?.getId(); + + if (!CoreUtils.instance.isTrueOrOne(data.foreground)) { + // The notification was clicked. + return this.notificationClicked(data); + } + + // If the app is in foreground when the notification is received, it's not shown. Let's show it ourselves. + if (!CoreLocalNotifications.instance.isAvailable()) { + return this.notifyReceived(notification, data); + } + + const localNotif: ILocalNotification = { + id: Number(data.notId) || 1, + data: data, + title: notification.title, + text: notification.message, + channel: 'PushPluginChannel', + }; + const isAndroid = CoreApp.instance.isAndroid(); + const extraFeatures = CoreUtils.instance.isTrueOrOne(data.extrafeatures); + + if (extraFeatures && isAndroid && CoreUtils.instance.isFalseOrZero(data.notif)) { + // It's a message, use messaging style. Ionic Native doesn't specify this option. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ( localNotif).text = [ + { + message: notification.message, + person: data.conversationtype == '2' ? data.userfromfullname : '', + }, + ]; + } + + if (extraFeatures && isAndroid) { + // Use a different icon if needed. + localNotif.icon = notification.image; + // This feature isn't supported by the official plugin, we use a fork. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ( localNotif).iconType = data['image-type']; + + localNotif.summary = data.summaryText; + + if (data.picture) { + localNotif.attachments = [data.picture]; + } + } + + CoreLocalNotifications.instance.schedule(localNotif, CorePushNotificationsProvider.COMPONENT, data.site || '', true); + + await this.notifyReceived(notification, data); + + } + + /** + * Notify that a notification was received. + * + * @param notification Notification. + * @param data Notification data. + * @return Promise resolved when done. + */ + protected async notifyReceived( + notification: NotificationEventResponse, + data: CorePushNotificationsNotificationBasicData, + ): Promise { + await ApplicationInit.instance.donePromise; + + CorePushNotificationsDelegate.instance.received(data); + } + + /** + * Unregisters a device from a certain Moodle site. + * + * @param site Site to unregister from. + * @return Promise resolved when device is unregistered. + */ + async unregisterDeviceOnMoodle(site: CoreSite): Promise { + if (!site || !CoreApp.instance.isMobile()) { + throw new CoreError('Cannot unregister device'); + } + + this.logger.debug(`Unregister device on Moodle: '${site.getId()}'`); + + const db = await this.appDB; + const data: CoreUserRemoveUserDeviceWSParams = { + appid: CoreConstants.CONFIG.app_id, + uuid: Device.instance.uuid, + }; + + try { + const response = await site.write('core_user_remove_user_device', data); + + if (!response || !response.removed) { + throw new CoreError('Cannot unregister device'); + } + + await CoreUtils.instance.ignoreErrors(Promise.all([ + // Remove the device from the local DB. + site.getDb().deleteRecords(REGISTERED_DEVICES_TABLE_NAME, this.getRegisterData()), + // Remove pending unregisters for this site. + db.deleteRecords(PENDING_UNREGISTER_TABLE_NAME, { siteid: site.getId() }), + ])); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + throw error; + } + + // Store the pending unregister so it's retried again later. + const entry: CorePushNotificationsPendingUnregisterDBRecord = { + siteid: site.getId(), + siteurl: site.getURL(), + token: site.getToken(), + info: JSON.stringify(site.getInfo()), + }; + await db.insertRecord(PENDING_UNREGISTER_TABLE_NAME, entry); + } + } + + /** + * Update Counter for an addon. It will update the refered siteId counter and the total badge. + * It will return the updated addon counter. + * + * @param addon Registered addon name to set the badge number. + * @param value The number to be stored. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the stored badge counter for the addon on the site. + */ + async updateAddonCounter(addon: string, value: number, siteId?: string): Promise { + if (!CorePushNotificationsDelegate.instance.isCounterHandlerRegistered(addon)) { + return 0; + } + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await this.saveAddonBadge(value, siteId, addon); + await this.updateSiteCounter(siteId); + + return value; + } + + /** + * Update total badge counter of the app. + * + * @return Promise resolved with the stored badge counter for the site. + */ + async updateAppCounter(): Promise { + const sitesIds = await CoreSites.instance.getSitesIds(); + + const counters = await Promise.all(sitesIds.map((siteId) => this.getAddonBadge(siteId))); + + const total = counters.reduce((previous, counter) => previous + counter, 0); + + if (!CoreApp.instance.isMobile()) { + // Browser doesn't have an app badge, stop. + return total; + } + + // Set the app badge. + await Badge.instance.set(total); + + return total; + } + + /** + * Update counter for a site using the stored addon data. It will update the total badge application number. + * It will return the updated site counter. + * + * @param siteId Site ID. + * @return Promise resolved with the stored badge counter for the site. + */ + async updateSiteCounter(siteId: string): Promise { + const addons = CorePushNotificationsDelegate.instance.getCounterHandlers(); + + const counters = await Promise.all(Object.values(addons).map((addon) => this.getAddonBadge(siteId, addon))); + + const total = counters.reduce((previous, counter) => previous + counter, 0); + + // Save the counter on site. + await this.saveAddonBadge(total, siteId); + + await this.updateAppCounter(); + + return total; + } + + /** + * Register a device in Apple APNS or Google GCM. + * + * @return Promise resolved when the device is registered. + */ + async registerDevice(): Promise { + try { + // Check if sound is enabled for notifications. + const options = await this.getOptions(); + + const pushObject = Push.instance.init(options); + + pushObject.on('notification').subscribe((notification: NotificationEventResponse) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.logger.log('Received a notification', notification); + this.onMessageReceived(notification); + }); + }); + + pushObject.on('registration').subscribe((data: RegistrationEventResponse) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.pushID = data.registrationId; + if (!CoreSites.instance.isLoggedIn()) { + return; + } + + this.registerDeviceOnMoodle().catch((error) => { + this.logger.warn('Can\'t register device', error); + }); + }); + }); + + pushObject.on('error').subscribe((error: Error) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.logger.warn('Error with Push plugin', error); + }); + }); + } catch (error) { + this.logger.warn(error); + + throw error; + } + } + + /** + * Registers a device on a Moodle site if needed. + * + * @param siteId Site ID. If not defined, current site. + * @param forceUnregister Whether to force unregister and register. + * @return Promise resolved when device is registered. + */ + async registerDeviceOnMoodle(siteId?: string, forceUnregister?: boolean): Promise { + this.logger.debug('Register device on Moodle.'); + + if (!this.canRegisterOnMoodle()) { + return Promise.reject(null); + } + + const site = await CoreSites.instance.getSite(siteId); + + try { + + const data = this.getRegisterData(); + let result = { + unregister: true, + register: true, + }; + + if (!forceUnregister) { + // Check if the device is already registered. + result = await this.shouldRegister(data, site); + } + + if (result.unregister) { + // Unregister the device first. + await CoreUtils.instance.ignoreErrors(this.unregisterDeviceOnMoodle(site)); + } + + if (result.register) { + // Now register the device. + await site.write('core_user_add_user_device', CoreUtils.instance.clone(data)); + + CoreEvents.trigger(CoreEvents.DEVICE_REGISTERED_IN_MOODLE, {}, site.getId()); + + // Insert the device in the local DB. + try { + await site.getDb().insertRecord(REGISTERED_DEVICES_TABLE_NAME, data); + } catch (err) { + // Ignore errors. + } + } + } finally { + // Remove pending unregisters for this site. + const db = await this.appDB; + await CoreUtils.instance.ignoreErrors(db.deleteRecords(PENDING_UNREGISTER_TABLE_NAME, { siteid: site.getId() })); + } + } + + /** + * Get the addon/site badge counter from the database. + * + * @param siteId Site ID. + * @param addon Registered addon name. If not defined it will store the site total. + * @return Promise resolved with the stored badge counter for the addon or site or 0 if none. + */ + protected async getAddonBadge(siteId?: string, addon: string = 'site'): Promise { + try { + const db = await this.appDB; + const entry = await db.getRecord(BADGE_TABLE_NAME, { siteid: siteId, addon }); + + return entry?.number || 0; + } catch (err) { + return 0; + } + } + + /** + * Retry pending unregisters. + * + * @param siteId If defined, retry only for that site if needed. Otherwise, retry all pending unregisters. + * @return Promise resolved when done. + */ + async retryUnregisters(siteId?: string): Promise { + + const db = await this.appDB; + let results: CorePushNotificationsPendingUnregisterDBRecord[]; + + if (siteId) { + // Check if the site has a pending unregister. + results = await db.getRecords(PENDING_UNREGISTER_TABLE_NAME, { + siteid: siteId, + }); + } else { + // Get all pending unregisters. + results = await db.getAllRecords(PENDING_UNREGISTER_TABLE_NAME); + } + + await Promise.all(results.map(async (result) => { + // Create a temporary site to unregister. + const tmpSite = new CoreSite( + result.siteid, + result.siteurl, + result.token, + CoreTextUtils.instance.parseJSON(result.info, null) || undefined, + ); + + await this.unregisterDeviceOnMoodle(tmpSite); + })); + } + + /** + * Save the addon/site badgecounter on the database. + * + * @param value The number to be stored. + * @param siteId Site ID. If not defined, use current site. + * @param addon Registered addon name. If not defined it will store the site total. + * @return Promise resolved with the stored badge counter for the addon or site. + */ + protected async saveAddonBadge(value: number, siteId?: string, addon: string = 'site'): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const entry: CorePushNotificationsBadgeDBRecord = { + siteid: siteId, + addon, + number: value, // eslint-disable-line id-blacklist + }; + + const db = await this.appDB; + await db.insertRecord(BADGE_TABLE_NAME, entry); + + return value; + } + + /** + * Check if device should be registered (and unregistered first). + * + * @param data Data of the device. + * @param site Site to use. + * @return Promise resolved with booleans: whether to register/unregister. + */ + protected async shouldRegister( + data: CoreUserAddUserDeviceWSParams, + site: CoreSite, + ): Promise<{register: boolean; unregister: boolean}> { + + // Check if the device is already registered. + const records = await CoreUtils.instance.ignoreErrors( + site.getDb().getRecords(REGISTERED_DEVICES_TABLE_NAME, { + appid: data.appid, + uuid: data.uuid, + name: data.name, + model: data.model, + platform: data.platform, + }), + ); + + let isStored = false; + let versionOrPushChanged = false; + + (records || []).forEach((record) => { + if (record.version == data.version && record.pushid == data.pushid) { + // The device is already stored. + isStored = true; + } else { + // The version or pushid has changed. + versionOrPushChanged = true; + } + }); + + if (isStored) { + // The device has already been registered, no need to register it again. + return { + register: false, + unregister: false, + }; + } else if (versionOrPushChanged) { + // This data can be updated by calling register WS, no need to call unregister. + return { + register: true, + unregister: false, + }; + } else { + return { + register: true, + unregister: true, + }; + } + } + +} + +export class CorePushNotifications extends makeSingleton(CorePushNotificationsProvider) {} + +/** + * Additional data sent in push notifications. + */ +export type CorePushNotificationsNotificationBasicRawData = { + customdata?: string; // Custom data. + extrafeatures?: string; // "1" if the notification uses extrafeatures, "0" otherwise. + foreground?: boolean; // Whether the app was in foreground. + 'image-type'?: string; // How to display the notification image. + moodlecomponent?: string; // Moodle component that triggered the notification. + name?: string; // A name to identify the type of notification. + notId?: string; // Notification ID. + notif?: string; // "1" if it's a notification, "0" if it's a Moodle message. + site?: string; // ID of the site sending the notification. + siteurl?: string; // URL of the site the notification is related to. + usertoid?: string; // ID of user receiving the push. + conversationtype?: string; // Conversation type. Only if it's a push generated by a Moodle message. + userfromfullname?: string; // Fullname of user sending the push. Only if it's a push generated by a Moodle message. + userfromid?: string; // ID of user sending the push. Only if it's a push generated by a Moodle message. + picture?: string; // Notification big picture. "Extra" feature. + summaryText?: string; // Notification summary text. "Extra" feature. +}; + +/** + * Additional data sent in push notifications, with some calculated data. + */ +export type CorePushNotificationsNotificationBasicData = Omit & { + title?: string; // Notification title. + message?: string; // Notification message. + customdata?: Record; // Parsed custom data. +}; + +/** + * Params of core_user_remove_user_device WS. + */ +export type CoreUserRemoveUserDeviceWSParams = { + uuid: string; // The device UUID. + appid?: string; // The app id, if empty devices matching the UUID for the user will be removed. +}; + +/** + * Data returned by core_user_remove_user_device WS. + */ +export type CoreUserRemoveUserDeviceWSResponse = { + removed: boolean; // True if removed, false if not removed because it doesn't exists. + warnings?: CoreWSExternalWarning[]; +}; +/** + * Params of core_user_add_user_device WS. + */ +export type CoreUserAddUserDeviceWSParams = { + appid: string; // The app id, usually something like com.moodle.moodlemobile. + name: string; // The device name, 'occam' or 'iPhone' etc. + model: string; // The device model 'Nexus4' or 'iPad1,1' etc. + platform: string; // The device platform 'iOS' or 'Android' etc. + version: string; // The device version '6.1.2' or '4.2.2' etc. + pushid: string; // The device PUSH token/key/identifier/registration id. + uuid: string; // The device UUID. +}; + +/** + * Data returned by core_user_add_user_device WS. + */ +export type CoreUserAddUserDeviceWSResponse = CoreWSExternalWarning[][]; diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index 36aa71550..2c4ee721f 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -971,6 +971,23 @@ export class CoreSitesProvider { } } + /** + * Finds a site with a certain URL. It will return the first site found. + * + * @param siteUrl The site URL. + * @return Promise resolved with the site. + */ + async getSiteByUrl(siteUrl: string): Promise { + const db = await this.appDB; + const data = await db.getRecord(SITES_TABLE_NAME, { siteUrl }); + + if (typeof this.sites[data.id] != 'undefined') { + return this.sites[data.id]; + } + + return this.makeSiteFromSiteListEntry(data); + } + /** * Create a site from an entry of the sites list DB. The new site is added to the list of "cached" sites: this.sites. *