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');
}
/**