diff --git a/src/addons/calendar/pages/index/index.html b/src/addons/calendar/pages/index/index.html index 61b07e7d5..50cdb0f25 100644 --- a/src/addons/calendar/pages/index/index.html +++ b/src/addons/calendar/pages/index/index.html @@ -15,8 +15,8 @@ iconAction="fas-th-list" (action)="toggleDisplay()"> - + [] = []; - notificationsEnabled = false; loaded = false; hasOffline = false; isOnline = false; @@ -165,8 +163,6 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { * View loaded. */ ngOnInit(): void { - this.notificationsEnabled = CoreLocalNotifications.isAvailable(); - this.loadUpcoming = !!CoreNavigator.getRouteBooleanParam('upcoming'); this.showCalendar = !this.loadUpcoming; diff --git a/src/addons/calendar/services/calendar.ts b/src/addons/calendar/services/calendar.ts index 81d8cab75..bc13c16a9 100644 --- a/src/addons/calendar/services/calendar.ts +++ b/src/addons/calendar/services/calendar.ts @@ -1384,10 +1384,6 @@ export class AddonCalendarProvider { async updateAllSitesEventReminders(): Promise { await CorePlatform.ready(); - if (!CoreLocalNotifications.isAvailable()) { - return; - } - const siteIds = await CoreSites.getSitesIds(); await Promise.all(siteIds.map((siteId: string) => async () => { @@ -1430,11 +1426,6 @@ export class AddonCalendarProvider { events: ({ id: number; timestart: number; name: string})[], siteId: string, ): Promise { - - if (!CoreLocalNotifications.isAvailable()) { - return; - } - await Promise.all(events.map(async (event) => { if (event.timestart * 1000 <= Date.now()) { // The event has already started, don't schedule it. diff --git a/src/core/features/emulator/emulator.module.ts b/src/core/features/emulator/emulator.module.ts index 227648524..48ef1aa05 100644 --- a/src/core/features/emulator/emulator.module.ts +++ b/src/core/features/emulator/emulator.module.ts @@ -25,6 +25,7 @@ import { FileOpener } from '@ionic-native/file-opener/ngx'; import { FileTransfer } from '@ionic-native/file-transfer/ngx'; import { Geolocation } from '@ionic-native/geolocation/ngx'; import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; +import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; import { MediaCapture } from '@ionic-native/media-capture/ngx'; import { Zip } from '@ionic-native/zip/ngx'; @@ -36,9 +37,11 @@ import { FileOpenerMock } from './services/file-opener'; import { FileTransferMock } from './services/file-transfer'; import { GeolocationMock } from './services/geolocation'; import { InAppBrowserMock } from './services/inappbrowser'; +import { LocalNotificationsMock } from './services/local-notifications'; import { MediaCaptureMock } from './services/media-capture'; import { ZipMock } from './services/zip'; import { CorePlatform } from '@services/platform'; +import { CoreLocalNotifications } from '@services/local-notifications'; /** * This module handles the emulation of Cordova plugins in browser and desktop. @@ -90,6 +93,12 @@ import { CorePlatform } from '@services/platform'; provide: Zip, useFactory: (): Zip => CorePlatform.is('cordova') ? new Zip() : new ZipMock(), }, + { + provide: LocalNotifications, + useFactory: (): LocalNotifications => CoreLocalNotifications.isPluginAvailable() + ? new LocalNotifications() + : new LocalNotificationsMock(), + }, { provide: APP_INITIALIZER, useFactory: () => () => { diff --git a/src/core/features/emulator/services/local-notifications.ts b/src/core/features/emulator/services/local-notifications.ts new file mode 100644 index 000000000..8fac97b80 --- /dev/null +++ b/src/core/features/emulator/services/local-notifications.ts @@ -0,0 +1,407 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { ILocalNotification, ILocalNotificationAction, LocalNotifications } from '@ionic-native/local-notifications/ngx'; +import { Observable, Subject } from 'rxjs'; + +/** + * Mock LocalNotifications service. + */ +export class LocalNotificationsMock extends LocalNotifications { + + protected scheduledNotifications: ILocalNotification[] = []; + protected triggeredNotifications: ILocalNotification[] = []; + protected presentNotifications: Record = {}; + protected nextTimeout = 0; + protected hasGranted?: boolean; + protected observables = { + trigger: new Subject(), + click: new Subject(), + clear: new Subject(), + clearall: new Subject(), + cancel: new Subject(), + cancelall: new Subject(), + schedule: new Subject(), + update: new Subject(), + }; + + /** + * @inheritdoc + */ + schedule(options?: ILocalNotification | Array): void { + this.hasPermission().then(() => { + // Do not check permission here, it could be denied by Selenium. + if (!options) { + return; + } + + if (!Array.isArray(options)) { + options = [options]; + } + + this.scheduledNotifications = this.scheduledNotifications.concat(options); + this.scheduledNotifications.sort((a, b) => + (a.trigger?.at?.getTime() || 0) - (b.trigger?.at?.getTime() || 0)); + + options.forEach((notification) => { + this.observables.schedule.next(notification); + }); + + this.scheduleNotifications(); + + return; + }).catch(() => { + // Ignore errors. + }); + } + + /** + * Sets timeout for next nofitication. + */ + protected scheduleNotifications(): void { + window.clearTimeout(this.nextTimeout); + + const nextNotification = this.scheduledNotifications[0]; + if (!nextNotification) { + return; + } + + const notificationTime = nextNotification.trigger?.at?.getTime() || 0; + const timeout = notificationTime - Date.now(); + if (timeout <= 0) { + this.triggerNextNotification(); + + return; + } + + this.nextTimeout = window.setTimeout(() => { + this.triggerNextNotification(); + }, timeout); + } + + /** + * Shows the next notification. + */ + protected triggerNextNotification(): void { + const dateNow = Date.now(); + + const nextNotification = this.scheduledNotifications[0]; + if (!nextNotification) { + return; + } + + const notificationTime = nextNotification.trigger?.at?.getTime() || 0; + if (notificationTime === 0 || notificationTime <= dateNow) { + const body = Array.isArray(nextNotification.text) ? nextNotification.text.join() : nextNotification.text; + const notification = new Notification(nextNotification.title || '', { + body, + data: nextNotification.data, + icon: nextNotification.icon, + requireInteraction: true, + tag: nextNotification.data?.component, + }); + + this.triggeredNotifications.push(nextNotification); + + this.observables.trigger.next(nextNotification); + + notification.addEventListener('click', () => { + this.observables.click.next(nextNotification); + }); + + if (nextNotification.id) { + this.presentNotifications[nextNotification.id] = notification; + + notification.addEventListener('close', () => { + delete(this.presentNotifications[nextNotification.id ?? 0]); + }); + } + + this.scheduledNotifications.shift(); + this.triggerNextNotification(); + } else { + this.scheduleNotifications(); + } + } + + /** + * @inheritdoc + */ + update(options?: ILocalNotification): void { + if (!options?.id) { + return; + } + const index = this.scheduledNotifications.findIndex((notification) => notification.id === options.id); + if (index < 0) { + return; + } + + this.observables.update.next(options); + + this.scheduledNotifications[index] = options; + } + + /** + * @inheritdoc + */ + async clear(notificationId: number | Array): Promise { + if (!Array.isArray(notificationId)) { + notificationId = [notificationId]; + } + + notificationId.forEach((id) => { + if (!this.presentNotifications[id]) { + return; + } + + this.presentNotifications[id].close(); + + this.observables.clear.next(this.presentNotifications[id]); + delete this.presentNotifications[id]; + }); + } + + /** + * @inheritdoc + */ + async clearAll(): Promise { + for (const x in this.presentNotifications) { + this.presentNotifications[x].close(); + } + this.presentNotifications = {}; + + this.observables.clearall.next(); + + } + + /** + * @inheritdoc + */ + async cancel(notificationId: number | Array): Promise { + if (!Array.isArray(notificationId)) { + notificationId = [notificationId]; + } + + notificationId.forEach((id) => { + const index = this.scheduledNotifications.findIndex((notification) => notification.id === id); + this.observables.cancel.next(this.scheduledNotifications[index]); + + this.scheduledNotifications.splice(index, 1); + }); + + this.scheduleNotifications(); + } + + /** + * @inheritdoc + */ + async cancelAll(): Promise { + window.clearTimeout(this.nextTimeout); + this.scheduledNotifications = []; + + this.observables.cancelall.next(); + } + + /** + * @inheritdoc + */ + async isPresent(notificationId: number): Promise { + return !!this.presentNotifications[notificationId]; + } + + /** + * @inheritdoc + */ + async isScheduled(notificationId: number): Promise { + return this.scheduledNotifications.some((notification) => notification.id === notificationId); + } + + /** + * @inheritdoc + */ + async isTriggered(notificationId: number): Promise { + return this.triggeredNotifications.some((notification) => notification.id === notificationId); + + } + + /** + * @inheritdoc + */ + async getIds(): Promise> { + const ids = await this.getScheduledIds(); + const triggeredIds = await this.getTriggeredIds(); + + return Promise.resolve(ids.concat(triggeredIds)); + } + + /** + * @inheritdoc + */ + async getTriggeredIds(): Promise> { + const ids = this.triggeredNotifications + .map((notification) => notification.id || 0) + .filter((id) => id > 0); + + return ids; + } + + /** + * @inheritdoc + */ + async getScheduledIds(): Promise> { + const ids = this.scheduledNotifications + .map((notification) => notification.id || 0) + .filter((id) => id > 0); + + return ids; + } + + /** + * @inheritdoc + */ + async get(notificationId: number): Promise { + const notification = this.scheduledNotifications + .find((notification) => notification.id === notificationId); + + if (!notification) { + throw new Error('Invalid Notification Id.'); + } + + return notification; + } + + /** + * @inheritdoc + */ + async getAll(): Promise> { + return this.scheduledNotifications.concat(this.triggeredNotifications); + } + + /** + * @inheritdoc + */ + async getAllScheduled(): Promise> { + return this.scheduledNotifications; + } + + /** + * @inheritdoc + */ + async getAllTriggered(): Promise> { + return this.triggeredNotifications; + } + + /** + * @inheritdoc + */ + async registerPermission(): Promise { + // We need to ask the user for permission + const permission = await Notification.requestPermission(); + + this.hasGranted = permission === 'granted'; + + // If the user accepts, let's create a notification + return this.hasGranted; + } + + /** + * @inheritdoc + */ + async hasPermission(): Promise { + if (this.hasGranted !== undefined) { + return this.hasGranted; + } + + if (!('Notification' in window)) { + // Check if the browser supports notifications + throw new CoreError('This browser does not support desktop notification'); + } + + return this.registerPermission(); + } + + /** + * @inheritdoc + */ + async addActions(groupId: unknown, actions: Array): Promise> { + // Not implemented. + return actions; + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async removeActions(groupId: unknown): Promise { + // Not implemented. + return; + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async hasActions(groupId: unknown): Promise { + // Not implemented. + return false; + } + + /** + * @inheritdoc + */ + async getDefaults(): Promise { + // Not implemented. + return; + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async setDefaults(defaults: unknown): Promise { + // Not implemented. + return; + } + + /** + * @inheritdoc + */ + on(eventName: string): Observable { + if (!this.observables[eventName]) { + this.observables[eventName] = new Subject(); + } + + return this.observables[eventName]; + } + + /** + * @inheritdoc + */ + fireEvent(eventName: string, args: unknown): void { + if (!this.observables[eventName]) { + return; + } + + this.observables[eventName].next(args); + } + + /** + * @inheritdoc + */ + async fireQueuedEvents(): Promise { + return this.triggerNextNotification(); + } + +} diff --git a/src/core/features/pushnotifications/services/pushnotifications.ts b/src/core/features/pushnotifications/services/pushnotifications.ts index b7865abda..31407566f 100644 --- a/src/core/features/pushnotifications/services/pushnotifications.ts +++ b/src/core/features/pushnotifications/services/pushnotifications.ts @@ -473,11 +473,6 @@ export class CorePushNotificationsProvider { 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.isAvailable()) { - return this.notifyReceived(notification, data); - } - const localNotif: ILocalNotification = { id: Number(data.notId) || 1, data: data, diff --git a/src/core/features/reminders/services/reminders.ts b/src/core/features/reminders/services/reminders.ts index 655d9f42a..a6388eff7 100644 --- a/src/core/features/reminders/services/reminders.ts +++ b/src/core/features/reminders/services/reminders.ts @@ -67,7 +67,7 @@ export class CoreRemindersService { * @return Promise resolved when done. */ async initialize(): Promise { - if (!CoreLocalNotifications.isAvailable()) { + if (!this.isEnabled()) { return; } @@ -92,7 +92,7 @@ export class CoreRemindersService { * @return True if reminders are enabled and available, false otherwise. */ isEnabled(): boolean { - return CoreLocalNotifications.isAvailable(); + return true; } /** @@ -314,7 +314,8 @@ export class CoreRemindersService { async scheduleAllNotifications(): Promise { await CorePlatform.ready(); - if (!this.isEnabled()) { + if (CoreLocalNotifications.isPluginAvailable()) { + // Notifications are already scheduled. return; } diff --git a/src/core/features/settings/pages/deviceinfo/deviceinfo.ts b/src/core/features/settings/pages/deviceinfo/deviceinfo.ts index b00cc0dae..1ce2ff010 100644 --- a/src/core/features/settings/pages/deviceinfo/deviceinfo.ts +++ b/src/core/features/settings/pages/deviceinfo/deviceinfo.ts @@ -94,7 +94,7 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy { lastCommit: CoreConstants.BUILD.lastCommitHash || '', networkStatus: CoreNetwork.isOnline() ? 'online' : 'offline', wifiConnection: CoreNetwork.isWifi() ? 'yes' : 'no', - localNotifAvailable: CoreLocalNotifications.isAvailable() ? 'yes' : 'no', + localNotifAvailable: CoreLocalNotifications.isPluginAvailable() ? 'yes' : 'no', pushId: CorePushNotifications.getPushId(), deviceType: '', }; diff --git a/src/core/services/local-notifications.ts b/src/core/services/local-notifications.ts index bfc61eabb..b1e7d84e0 100644 --- a/src/core/services/local-notifications.ts +++ b/src/core/services/local-notifications.ts @@ -319,14 +319,26 @@ export class CoreLocalNotificationsProvider { } /** - * Returns whether local notifications plugin is installed. + * Returns whether local notifications are available. * - * @return Whether local notifications plugin is installed. + * @return Whether local notifications are available. + * @deprecated since 4.1. It will always return true. */ isAvailable(): boolean { + return true; + } + + /** + * Returns whether local notifications plugin is available. + * + * @return Whether local notifications plugin is available. + */ + isPluginAvailable(): boolean { const win = window; // eslint-disable-line @typescript-eslint/no-explicit-any - return !!win.cordova?.plugins?.notification?.local; + const enabled = !!win.cordova?.plugins?.notification?.local; + + return enabled && CorePlatform.is('cordova'); } /**