// (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, Subscription } from 'rxjs'; import { ILocalNotification } from '@ionic-native/local-notifications'; import { CoreApp, CoreAppSchema } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreEventObserver, CoreEvents, CoreEventsProvider } from '@services/events'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { SQLiteDB } from '@classes/sqlitedb'; import { CoreQueueRunner } from '@classes/queue-runner'; import { CoreConstants } from '@core/constants'; import CoreConfigConstants from '@app/config.json'; import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push, Device } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; /** * Service to handle local notifications. */ @Injectable() export class CoreLocalNotificationsProvider { // Variables for the database. protected static readonly SITES_TABLE = 'notification_sites'; // Store to asigne unique codes to each site. protected static readonly COMPONENTS_TABLE = 'notification_components'; // Store to asigne unique codes to each component. protected static readonly TRIGGERED_TABLE = 'notifications_triggered'; // Store to prevent re-triggering notifications. protected tablesSchema: CoreAppSchema = { name: 'CoreLocalNotificationsProvider', version: 1, tables: [ { name: CoreLocalNotificationsProvider.SITES_TABLE, columns: [ { name: 'id', type: 'TEXT', primaryKey: true, }, { name: 'code', type: 'INTEGER', notNull: true, }, ], }, { name: CoreLocalNotificationsProvider.COMPONENTS_TABLE, columns: [ { name: 'id', type: 'TEXT', primaryKey: true, }, { name: 'code', type: 'INTEGER', notNull: true, }, ], }, { name: CoreLocalNotificationsProvider.TRIGGERED_TABLE, columns: [ { name: 'id', type: 'INTEGER', primaryKey: true, }, { name: 'at', type: 'INTEGER', notNull: true, }, ], }, ], }; protected logger: CoreLogger; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. protected codes: { [s: string]: number } = {}; protected codeRequestsQueue = {}; protected observables = {}; protected currentNotification = { title: '', texts: [], ids: [], timeouts: [], }; protected triggerSubscription: Subscription; protected clickSubscription: Subscription; protected clearSubscription: Subscription; protected cancelSubscription: Subscription; protected addSubscription: Subscription; protected updateSubscription: Subscription; protected queueRunner: CoreQueueRunner; // Queue to decrease the number of concurrent calls to the plugin (see MOBILE-3477). constructor() { this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider'); this.queueRunner = new CoreQueueRunner(10); this.appDB = CoreApp.instance.getDB(); this.dbReady = CoreApp.instance.createTablesFromSchema(this.tablesSchema).catch(() => { // Ignore errors. }); Platform.instance.ready().then(() => { // Listen to events. this.triggerSubscription = LocalNotifications.instance.on('trigger').subscribe((notification: ILocalNotification) => { this.trigger(notification); this.handleEvent('trigger', notification); }); this.clickSubscription = LocalNotifications.instance.on('click').subscribe((notification: ILocalNotification) => { this.handleEvent('click', notification); }); this.clearSubscription = LocalNotifications.instance.on('clear').subscribe((notification: ILocalNotification) => { this.handleEvent('clear', notification); }); this.cancelSubscription = LocalNotifications.instance.on('cancel').subscribe((notification: ILocalNotification) => { this.handleEvent('cancel', notification); }); this.addSubscription = LocalNotifications.instance.on('schedule').subscribe((notification: ILocalNotification) => { this.handleEvent('schedule', notification); }); this.updateSubscription = LocalNotifications.instance.on('update').subscribe((notification: ILocalNotification) => { this.handleEvent('update', notification); }); // Create the default channel for local notifications. this.createDefaultChannel(); Translate.instance.onLangChange.subscribe(() => { // Update the channel name. this.createDefaultChannel(); }); }); CoreEvents.instance.on(CoreEventsProvider.SITE_DELETED, (site) => { if (site) { this.cancelSiteNotifications(site.id); } }); } /** * Cancel a local notification. * * @param id Notification id. * @param component Component of the notification. * @param siteId Site ID. * @return Promise resolved when the notification is cancelled. */ async cancel(id: number, component: string, siteId: string): Promise { const uniqueId = await this.getUniqueNotificationId(id, component, siteId); const queueId = 'cancel-' + uniqueId; await this.queueRunner.run(queueId, () => LocalNotifications.instance.cancel(uniqueId), { allowRepeated: true, }); } /** * Cancel all the scheduled notifications belonging to a certain site. * * @param siteId Site ID. * @return Promise resolved when the notifications are cancelled. */ async cancelSiteNotifications(siteId: string): Promise { if (!this.isAvailable()) { return; } else if (!siteId) { throw new Error('No site ID supplied.'); } const scheduled = await this.getAllScheduled(); const ids = []; const queueId = 'cancelSiteNotifications-' + siteId; scheduled.forEach((notif) => { notif.data = this.parseNotificationData(notif.data); if (typeof notif.data == 'object' && notif.data.siteId === siteId) { ids.push(notif.id); } }); await this.queueRunner.run(queueId, () => LocalNotifications.instance.cancel(ids), { allowRepeated: true, }); } /** * Check whether sound can be disabled for notifications. * * @return Whether sound can be disabled for notifications. */ canDisableSound(): boolean { // Only allow disabling sound in Android 7 or lower. In iOS and Android 8+ it can easily be done with system settings. return this.isAvailable() && !CoreApp.instance.isDesktop() && CoreApp.instance.isAndroid() && Device.instance.version && Number(Device.instance.version.split('.')[0]) < 8; } /** * Create the default channel. It is used to change the name. * * @return Promise resolved when done. */ protected async createDefaultChannel(): Promise { if (!CoreApp.instance.isAndroid()) { return; } await Push.instance.createChannel({ id: 'default-channel-id', description: Translate.instance.instant('addon.calendar.calendarreminders'), importance: 4, }).catch((error) => { this.logger.error('Error changing channel name', error); }); } /** * Get all scheduled notifications. * * @return Promise resolved with the notifications. */ protected getAllScheduled(): Promise { return this.queueRunner.run('allScheduled', () => LocalNotifications.instance.getAllScheduled()); } /** * Get a code to create unique notifications. If there's no code assigned, create a new one. * * @param table Table to search in local DB. * @param id ID of the element to get its code. * @return Promise resolved when the code is retrieved. */ protected async getCode(table: string, id: string): Promise { await this.dbReady; const key = table + '#' + id; // Check if the code is already in memory. if (typeof this.codes[key] != 'undefined') { return this.codes[key]; } try { // Check if we already have a code stored for that ID. const entry = await this.appDB.getRecord(table, { id: id }); this.codes[key] = entry.code; return entry.code; } catch (err) { // No code stored for that ID. Create a new code for it. const entries = await this.appDB.getRecords(table, undefined, 'code DESC'); let newCode = 0; if (entries.length > 0) { newCode = entries[0].code + 1; } await this.appDB.insertRecord(table, { id: id, code: newCode }); this.codes[key] = newCode; return newCode; } } /** * Get a notification component code to be used. * If it's the first time this component is used to send notifications, create a new code for it. * * @param component Component name. * @return Promise resolved when the component code is retrieved. */ protected getComponentCode(component: string): Promise { return this.requestCode(CoreLocalNotificationsProvider.COMPONENTS_TABLE, component); } /** * Get a site code to be used. * If it's the first time this site is used to send notifications, create a new code for it. * * @param siteId Site ID. * @return Promise resolved when the site code is retrieved. */ protected getSiteCode(siteId: string): Promise { return this.requestCode(CoreLocalNotificationsProvider.SITES_TABLE, siteId); } /** * Create a unique notification ID, trying to prevent collisions. Generated ID must be a Number (Android). * The generated ID shouldn't be higher than 2147483647 or it's going to cause problems in Android. * This function will prevent collisions and keep the number under Android limit if: * - User has used less than 21 sites. * - There are less than 11 components. * - The notificationId passed as parameter is lower than 10000000. * * @param notificationId Notification ID. * @param component Component triggering the notification. * @param siteId Site ID. * @return Promise resolved when the notification ID is generated. */ protected getUniqueNotificationId(notificationId: number, component: string, siteId: string): Promise { if (!siteId || !component) { return Promise.reject(null); } return this.getSiteCode(siteId).then((siteCode) => this.getComponentCode(component).then((componentCode) => // We use the % operation to keep the number under Android's limit. (siteCode * 100000000 + componentCode * 10000000 + notificationId) % 2147483647, )); } /** * Handle an event triggered by the local notifications plugin. * * @param eventName Name of the event. * @param notification Notification. */ protected handleEvent(eventName: string, notification: ILocalNotification): void { if (notification && notification.data) { this.logger.debug('Notification event: ' + eventName + '. Data:', notification.data); this.notifyEvent(eventName, notification.data); } } /** * Returns whether local notifications plugin is installed. * * @return Whether local notifications plugin is installed. */ isAvailable(): boolean { const win = window; return CoreApp.instance.isDesktop() || !!(win.cordova && win.cordova.plugins && win.cordova.plugins.notification && win.cordova.plugins.notification.local); } /** * Check if a notification has been triggered with the same trigger time. * * @param notification Notification to check. * @param useQueue Whether to add the call to the queue. * @return Promise resolved with a boolean indicating if promise is triggered (true) or not. */ async isTriggered(notification: ILocalNotification, useQueue: boolean = true): Promise { await this.dbReady; try { const stored = await this.appDB.getRecord(CoreLocalNotificationsProvider.TRIGGERED_TABLE, { id: notification.id }); let triggered = (notification.trigger && notification.trigger.at) || 0; if (typeof triggered != 'number') { triggered = triggered.getTime(); } return stored.at === triggered; } catch (err) { if (useQueue) { const queueId = 'isTriggered-' + notification.id; return this.queueRunner.run(queueId, () => LocalNotifications.instance.isTriggered(notification.id), { allowRepeated: true, }); } else { return LocalNotifications.instance.isTriggered(notification.id); } } } /** * Notify notification click to observers. Only the observers with the same component as the notification will be notified. * * @param data Data received by the notification. */ notifyClick(data: any): void { this.notifyEvent('click', data); } /** * Notify a certain event to observers. Only the observers with the same component as the notification will be notified. * * @param eventName Name of the event to notify. * @param data Data received by the notification. */ notifyEvent(eventName: string, data: any): void { // Execute the code in the Angular zone, so change detection doesn't stop working. NgZone.instance.run(() => { const component = data.component; if (component) { if (this.observables[eventName] && this.observables[eventName][component]) { this.observables[eventName][component].next(data); } } }); } /** * Parse some notification data. * * @param data Notification data. * @return Parsed data. */ protected parseNotificationData(data: any): any { if (!data) { return {}; } else if (typeof data == 'string') { return CoreTextUtils.instance.parseJSON(data, {}); } else { return data; } } /** * Process the next request in queue. */ protected processNextRequest(): void { const nextKey = Object.keys(this.codeRequestsQueue)[0]; let promise: Promise; if (typeof nextKey == 'undefined') { // No more requests in queue, stop. return; } const request = this.codeRequestsQueue[nextKey]; // Check if request is valid. if (typeof request == 'object' && typeof request.table != 'undefined' && typeof request.id != 'undefined') { // Get the code and resolve/reject all the promises of this request. promise = this.getCode(request.table, request.id).then((code) => { request.promises.forEach((p) => { p.resolve(code); }); }).catch((error) => { request.promises.forEach((p) => { p.reject(error); }); }); } else { promise = Promise.resolve(); } // Once this item is treated, remove it and process next. promise.finally(() => { delete this.codeRequestsQueue[nextKey]; this.processNextRequest(); }); } /** * Register an observer to be notified when a notification belonging to a certain component is clicked. * * @param component Component to listen notifications for. * @param callback Function to call with the data received by the notification. * @return Object with an "off" property to stop listening for clicks. */ registerClick(component: string, callback: CoreLocalNotificationsClickCallback): CoreEventObserver { return this.registerObserver('click', component, callback); } /** * Register an observer to be notified when a certain event is fired for a notification belonging to a certain component. * * @param eventName Name of the event to listen to. * @param component Component to listen notifications for. * @param callback Function to call with the data received by the notification. * @return Object with an "off" property to stop listening for events. */ registerObserver(eventName: string, component: string, callback: CoreLocalNotificationsClickCallback): CoreEventObserver { this.logger.debug(`Register observer '${component}' for event '${eventName}'.`); if (typeof this.observables[eventName] == 'undefined') { this.observables[eventName] = {}; } if (typeof this.observables[eventName][component] == 'undefined') { // No observable for this component, create a new one. this.observables[eventName][component] = new Subject(); } this.observables[eventName][component].subscribe(callback); return { off: (): void => { this.observables[eventName][component].unsubscribe(callback); }, }; } /** * Remove a notification from triggered store. * * @param id Notification ID. * @return Promise resolved when it is removed. */ async removeTriggered(id: number): Promise { await this.dbReady; await this.appDB.deleteRecords(CoreLocalNotificationsProvider.TRIGGERED_TABLE, { id: id }); } /** * Request a unique code. The request will be added to the queue and the queue is going to be started if it's paused. * * @param table Table to search in local DB. * @param id ID of the element to get its code. * @return Promise resolved when the code is retrieved. */ protected requestCode(table: string, id: string): Promise { const deferred = CoreUtils.instance.promiseDefer(); const key = table + '#' + id; const isQueueEmpty = Object.keys(this.codeRequestsQueue).length == 0; if (typeof this.codeRequestsQueue[key] != 'undefined') { // There's already a pending request for this store and ID, add the promise to it. this.codeRequestsQueue[key].promises.push(deferred); } else { // Add a pending request to the queue. this.codeRequestsQueue[key] = { table: table, id: id, promises: [deferred], }; } if (isQueueEmpty) { this.processNextRequest(); } return deferred.promise; } /** * Reschedule all notifications that are already scheduled. * * @return Promise resolved when all notifications have been rescheduled. */ async rescheduleAll(): Promise { // Get all the scheduled notifications. const notifications = await this.getAllScheduled(); await Promise.all(notifications.map(async (notification) => { // Convert some properties to the needed types. notification.data = this.parseNotificationData(notification.data); const queueId = 'schedule-' + notification.id; await this.queueRunner.run(queueId, () => this.scheduleNotification(notification), { allowRepeated: true, }); })); } /** * Schedule a local notification. * * @param notification Notification to schedule. Its ID should be lower than 10000000 and it should * be unique inside its component and site. * @param component Component triggering the notification. It is used to generate unique IDs. * @param siteId Site ID. * @param alreadyUnique Whether the ID is already unique. * @return Promise resolved when the notification is scheduled. */ async schedule(notification: ILocalNotification, component: string, siteId: string, alreadyUnique?: boolean): Promise { if (!alreadyUnique) { notification.id = await this.getUniqueNotificationId(notification.id, component, siteId); } notification.data = notification.data || {}; notification.data.component = component; notification.data.siteId = siteId; if (CoreApp.instance.isAndroid()) { notification.icon = notification.icon || 'res://icon'; notification.smallIcon = notification.smallIcon || 'res://smallicon'; notification.color = notification.color || CoreConfigConstants.notificoncolor; if (notification.led !== false) { let ledColor = 'FF9900'; let ledOn = 1000; let ledOff = 1000; if (typeof notification.led === 'string') { ledColor = notification.led; } else if (Array.isArray(notification.led)) { ledColor = notification.led[0] || ledColor; ledOn = notification.led[1] || ledOn; ledOff = notification.led[2] || ledOff; } else if (typeof notification.led === 'object') { ledColor = notification.led.color || ledColor; ledOn = notification.led.on || ledOn; ledOff = notification.led.off || ledOff; } notification.led = { color: ledColor, on: ledOn, off: ledOff, }; } } const queueId = 'schedule-' + notification.id; await this.queueRunner.run(queueId, () => this.scheduleNotification(notification), { allowRepeated: true, }); } /** * Helper function to schedule a notification object if it hasn't been triggered already. * * @param notification Notification to schedule. * @return Promise resolved when scheduled. */ protected async scheduleNotification(notification: ILocalNotification): Promise { // Check if the notification has been triggered already. const triggered = await this.isTriggered(notification, false); // Cancel the current notification in case it gets scheduled twice. LocalNotifications.instance.cancel(notification.id).finally(async () => { if (!triggered) { let soundEnabled: boolean; // Check if sound is enabled for notifications. if (!this.canDisableSound()) { soundEnabled = true; } else { soundEnabled = await CoreConfig.instance.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true); } if (!soundEnabled) { notification.sound = null; } else { delete notification.sound; // Use default value. } notification.foreground = true; // Remove from triggered, since the notification could be in there with a different time. this.removeTriggered(notification.id); LocalNotifications.instance.schedule(notification); } }); } /** * Function to call when a notification is triggered. Stores the notification so it's not scheduled again unless the * time is changed. * * @param notification Triggered notification. * @return Promise resolved when stored, rejected otherwise. */ async trigger(notification: ILocalNotification): Promise { await this.dbReady; const entry = { id: notification.id, at: notification.trigger && notification.trigger.at ? notification.trigger.at : Date.now(), }; return this.appDB.insertRecord(CoreLocalNotificationsProvider.TRIGGERED_TABLE, entry); } /** * Update a component name. * * @param oldName The old name. * @param newName The new name. * @return Promise resolved when done. */ async updateComponentName(oldName: string, newName: string): Promise { await this.dbReady; const oldId = CoreLocalNotificationsProvider.COMPONENTS_TABLE + '#' + oldName; const newId = CoreLocalNotificationsProvider.COMPONENTS_TABLE + '#' + newName; await this.appDB.updateRecords(CoreLocalNotificationsProvider.COMPONENTS_TABLE, { id: newId }, { id: oldId }); } } export class CoreLocalNotifications extends makeSingleton(CoreLocalNotificationsProvider) {} export type CoreLocalNotificationsClickCallback = (value: T) => void;