From a256ab97754320dbef973a2937f17446964c7515 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 14 Nov 2017 15:43:03 +0100 Subject: [PATCH] MOBILE-2261 localnotif: Implement Local Notifs provider and Mock --- package-lock.json | 5 + package.json | 1 + src/app/app.module.ts | 2 + src/core/constants.ts | 3 + src/core/emulator/emulator.module.ts | 13 +- src/core/emulator/providers/helper.ts | 60 +- .../emulator/providers/local-notifications.ts | 762 ++++++++++++++++++ src/providers/local-notifications.ts | 484 +++++++++++ 8 files changed, 1327 insertions(+), 3 deletions(-) create mode 100644 src/core/emulator/providers/local-notifications.ts create mode 100644 src/providers/local-notifications.ts diff --git a/package-lock.json b/package-lock.json index 91e0ba871..ce401e31e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,11 @@ "resolved": "https://registry.npmjs.org/@ionic-native/keyboard/-/keyboard-4.3.2.tgz", "integrity": "sha512-iTvFCONbE26+dXNdJAHmNxkfLbtN+CmiuJQUy56kvjzVvOTFI5gIZ0aE5nlbYpB1GY4DFQVw/+E+xc0Bpw5mKw==" }, + "@ionic-native/local-notifications": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@ionic-native/local-notifications/-/local-notifications-4.4.0.tgz", + "integrity": "sha512-h5P3+9lH0UeJnIBAqSM2LeKsNoJqlFAu2N0/43EtZgDsOkfWOjNSKCsCcrEoBEMi/qUnZyxNLAHfSMiYVGiuew==" + }, "@ionic-native/network": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@ionic-native/network/-/network-4.3.2.tgz", diff --git a/package.json b/package.json index 0d3753420..a9c263f0c 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@ionic-native/globalization": "^4.3.2", "@ionic-native/in-app-browser": "^4.3.3", "@ionic-native/keyboard": "^4.3.2", + "@ionic-native/local-notifications": "^4.4.0", "@ionic-native/network": "^4.3.2", "@ionic-native/splash-screen": "4.3.0", "@ionic-native/sqlite": "^4.3.2", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 18663bafb..52cade374 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -43,6 +43,7 @@ import { CoreWSProvider } from '../providers/ws'; import { CoreEventsProvider } from '../providers/events'; import { CoreSitesFactoryProvider } from '../providers/sites-factory'; import { CoreSitesProvider } from '../providers/sites'; +import { CoreLocalNotificationsProvider } from '../providers/local-notifications'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -93,6 +94,7 @@ export function createTranslateLoader(http: HttpClient) { CoreEventsProvider, CoreSitesFactoryProvider, CoreSitesProvider, + CoreLocalNotificationsProvider, ] }) export class AppModule { diff --git a/src/core/constants.ts b/src/core/constants.ts index 0519e2948..f73227031 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -23,7 +23,10 @@ export class CoreConstants { public static wifiDownloadThreshold = 104857600; // 100MB. public static downloadThreshold = 10485760; // 10MB. public static dontShowError = 'CoreDontShowError'; + + // Settings constants. public static settingsRichTextEditor = 'CoreSettingsRichTextEditor'; + public static settingsNotificationSound = 'CoreSettingsNotificationSound'; // WS constants. public static wsTimeout = 30000; diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index d1798a351..9b35d5d43 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -19,6 +19,7 @@ import { Clipboard } from '@ionic-native/clipboard'; import { File } from '@ionic-native/file'; import { FileTransfer } from '@ionic-native/file-transfer'; import { Globalization } from '@ionic-native/globalization'; +import { LocalNotifications } from '@ionic-native/local-notifications'; import { Network } from '@ionic-native/network'; import { Zip } from '@ionic-native/zip'; @@ -26,6 +27,7 @@ import { ClipboardMock } from './providers/clipboard'; import { FileMock } from './providers/file'; import { FileTransferMock } from './providers/file-transfer'; import { GlobalizationMock } from './providers/globalization'; +import { LocalNotificationsMock } from './providers/local-notifications'; import { NetworkMock } from './providers/network'; import { ZipMock } from './providers/zip'; import { InAppBrowser } from '@ionic-native/in-app-browser'; @@ -35,6 +37,7 @@ import { CoreAppProvider } from '../../providers/app'; import { CoreFileProvider } from '../../providers/file'; import { CoreTextUtilsProvider } from '../../providers/utils/text'; import { CoreMimetypeUtilsProvider } from '../../providers/utils/mimetype'; +import { CoreUtilsProvider } from '../../providers/utils/utils'; import { CoreInitDelegate } from '../../providers/init'; @NgModule({ @@ -44,8 +47,6 @@ import { CoreInitDelegate } from '../../providers/init'; ], providers: [ CoreEmulatorHelper, - ClipboardMock, - GlobalizationMock, { provide: Clipboard, deps: [CoreAppProvider], @@ -76,6 +77,14 @@ import { CoreInitDelegate } from '../../providers/init'; return appProvider.isMobile() ? new Globalization() : new GlobalizationMock(appProvider); } }, + { + provide: LocalNotifications, + deps: [CoreAppProvider, CoreUtilsProvider], + useFactory: (appProvider: CoreAppProvider, utils: CoreUtilsProvider) => { + // Use platform instead of CoreAppProvider to prevent circular dependencies. + return appProvider.isMobile() ? new LocalNotifications() : new LocalNotificationsMock(appProvider, utils); + } + }, { provide: Network, deps: [Platform], diff --git a/src/core/emulator/providers/helper.ts b/src/core/emulator/providers/helper.ts index 1a0322a7c..7e2e76c5f 100644 --- a/src/core/emulator/providers/helper.ts +++ b/src/core/emulator/providers/helper.ts @@ -13,9 +13,11 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreAppProvider } from '../../../providers/app'; import { CoreFileProvider } from '../../../providers/file'; import { CoreUtilsProvider } from '../../../providers/utils/utils'; import { File } from '@ionic-native/file'; +import { LocalNotifications } from '@ionic-native/local-notifications'; import { CoreInitDelegate, CoreInitHandler } from '../../../providers/init'; import { FileTransferErrorMock } from './file-transfer'; @@ -29,10 +31,64 @@ export class CoreEmulatorHelper implements CoreInitHandler { blocking = true; constructor(private file: File, private fileProvider: CoreFileProvider, private utils: CoreUtilsProvider, - initDelegate: CoreInitDelegate) { + initDelegate: CoreInitDelegate, private localNotif: LocalNotifications, private appProvider: CoreAppProvider) { this.priority = initDelegate.MAX_RECOMMENDED_PRIORITY + 500; } + /** + * Check if the app is running in a Linux environment. + * + * @return {boolean} Whether it's running in a Linux environment. + */ + isLinux() : boolean { + if (!this.appProvider.isDesktop()) { + return false; + } + + try { + var os = require('os'); + return os.platform().indexOf('linux') === 0; + } catch(ex) { + return false; + } + } + + /** + * Check if the app is running in a Mac OS environment. + * + * @return {boolean} Whether it's running in a Mac OS environment. + */ + isMac() : boolean { + if (!this.appProvider.isDesktop()) { + return false; + } + + try { + var os = require('os'); + return os.platform().indexOf('darwin') === 0; + } catch(ex) { + return false; + } + } + + /** + * Check if the app is running in a Windows environment. + * + * @return {boolean} Whether it's running in a Windows environment. + */ + isWindows() : boolean { + if (!this.appProvider.isDesktop()) { + return false; + } + + try { + var os = require('os'); + return os.platform().indexOf('win') === 0; + } catch(ex) { + return false; + } + } + /** * Load the Mocks that need it. * @@ -44,6 +100,8 @@ export class CoreEmulatorHelper implements CoreInitHandler { promises.push((this.file).load().then((basePath: string) => { this.fileProvider.setHTMLBasePath(basePath); })); + promises.push((this.localNotif).load(this.isWindows())); + (window).FileTransferError = FileTransferErrorMock; return this.utils.allPromises(promises); diff --git a/src/core/emulator/providers/local-notifications.ts b/src/core/emulator/providers/local-notifications.ts new file mode 100644 index 000000000..209788430 --- /dev/null +++ b/src/core/emulator/providers/local-notifications.ts @@ -0,0 +1,762 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { LocalNotifications, ILocalNotification } from '@ionic-native/local-notifications'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { SQLiteDB } from '../../../classes/sqlitedb'; +import { CoreConstants } from '../../constants'; +import { CoreConfigConstants } from '../../../configconstants'; +import * as moment from 'moment'; + +/** + * Emulates the Cordova Globalization plugin in desktop apps and in browser. + */ +@Injectable() +export class LocalNotificationsMock extends LocalNotifications { + // Variables for Windows notifications. + protected winNotif; // Library for Windows notifications. + // Templates for Windows ToastNotifications and TileNotifications. + protected toastTemplate = '%s' + + '%s'; + protected tileBindingTemplate = '%s' + + '%s'; + protected tileTemplate = '' + + '' + this.tileBindingTemplate + '' + + '' + this.tileBindingTemplate + '' + + '' + this.tileBindingTemplate + '' + + ''; + + // Variables for database. + protected DESKTOP_NOTIFS_TABLE = 'desktop_local_notifications'; + protected tableSchema = { + name: this.DESKTOP_NOTIFS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'triggered', + type: 'INTEGER' + } + ] + }; + + protected appDB: SQLiteDB; + protected scheduled: {[i: number] : any} = {}; + protected triggered: {[i: number] : any} = {}; + protected observers; + protected defaults = { + text: '', + title: '', + sound: '', + badge: 0, + id: 0, + data: undefined, + every: undefined, + at: undefined + }; + + constructor(private appProvider: CoreAppProvider, private utils: CoreUtilsProvider) { + super(); + + this.appDB = appProvider.getDB(); + this.appDB.createTableFromSchema(this.tableSchema); + + // Initialize observers. + this.observers = { + schedule: [], + trigger: [], + click: [], + update: [], + clear: [], + clearall: [], + cancel: [], + cancelall: [] + }; + } + + + /** + * Cancels single or multiple notifications + * @param notificationId {any} A single notification id, or an array of notification ids. + * @returns {Promise} Returns a promise when the notification is canceled + */ + cancel(notificationId: any): Promise { + let promises = []; + + notificationId = Array.isArray(notificationId) ? notificationId : [notificationId]; + notificationId = this.convertIds(notificationId); + + // Cancel the notifications. + notificationId.forEach((id) => { + if (this.scheduled[id]) { + promises.push(this.cancelNotification(id, false, 'cancel')); + } + }); + + return Promise.all(promises); + } + + /** + * Cancels all notifications. + * + * @returns {Promise} Returns a promise when all notifications are canceled. + */ + cancelAll(): Promise { + let ids = Object.keys(this.scheduled); + return this.cancel(ids).then(() => { + this.triggerEvent('cancelall', 'foreground'); + }); + } + + /** + * Cancel a local notification. + * + * @param {number} id Notification ID. + * @param {boolean} omitEvent If true, the clear/cancel event won't be triggered. + * @param {string} eventName Name of the event to trigger. + * @return {Void} + */ + protected cancelNotification(id: number, omitEvent: boolean, eventName: string) : void { + let notification = this.scheduled[id].notification; + + clearTimeout(this.scheduled[id].timeout); + clearInterval(this.scheduled[id].interval); + delete this.scheduled[id]; + delete this.triggered[id]; + + this.removeNotification(id); + + if (!omitEvent) { + this.triggerEvent(eventName, notification, 'foreground'); + } + } + + /** + * Clears single or multiple notifications. + * + * @param {any} notificationId A single notification id, or an array of notification ids. + * @returns {Promise} Returns a promise when the notification had been cleared. + */ + clear(notificationId: any): Promise { + let promises = []; + + notificationId = Array.isArray(notificationId) ? notificationId : [notificationId]; + notificationId = this.convertIds(notificationId); + + // Clear the notifications. + notificationId.forEach((id) => { + // Cancel only the notifications that aren't repeating. + if (this.scheduled[id] && this.scheduled[id].notification && !this.scheduled[id].notification.every) { + promises.push(this.cancelNotification(id, false, 'clear')); + } + }); + + return Promise.all(promises); + } + /** + * Clears all notifications. + * + * @returns {Promise} Returns a promise when all notifications have cleared + */ + clearAll(): Promise { + let ids = Object.keys(this.scheduled); + return this.clear(ids).then(() => { + this.triggerEvent('clearall', 'foreground'); + }); + } + + /** + * Convert a list of IDs to numbers. + * Code extracted from the Cordova plugin. + * + * @param {any[]} ids List of IDs. + * @return {Number[]} List of IDs as numbers. + */ + protected convertIds(ids: any[]) : number[] { + let convertedIds = []; + + for (let i = 0; i < ids.length; i++) { + convertedIds.push(Number(ids[i])); + } + + return convertedIds; + } + + /** + * Convert the notification options to their required type. + * Code extracted from the Cordova plugin. + * + * @param {ILocalNotification} notification Notification. + * @return {ILocalNotification} Converted notification. + */ + protected convertProperties(notification: ILocalNotification) : ILocalNotification { + if (notification.id) { + if (isNaN(notification.id)) { + notification.id = this.defaults.id; + } else { + notification.id = Number(notification.id); + } + } + + if (notification.title) { + notification.title = notification.title.toString(); + } + + if (notification.text) { + notification.text = notification.text.toString(); + } + + if (notification.badge) { + if (isNaN(notification.badge)) { + notification.badge = this.defaults.badge; + } else { + notification.badge = Number(notification.badge); + } + } + + if (notification.at) { + if (typeof notification.at == 'object') { + notification.at = notification.at.getTime(); + } + + notification.at = Math.round(notification.at / 1000); + } + + if (typeof notification.data == 'object') { + notification.data = JSON.stringify(notification.data); + } + + return notification; + } + + /** + * Get a notification object. + * + * @param {any} notificationId The id of the notification to get. + * @returns {Promise} + */ + get(notificationId: any): Promise { + return Promise.resolve(this.getNotifications([Number(notificationId)], true, true)[0]); + } + + /** + * Get all notification objects. + * + * @returns {Promise>} + */ + getAll(): Promise> { + return Promise.resolve(this.getNotifications(null, true, true)); + } + + /** + * Get all the notification ids. + * + * @returns {Promise>} + */ + getAllIds(): Promise> { + let ids = this.utils.mergeArraysWithoutDuplicates(Object.keys(this.scheduled), Object.keys(this.triggered)); + ids = ids.map((id) => { + return Number(id); + }); + return Promise.resolve(ids); + } + + /** + * Get all the notification stored in local DB. + * + * @return {Promise} Promise resolved with the notifications. + */ + protected getAllNotifications() : Promise { + return this.appDB.getAllRecords(this.DESKTOP_NOTIFS_TABLE); + } + + /** + * Get all scheduled notification objects. + * + * @returns {Promise>} + */ + getAllScheduled(): Promise> { + return Promise.resolve(this.getNotifications(null, true, false)); + } + + /** + * Get all triggered notification objects. + * + * @returns {Promise>} + */ + getAllTriggered(): Promise> { + return Promise.resolve(this.getNotifications(null, false, true)); + } + + /** + * Get a set of notifications. If ids isn't specified, return all the notifications. + * + * @param {Number[]} [ids] Ids of notifications to get. If not specified, get all notifications. + * @param {boolean} [getScheduled] Get scheduled notifications. + * @param {boolean} [getTriggered] Get triggered notifications. + * @return {ILocalNotification[]} List of notifications. + */ + protected getNotifications(ids?: number[], getScheduled?: boolean, getTriggered?: boolean) : ILocalNotification[] { + let notifications = []; + + if (getScheduled) { + for (let id in this.scheduled) { + if (!ids || ids.indexOf(Number(id)) != -1) { + notifications.push(this.scheduled[id].notification); + } + } + } + + if (getTriggered) { + for (let id in this.triggered) { + if ((!getScheduled || !this.scheduled[id]) && (!ids || ids.indexOf(Number(id)) != -1)) { + notifications.push(this.triggered[id].notification); + } + } + } + + return notifications; + } + + /** + * Get a scheduled notification object. + * + * @param {any} notificationId The id of the notification to ge. + * @returns {Promise} + */ + getScheduled(notificationId: any): Promise { + return Promise.resolve(this.getNotifications([Number(notificationId)], true, false)[0]); + } + + /** + * Get the ids of scheduled notifications. + * + * @returns {Promise>} Returns a promise + */ + getScheduledIds(): Promise> { + let ids = Object.keys(this.scheduled).map((id) => { + return Number(id); + }); + return Promise.resolve(ids); + } + + /** + * Get a triggered notification object. + * + * @param {any} notificationId The id of the notification to get. + * @returns {Promise} + */ + getTriggered(notificationId: any): Promise { + return Promise.resolve(this.getNotifications([Number(notificationId)], false, true)[0]); + } + + /** + * Get the ids of triggered notifications. + * + * @returns {Promise>} + */ + getTriggeredIds(): Promise> { + let ids = Object.keys(this.triggered).map((id) => { + return Number(id); + }); + return Promise.resolve(ids); + } + + /** + * Given an object of options and a list of properties, return the first property that exists. + * Code extracted from the Cordova plugin. + * + * @param {ILocalNotification} notification Notification. + * @param {any} ...args List of keys to check. + * @return {any} First value found. + */ + protected getValueFor(notification: ILocalNotification, ...args) : any { + for (let i in args) { + const key = args[i]; + if (notification.hasOwnProperty(key)) { + return notification[key]; + } + } + } + + /** + * Informs if the app has the permission to show notifications. + * + * @returns {Promise} + */ + hasPermission(): Promise { + return Promise.resolve(true); + } + + /** + * Checks presence of a notification. + * + * @param {number} notificationId Notification ID. + * @returns {Promise} + */ + isPresent(notificationId: number): Promise { + return Promise.resolve(!!this.scheduled[notificationId] || !!this.triggered[notificationId]); + } + + /** + * Checks is a notification is scheduled. + * + * @param {number} notificationId Notification ID. + * @returns {Promise} + */ + isScheduled(notificationId: number): Promise { + return Promise.resolve(!!this.scheduled[notificationId]); + } + /** + * Checks if a notification is triggered. + * + * @param {number} notificationId Notification ID. + * @returns {Promise} + */ + isTriggered(notificationId: number): Promise { + return Promise.resolve(!!this.triggered[notificationId]); + } + + /** + * Loads an initialize the API for desktop. + * + * @param {boolean} isWindows Whether the app is running in a Windows environment. + * @return {Promise} Promise resolved when done. + */ + load(isWindows: boolean) : Promise { + if (!this.appProvider.isDesktop()) { + return Promise.resolve(); + } + + if (isWindows) { + try { + this.winNotif = require('electron-windows-notifications'); + } catch(ex) {} + } + + + // App is being loaded, re-schedule all the notifications that were scheduled before. + return this.getAllNotifications().catch(() => { + return []; + }).then((notifications) => { + notifications.forEach((notification) => { + if (notification.triggered) { + // Notification was triggered already, store it in memory but don't schedule it again. + delete notification.triggered; + this.scheduled[notification.id] = { + notification: notification + }; + this.triggered[notification.id] = notification; + } else { + // Schedule the notification again unless it should have been triggered more than an hour ago. + delete notification.triggered; + notification.at = notification.at * 1000; + if (notification.at - Date.now() > - CoreConstants.secondsHour * 1000) { + this.schedule(notification); + } + } + }); + }); + } + + /** + * Merge notification options with default values. + * Code extracted from the Cordova plugin. + * + * @param {ILocalNotification} notification Notification. + * @return {ILocalNotification} Treated notification. + */ + protected mergeWithDefaults(notification: ILocalNotification) : ILocalNotification { + notification.at = this.getValueFor(notification, 'at', 'firstAt', 'date'); + notification.text = this.getValueFor(notification, 'text', 'message'); + notification.data = this.getValueFor(notification, 'data', 'json'); + + if (notification.at === undefined || notification.at === null) { + notification.at = new Date(); + } + + for (let key in this.defaults) { + if (notification[key] === null || notification[key] === undefined) { + if (notification.hasOwnProperty(key) && ['data','sound'].indexOf(key) > -1) { + notification[key] = undefined; + } else { + notification[key] = this.defaults[key]; + } + } + } + + for (let key in notification) { + if (!this.defaults.hasOwnProperty(key)) { + delete notification[key]; + } + } + + return notification; + } + + /** + * Function called when a notification is clicked. + * + * @param {ILocalNotification} notification Clicked notification. + */ + protected notificationClicked(notification: ILocalNotification) : void { + this.triggerEvent('click', notification, 'foreground'); + // Focus the app. + require('electron').ipcRenderer.send('focusApp'); + } + + /** + * Sets a callback for a specific event. + * + * @param {string} eventName Name of the event. Events: schedule, trigger, click, update, clear, clearall, cancel, cancelall + * @param {any} callback Call back function. + */ + on(eventName: string, callback: any): void { + if (!this.observers[eventName] || typeof callback != 'function') { + // Event not supported, stop. + return; + } + this.observers[eventName].push(callback); + } + + /** + * Parse a interval and convert it to a number of milliseconds (0 if not valid). + * Code extracted from the Cordova plugin. + * + * @param {string} every Interval to convert. + * @return {number} Number of milliseconds of the interval- + */ + protected parseInterval(every: string) { + let interval; + + every = String(every).toLowerCase(); + + if (!every || every == 'undefined') { + interval = 0; + } else if (every == 'second') { + interval = 1000; + } else if (every == 'minute') { + interval = CoreConstants.secondsMinute * 1000; + } else if (every == 'hour') { + interval = CoreConstants.secondsHour * 1000; + } else if (every == 'day') { + interval = CoreConstants.secondsDay * 1000; + } else if (every == 'week') { + interval = CoreConstants.secondsDay * 7 * 1000; + } else if (every == 'month') { + interval = CoreConstants.secondsDay * 31 * 1000; + } else if (every == 'quarter') { + interval = CoreConstants.secondsHour * 2190 * 1000; + } else if (every == 'year') { + interval = CoreConstants.secondsYear * 1000; + } else { + interval = parseInt(every, 10); + if (isNaN(interval)) { + interval = 0; + } else { + interval *= 60000; + } + } + + return interval; + } + + /** + * Register permission to show notifications if not already granted. + * + * @returns {Promise} + */ + registerPermission(): Promise { + return Promise.resolve(true); + } + + /** + * Remove a notification from local DB. + * + * @param {number} id ID of the notification. + * @return {Promise} Promise resolved when done. + */ + protected removeNotification(id: number) : Promise { + return this.appDB.deleteRecords(this.DESKTOP_NOTIFS_TABLE, {id: id}); + } + + /** + * Schedules a single or multiple notifications. + * + * @param {ILocalNotification | Array} [options] Notification or notifications. + */ + schedule(options?: ILocalNotification | Array): void { + this.scheduleOrUpdate(options); + } + + /** + * Schedules or updates a single or multiple notifications. + * + * @param {ILocalNotification | Array} [options] Notification or notifications. + * @param {string} [eventName] Name of the event: schedule or update. + */ + protected scheduleOrUpdate(options?: ILocalNotification | Array, eventName = 'schedule'): void { + options = Array.isArray(options) ? options : [options]; + + options.forEach((notification) => { + this.mergeWithDefaults(notification); + this.convertProperties(notification); + + // Cancel current notification if exists. + this.cancelNotification(notification.id, true, 'cancel'); + + // Store the notification in the scheduled list and in the DB. + this.scheduled[notification.id] = { + notification: notification + }; + this.storeNotification(notification, false); + + if (Math.abs(moment().diff(notification.at * 1000, 'days')) > 15) { + // Notification should trigger more than 15 days from now, don't schedule it. + return; + } + + // Schedule the notification. + let toTriggerTime = notification.at * 1000 - Date.now(), + trigger = () => { + // Trigger the notification. + this.triggerNotification(notification); + + // Store the notification as triggered. Don't remove it from scheduled, it's how the plugin works. + this.triggered[notification.id] = notification; + this.storeNotification(notification, true); + + // Launch the trigger event. + this.triggerEvent('trigger', notification, 'foreground'); + + if (notification.every && this.scheduled[notification.id] && !this.scheduled[notification.id].interval) { + const interval = this.parseInterval(notification.every); + if (interval > 0) { + this.scheduled[notification.id].interval = setInterval(trigger, interval); + } + } + }; + + this.scheduled[notification.id].timeout = setTimeout(trigger, toTriggerTime); + + // Launch the scheduled/update event. + this.triggerEvent(eventName, notification, 'foreground'); + }); + } + + /** + * Store a notification in local DB. + * + * @param {any} notification Notification to store. + * @param {boolean} triggered Whether the notification has been triggered. + * @return {Promise} Promise resolved when stored. + */ + protected storeNotification(notification: any, triggered: boolean) : Promise { + notification = Object.assign({}, notification); // Clone the object. + notification.triggered = !!triggered; + return this.appDB.insertOrUpdateRecord(this.DESKTOP_NOTIFS_TABLE, notification, {id: notification.id}); + } + + /** + * Trigger an event. + * + * @param {string} eventName Event name. + * @param {any[]} ...args List of parameters to pass. + */ + protected triggerEvent(eventName: string, ...args: any[]) { + if (this.observers[eventName]) { + this.observers[eventName].forEach((callback) => { + callback.apply(null, args); + }); + } + } + + /** + * Trigger a notification, using the best method depending on the OS. + * + * @param {ILocalNotification} notification Notification to trigger. + */ + protected triggerNotification(notification: ILocalNotification) : void { + if (this.winNotif) { + // Use Windows notifications. + let notifInstance = new this.winNotif.ToastNotification({ + appId: CoreConfigConstants.app_id, + template: this.toastTemplate, + strings: [notification.title, notification.text] + }); + + // Listen for click events. + notifInstance.on('activated', () => { + this.notificationClicked(notification); + }); + + notifInstance.show(); + + try { + // Show it in Tile too. + let tileNotif = new this.winNotif.TileNotification({ + tag: notification.id + '', + template: this.tileTemplate, + strings: [notification.title, notification.text, notification.title, notification.text, notification.title, notification.text], + expirationTime: new Date(Date.now() + CoreConstants.secondsHour * 1000) // Expire in 1 hour. + }) + + tileNotif.show() + } catch(ex) { + console.warn('Error showing TileNotification. Please notice they only work with the app installed.', ex); + } + } else { + // Use Electron default notifications. + const notifInstance = new Notification(notification.title, { + body: notification.text + }); + + // Listen for click events. + notifInstance.onclick = () => { + this.notificationClicked(notification); + }; + } + } + + /** + * Removes a callback of a specific event. + * + * @param {string} eventName Name of the event. Events: schedule, trigger, click, update, clear, clearall, cancel, cancelall + * @param {any} callback Call back function. + */ + un(eventName: string, callback: any): void { + if (this.observers[eventName] && this.observers[eventName].length) { + for (let i = 0; i < this.observers[eventName].length; i++) { + if (this.observers[eventName][i] == callback) { + this.observers[eventName].splice(i, 1); + break; + } + } + } + } + + /** + * Updates a previously scheduled notification. Must include the id in the options parameter. + * + * @param {ILocalNotification} [options] Notification. + */ + update(options?: ILocalNotification): void { + return this.scheduleOrUpdate(options, 'update'); + }; +} diff --git a/src/providers/local-notifications.ts b/src/providers/local-notifications.ts new file mode 100644 index 000000000..c95ef78c4 --- /dev/null +++ b/src/providers/local-notifications.ts @@ -0,0 +1,484 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Platform } from 'ionic-angular'; +import { LocalNotifications, ILocalNotification } from '@ionic-native/local-notifications'; +import { CoreAppProvider } from './app'; +import { CoreConfigProvider } from './config'; +import { CoreLoggerProvider } from './logger'; +import { CoreDomUtilsProvider } from './utils/dom'; +import { CoreUtilsProvider } from './utils/utils'; +import { SQLiteDB } from '../classes/sqlitedb'; +import { CoreConstants } from '../core/constants'; +import { Subject } from 'rxjs'; + +export interface CoreILocalNotification extends ILocalNotification { + ledOnTime?: number; + ledOffTime?: number; +} + +/* + Generated class for the LocalNotificationsProvider provider. + + See https://angular.io/guide/dependency-injection for more info on providers + and Angular DI. +*/ +@Injectable() +export class CoreLocalNotificationsProvider { + // Variables for the database. + protected SITES_TABLE = 'notification_sites'; // Store to asigne unique codes to each site. + protected COMPONENTS_TABLE = 'notification_components'; // Store to asigne unique codes to each component. + protected TRIGGERED_TABLE = 'notifications_triggered'; // Store to prevent re-triggering notifications. + protected tablesSchema = [ + { + name: this.SITES_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'code', + type: 'INTEGER', + notNull: true + } + ] + }, + { + name: this.COMPONENTS_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'code', + type: 'INTEGER', + notNull: true + } + ] + }, + { + name: this.TRIGGERED_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'at', + type: 'INTEGER', + notNull: true + } + ] + } + ]; + + protected logger; + protected appDB: SQLiteDB; + protected codes: {[s: string]: number} = {}; + protected codeRequestsQueue = {}; + protected observables = {}; + + constructor(logger: CoreLoggerProvider, private localNotifications: LocalNotifications, private platform: Platform, + private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, private configProvider: CoreConfigProvider, + private domUtils: CoreDomUtilsProvider) { + this.logger = logger.getInstance('CoreLocalNotificationsProvider'); + this.appDB = appProvider.getDB(); + this.appDB.createTablesFromSchema(this.tablesSchema); + } + + /** + * Cancel a local notification. + * + * @param {number} id Notification id. + * @param {string} component Component of the notification. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when the notification is cancelled. + */ + cancel(id, component, siteId) : Promise { + return this.getUniqueNotificationId(id, component, siteId).then((uniqueId) => { + return this.localNotifications.cancel(uniqueId); + }); + } + + /** + * Cancel all the scheduled notifications belonging to a certain site. + * + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when the notifications are cancelled. + */ + cancelSiteNotifications(siteId) : Promise { + + if (!this.isAvailable()) { + return Promise.resolve(); + } else if (!siteId) { + return Promise.reject(null); + } + + return this.localNotifications.getAllScheduled().then((scheduled) => { + let ids = []; + + scheduled.forEach((notif) => { + if (typeof notif.data == 'string') { + notif.data = JSON.parse(notif.data); + } + + if (typeof notif.data == 'object' && notif.data.siteId === siteId) { + ids.push(notif.id); + } + }); + + return this.localNotifications.cancel(ids); + }); + } + + /** + * Get a code to create unique notifications. If there's no code assigned, create a new one. + * + * @param {string} table Table to search in local DB. + * @param {string} id ID of the element to get its code. + * @return {Promise} Promise resolved when the code is retrieved. + */ + protected getCode(table, id) : Promise { + const key = table + '#' + id; + + // Check if the code is already in memory. + if (typeof this.codes[key] != 'undefined') { + return Promise.resolve(this.codes[key]); + } + + // Check if we already have a code stored for that ID. + return this.appDB.getRecord(table, {id: id}).then((entry) => { + this.codes[key] = entry.code; + return entry.code; + }).catch(() => { + // No code stored for that ID. Create a new code for it. + return this.appDB.getRecords(table, undefined, 'code DESC').then((entries) => { + let newCode = 0; + if (entries.length > 0) { + newCode = entries[0].code + 1; + } + return this.appDB.insertRecord(table, {id: id, code: newCode}).then(() => { + 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 {string} component Component name. + * @return {Promise} Promise resolved when the component code is retrieved. + */ + protected getComponentCode(component: string) : Promise { + return this.requestCode(this.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 {string} siteId Site ID. + * @return {Promise} Promise resolved when the site code is retrieved. + */ + protected getSiteCode(siteId: string) : Promise { + return this.requestCode(this.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 {number} notificationId Notification ID. + * @param {string} component Component triggering the notification. + * @param {string} siteId Site ID. + * @return {Promise} 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) => { + return this.getComponentCode(component).then((componentCode) => { + // We use the % operation to keep the number under Android's limit. + return (siteCode * 100000000 + componentCode * 10000000 + notificationId) % 2147483647; + }); + }); + } + + /** + * Returns whether local notifications plugin is installed. + * + * @return {boolean} Whether local notifications plugin is installed. + */ + isAvailable() : boolean { + let win = window; + return this.appProvider.isDesktop() || !!(win.plugin && win.plugin.notification && win.plugin.notification.local); + } + + /** + * Check if a notification has been triggered with the same trigger time. + * + * @param {CoreILocalNotification} notification Notification to check. + * @return {Promise} Promise resolved with a boolean indicating if promise is triggered (true) or not. + */ + isTriggered(notification: CoreILocalNotification) { + return this.appDB.getRecord(this.TRIGGERED_TABLE, {id: notification.id}).then((stored) => { + return stored.at === notification.at.getTime() / 1000; + }).catch(() => { + return false; + }); + } + + /** + * Notify notification click to observers. Only the observers with the same component as the notification will be notified. + * + * @param {any} data Data received by the notification. + */ + notifyClick(data: any) : void { + const component = data.component; + if (component) { + if (this.observables[component]) { + this.observables[component].next(data); + } + } + } + + /** + * Process the next request in queue. + */ + protected processNextRequest() : void { + let nextKey = Object.keys(this.codeRequestsQueue)[0], + request, + promise; + + if (typeof nextKey == 'undefined') { + // No more requests in queue, stop. + return; + } + + 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 {string} component Component to listen notifications for. + * @param {Function} callback Function to call with the data received by the notification. + * @return {any} Object with an "off" property to stop listening for clicks. + */ + registerClick(component: string, callback: Function) : any { + this.logger.debug(`Register observer '${component}' for notification click.`); + + if (typeof this.observables[component] == 'undefined') { + // No observable for this component, create a new one. + this.observables[component] = new Subject(); + } + + this.observables[component].subscribe(callback); + + return { + off: () => { + this.observables[component].unsubscribe(callback); + } + }; + } + + /** + * Remove a notification from triggered store. + * + * @param {number} id Notification ID. + * @return {Promise} Promise resolved when it is removed. + */ + removeTriggered(id: number) : Promise { + return this.appDB.deleteRecords(this.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 {string} table Table to search in local DB. + * @param {string} id ID of the element to get its code. + * @return {Promise} Promise resolved when the code is retrieved. + */ + protected requestCode(table: string, id: string) : Promise { + let deferred = this.utils.promiseDefer(), + key = table + '#' + id, + 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} Promise resolved when all notifications have been rescheduled. + */ + rescheduleAll() : Promise { + // Get all the scheduled notifications. + return this.localNotifications.getAllScheduled().then((notifications) => { + let promises = []; + + notifications.forEach((notification) => { + // Convert some properties to the needed types. + notification.at = new Date(notification.at * 1000); + notification.data = notification.data ? JSON.parse(notification.data) : {}; + + promises.push(this.scheduleNotification(notification)); + }); + + return Promise.all(promises); + }); + } + + /** + * Schedule a local notification. + * + * @param {CoreILocalNotification} notification Notification to schedule. Its ID should be lower than 10000000 and it should + * be unique inside its component and site. + * @param {string} component Component triggering the notification. It is used to generate unique IDs. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when the notification is scheduled. + */ + schedule(notification: CoreILocalNotification, component: string, siteId: string) : Promise { + return this.getUniqueNotificationId(notification.id, component, siteId).then((uniqueId) => { + notification.id = uniqueId; + notification.data = notification.data || {}; + notification.data.component = component; + notification.data.siteId = siteId; + + if (this.platform.is('android')) { + notification.icon = notification.icon || 'res://icon'; + notification.smallIcon = notification.smallIcon || 'res://icon'; + notification.led = notification.led || 'FF9900'; + notification.ledOnTime = notification.ledOnTime || 1000; + notification.ledOffTime = notification.ledOffTime || 1000; + } + + return this.scheduleNotification(notification); + }); + } + + /** + * Helper function to schedule a notification object if it hasn't been triggered already. + * + * @param {CoreILocalNotification} notification Notification to schedule. + * @return {Promise} Promise resolved when scheduled. + */ + protected scheduleNotification(notification: CoreILocalNotification) : Promise { + // Check if the notification has been triggered already. + return this.isTriggered(notification).then((triggered) => { + if (!triggered) { + // Check if sound is enabled for notifications. + return this.configProvider.get(CoreConstants.settingsNotificationSound, true).then((soundEnabled) => { + if (!soundEnabled) { + notification.sound = null; + } else { + delete notification.sound; // Use default value. + } + + // Remove from triggered, since the notification could be in there with a different time. + this.removeTriggered(notification.id); + this.localNotifications.schedule(notification); + }); + } + }); + } + + /** + * Show an in app notification popover. + * + * @param {CoreILocalNotification} notification Notification. + */ + showNotificationPopover(notification: CoreILocalNotification) : void { + // @todo Improve it. For now, show Toast. + if (!notification || !notification.title || !notification.text) { + // Invalid data. + return; + } + + this.domUtils.showToast(notification.text, false, 4000); + } + + /** + * Function to call when a notification is triggered. Stores the notification so it's not scheduled again unless the + * time is changed. + * + * @param {CoreILocalNotification} notification Triggered notification. + * @return {Promise} Promise resolved when stored, rejected otherwise. + */ + trigger(notification: CoreILocalNotification) : Promise { + if (this.platform.is('ios') && this.platform.version().num >= 10) { + // In iOS10 show in app notification. + this.showNotificationPopover(notification); + } + + let entry = { + id: notification.id, + at: parseInt(notification.at, 10) + }; + return this.appDB.insertOrUpdateRecord(this.TRIGGERED_TABLE, entry, {id: notification.id}); + } +}