diff --git a/.changeset/strange-crabs-tell.md b/.changeset/strange-crabs-tell.md new file mode 100644 index 00000000000..d4b305b279c --- /dev/null +++ b/.changeset/strange-crabs-tell.md @@ -0,0 +1,10 @@ +--- +'firebase': minor +'@firebase/messaging': minor, +'@firebase/messaging-types': minor +--- + +Add `getToken(options:{serviceWorkerRegistration, vapidKey})`,`onBackgroundMessage`. +Deprecate `setBackgroundMessageHandler`, `onTokenRefresh`, `useVapidKey`, `useServiceWorker`, `getToken`. + +Add Typing `MessagePayload`, `NotificationPayload`, `FcmOptions`. diff --git a/integration/messaging/download-browsers.js b/integration/messaging/download-browsers.js index 9dd38250ca6..c8b5de3f7d7 100644 --- a/integration/messaging/download-browsers.js +++ b/integration/messaging/download-browsers.js @@ -18,8 +18,9 @@ const seleniumAssistant = require('selenium-assistant'); console.log('Starting browser download - this may take some time.'); -// TODO: enable firefox testing once figure out how to give notification permission with SE webdriver. -// TODO: Run the integration test against multiple major chrome versions to ensure backward compatibility +// TODO: enable firefox testing once figure out how to give notification permission with SE +// webdriver. TODO: Run the integration test against multiple major chrome versions to ensure +// backward compatibility Promise.all([seleniumAssistant.downloadLocalBrowser('chrome', 'stable', 80)]) .then(() => { console.log('Browser download complete.'); diff --git a/integration/messaging/manual-test-server.js b/integration/messaging/manual-test-server.js index 34a5ca05948..9532b74c584 100644 --- a/integration/messaging/manual-test-server.js +++ b/integration/messaging/manual-test-server.js @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/integration/messaging/test/static/helpers.js b/integration/messaging/test/static/helpers.js new file mode 100644 index 00000000000..7712a636bea --- /dev/null +++ b/integration/messaging/test/static/helpers.js @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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. + */ + +async function addPayloadToDb(payload) { + const dbOpenReq = indexedDB.open(TEST_DB); + + dbOpenReq.onupgradeneeded = () => { + const db = dbOpenReq.result; + + // store creation is a synchronized call + console.log('creating object store...'); + db.createObjectStore(BACKGROUND_MESSAGES_OBJECT_STORE, { + keyPath: BACKGROUND_MESSAGES_OBJECT_STORE_PRIMARY_KEY + }); + }; + + dbOpenReq.onsuccess = () => { + const db = dbOpenReq.result; + + addPayloadToDbInternal(db, { + ...payload, + // ndx is required as the primary key of the store. It doesn't have any other testing purpose + ndx: BACKGROUND_MESSAGES_OBJECT_STORE_DEFAULT_NDX + }); + }; +} + +async function addPayloadToDbInternal(db, payload) { + // onsuccess might race with onupgradeneeded. Consequently causing "object stores was not found" + // error. Therefore, wait briefly for db.createObjectStore to complete + const delay = ms => new Promise(res => setTimeout(res, ms)); + await delay(/* milliseconds= */ 30000); + + tx = db.transaction(BACKGROUND_MESSAGES_OBJECT_STORE, 'readwrite'); + + console.log('adding message payload to db: ' + JSON.stringify(payload)); + addReq = tx.objectStore(BACKGROUND_MESSAGES_OBJECT_STORE).add(payload); +} diff --git a/integration/messaging/test/static/sw-base.js b/integration/messaging/test/static/sw-base.js index 01d2baf137d..d7617bee105 100644 --- a/integration/messaging/test/static/sw-base.js +++ b/integration/messaging/test/static/sw-base.js @@ -16,6 +16,7 @@ */ importScripts('../constants.js'); +importScripts('../helpers.js'); // HEAD targets served through express importScripts('/firebase-app.js'); @@ -27,45 +28,10 @@ const messaging = firebase.messaging(); messaging.setBackgroundMessageHandler(payload => { console.log( TAG + - 'a background message is received: ' + + 'a background message is received in the background handler hook: ' + JSON.stringify(payload) + '. Storing it into idb for tests to read...' ); addPayloadToDb(payload); }); - -async function addPayloadToDb(payload) { - const dbOpenReq = indexedDB.open(TEST_DB); - - dbOpenReq.onupgradeneeded = () => { - const db = dbOpenReq.result; - - // store creation is a synchronized call - console.log('creating object store...'); - db.createObjectStore(BACKGROUND_MESSAGES_OBJECT_STORE, { - keyPath: BACKGROUND_MESSAGES_OBJECT_STORE_PRIMARY_KEY - }); - }; - - dbOpenReq.onsuccess = () => { - const db = dbOpenReq.result; - - addPayloadToDbInternal(db, { - ...payload, - // ndx is required as the primary key of the store. It doesn't have any other testing purpose - ndx: BACKGROUND_MESSAGES_OBJECT_STORE_DEFAULT_NDX - }); - }; -} - -async function addPayloadToDbInternal(db, payload) { - // onsuccess might race with onupgradeneeded. Consequently causing " object stores was not found" error. Therefore, wait briefly for db.createObjectStore to complete - const delay = ms => new Promise(res => setTimeout(res, ms)); - await delay(/* milliseconds= */ 30000); - - tx = db.transaction(BACKGROUND_MESSAGES_OBJECT_STORE, 'readwrite'); - - console.log('adding message payload to db: ' + JSON.stringify(payload)); - addReq = tx.objectStore(BACKGROUND_MESSAGES_OBJECT_STORE).add(payload); -} diff --git a/integration/messaging/test/static/valid-vapid-key-modern-sw/index.html b/integration/messaging/test/static/valid-vapid-key-modern-sw/index.html new file mode 100644 index 00000000000..269a41f0d91 --- /dev/null +++ b/integration/messaging/test/static/valid-vapid-key-modern-sw/index.html @@ -0,0 +1,28 @@ + + + FCM Demo + + + +

Valid WITH VAPID Key - Modern SW

+ + + + + + + + + diff --git a/packages/messaging/src/interfaces/internal-message.ts b/integration/messaging/test/static/valid-vapid-key-modern-sw/sw.js similarity index 53% rename from packages/messaging/src/interfaces/internal-message.ts rename to integration/messaging/test/static/valid-vapid-key-modern-sw/sw.js index 41f0299664a..459e83e948a 100644 --- a/packages/messaging/src/interfaces/internal-message.ts +++ b/integration/messaging/test/static/valid-vapid-key-modern-sw/sw.js @@ -15,16 +15,23 @@ * limitations under the License. */ -import { MessagePayload } from './message-payload'; +importScripts('../constants.js'); +importScripts('../helpers.js'); -export enum MessageType { - PUSH_RECEIVED = 'push-received', - NOTIFICATION_CLICKED = 'notification-clicked' -} +// HEAD targets served through express +importScripts('/firebase-app.js'); +importScripts('/firebase-messaging.js'); -export interface InternalMessage { - firebaseMessaging: { - type: MessageType; - payload: MessagePayload; - }; -} +firebase.initializeApp(FIREBASE_CONFIG); +const messaging = firebase.messaging(); + +messaging.onBackgroundMessage(payload => { + console.log( + TAG + + 'a background message is received in the onBackgroundMessage hook: ' + + JSON.stringify(payload) + + '. Storing it into idb for tests to read...' + ); + + addPayloadToDb(payload); +}); diff --git a/integration/messaging/test/test-deleteToken.js b/integration/messaging/test/test-deleteToken.js index 65dcab7e251..cb6aa341088 100644 --- a/integration/messaging/test/test-deleteToken.js +++ b/integration/messaging/test/test-deleteToken.js @@ -41,7 +41,7 @@ describe('Firebase Messaging Integration Tests > get and delete token', function const availableBrowsers = seleniumAssistant.getLocalBrowsers(); availableBrowsers.forEach(assistantBrowser => { - //TODO: enable testing for firefox + // TODO: enable testing for firefox if (assistantBrowser.getId() !== 'chrome') { return; } diff --git a/integration/messaging/test/test-send.js b/integration/messaging/test/test-send.js index 9c196e0825e..2f15519b807 100644 --- a/integration/messaging/test/test-send.js +++ b/integration/messaging/test/test-send.js @@ -25,20 +25,25 @@ const getReceivedForegroundMessages = require('./utils/getReceivedForegroundMess const openNewTab = require('./utils/openNewTab'); const createPermittedWebDriver = require('./utils/createPermittedWebDriver'); -const TEST_DOMAIN = 'valid-vapid-key'; +const TEST_DOMAINS = ['valid-vapid-key', 'valid-vapid-key-modern-sw']; const TEST_PROJECT_SENDER_ID = '750970317741'; const DEFAULT_COLLAPSE_KEY_VALUE = 'do_not_collapse'; const FIELD_FROM = 'from'; -const FIELD_COLLAPSE_KEY = 'collapse_key'; +const FIELD_COLLAPSE_KEY_LEGACY = 'collapse_key'; +const FIELD_COLLAPSE_KEY = 'collapseKey'; + const FIELD_DATA = 'data'; const FIELD_NOTIFICATION = 'notification'; -// 4 minutes. The fact that the flow includes making a request to the Send Service, storing/retrieving form indexedDb asynchronously makes these test units to have a execution time variance. Therefore, allowing these units to have a longer time to work is crucial. +// 4 minutes. The fact that the flow includes making a request to the Send Service, +// storing/retrieving form indexedDb asynchronously makes these test units to have a execution time +// variance. Therefore, allowing these units to have a longer time to work is crucial. const TIMEOUT_BACKGROUND_MESSAGE_TEST_UNIT_MILLISECONDS = 240000; const TIMEOUT_FOREGROUND_MESSAGE_TEST_UNIT_MILLISECONDS = 120000; -// 1 minute. Wait for object store to be created and received message to be stored in idb. This waiting time MUST be longer than the wait time for adding to db in the sw. +// 1 minute. Wait for object store to be created and received message to be stored in idb. This +// waiting time MUST be longer than the wait time for adding to db in the sw. const WAIT_TIME_BEFORE_RETRIEVING_BACKGROUND_MESSAGES_MILLISECONDS = 60000; const wait = ms => new Promise(res => setTimeout(res, ms)); @@ -55,75 +60,82 @@ describe('Starting Integration Test > Sending and Receiving ', function () { await testServer.stop(); }); - //TODO: enable testing for firefox + // TODO: enable testing for firefox seleniumAssistant.getLocalBrowsers().forEach(assistantBrowser => { if (assistantBrowser.getId() !== 'chrome') { return; } - describe(`Testing browser: ${assistantBrowser.getPrettyName()} : ${TEST_DOMAIN}`, function () { - before(async function () { - globalWebDriver = createPermittedWebDriver( - /* browser= */ assistantBrowser.getId() - ); - }); - - it('Background app can receive a {} empty message from sw', async function () { - this.timeout(TIMEOUT_BACKGROUND_MESSAGE_TEST_UNIT_MILLISECONDS); - - // Clearing the cache and db data by killing the previously instantiated driver. Note that ideally this call is placed inside the after/before hooks. However, Mocha forbids operations longer than 2s in hooks. Hence, this clearing call needs to be inside the test unit. - await seleniumAssistant.killWebDriver(globalWebDriver); - - globalWebDriver = createPermittedWebDriver( - /* browser= */ assistantBrowser.getId() - ); - - prepareBackgroundApp(globalWebDriver); + TEST_DOMAINS.forEach(domain => { + describe(`Testing browser: ${assistantBrowser.getPrettyName()} : ${domain}`, function() { + before(async function() { + globalWebDriver = createPermittedWebDriver( + /* browser= */ assistantBrowser.getId() + ); + }); + + it('Background app can receive a {} empty message from sw', async function() { + this.timeout(TIMEOUT_BACKGROUND_MESSAGE_TEST_UNIT_MILLISECONDS); + + // Clearing the cache and db data by killing the previously instantiated driver. Note that + // ideally this call is placed inside the after/before hooks. However, Mocha forbids + // operations longer than 2s in hooks. Hence, this clearing call needs to be inside the + // test unit. + await seleniumAssistant.killWebDriver(globalWebDriver); + + globalWebDriver = createPermittedWebDriver( + /* browser= */ assistantBrowser.getId() + ); + + prepareBackgroundApp(globalWebDriver, domain); + + checkSendResponse( + await sendMessage({ + to: await retrieveToken(globalWebDriver) + }) + ); + + await wait( + WAIT_TIME_BEFORE_RETRIEVING_BACKGROUND_MESSAGES_MILLISECONDS + ); + + checkMessageReceived( + await getReceivedBackgroundMessages(globalWebDriver), + /* expectedNotificationPayload= */ null, + /* expectedDataPayload= */ null, + /* isLegacyPayload= */ false + ); + }); + + it('Background app can receive a {"data"} message frow sw', async function() { + this.timeout(TIMEOUT_BACKGROUND_MESSAGE_TEST_UNIT_MILLISECONDS); + + await seleniumAssistant.killWebDriver(globalWebDriver); + + globalWebDriver = createPermittedWebDriver( + /* browser= */ assistantBrowser.getId() + ); + + prepareBackgroundApp(globalWebDriver, domain); + + checkSendResponse( + await sendMessage({ + to: await retrieveToken(globalWebDriver), + data: getTestDataPayload() + }) + ); + + await wait( + WAIT_TIME_BEFORE_RETRIEVING_BACKGROUND_MESSAGES_MILLISECONDS + ); + + checkMessageReceived( + await getReceivedBackgroundMessages(globalWebDriver), + /* expectedNotificationPayload= */ null, + /* expectedDataPayload= */ getTestDataPayload() + ); + }); - checkSendResponse( - await sendMessage({ - to: await retrieveToken(globalWebDriver) - }) - ); - - await wait( - WAIT_TIME_BEFORE_RETRIEVING_BACKGROUND_MESSAGES_MILLISECONDS - ); - - checkMessageReceived( - await getReceivedBackgroundMessages(globalWebDriver), - /* expectedNotificationPayload= */ null, - /* expectedDataPayload= */ null - ); - }); - - it('Background app can receive a {"data"} message frow sw', async function () { - this.timeout(TIMEOUT_BACKGROUND_MESSAGE_TEST_UNIT_MILLISECONDS); - - await seleniumAssistant.killWebDriver(globalWebDriver); - - globalWebDriver = createPermittedWebDriver( - /* browser= */ assistantBrowser.getId() - ); - - prepareBackgroundApp(globalWebDriver); - - checkSendResponse( - await sendMessage({ - to: await retrieveToken(globalWebDriver), - data: getTestDataPayload() - }) - ); - - await wait( - WAIT_TIME_BEFORE_RETRIEVING_BACKGROUND_MESSAGES_MILLISECONDS - ); - - checkMessageReceived( - await getReceivedBackgroundMessages(globalWebDriver), - /* expectedNotificationPayload= */ null, - /* expectedDataPayload= */ getTestDataPayload() - ); }); it('Foreground app can receive a {} empty message in onMessage', async function () { @@ -135,9 +147,7 @@ describe('Starting Integration Test > Sending and Receiving ', function () { /* browser= */ assistantBrowser.getId() ); - await globalWebDriver.get( - `${testServer.serverAddress}/${TEST_DOMAIN}/` - ); + await globalWebDriver.get(`${testServer.serverAddress}/${domain}/`); let token = await retrieveToken(globalWebDriver); checkSendResponse( @@ -162,9 +172,7 @@ describe('Starting Integration Test > Sending and Receiving ', function () { /* browser= */ assistantBrowser.getId() ); - await globalWebDriver.get( - `${testServer.serverAddress}/${TEST_DOMAIN}/` - ); + await globalWebDriver.get(`${testServer.serverAddress}/${domain}/`); checkSendResponse( await sendMessage({ @@ -189,9 +197,7 @@ describe('Starting Integration Test > Sending and Receiving ', function () { /* browser= */ assistantBrowser.getId() ); - await globalWebDriver.get( - `${testServer.serverAddress}/${TEST_DOMAIN}/` - ); + await globalWebDriver.get(`${testServer.serverAddress}/${domain}/`); checkSendResponse( await sendMessage({ @@ -216,9 +222,7 @@ describe('Starting Integration Test > Sending and Receiving ', function () { /* browser= */ assistantBrowser.getId() ); - await globalWebDriver.get( - `${testServer.serverAddress}/${TEST_DOMAIN}/` - ); + await globalWebDriver.get(`${testServer.serverAddress}/${domain}/`); checkSendResponse( await sendMessage({ @@ -248,7 +252,10 @@ function checkMessageReceived( const message = receivedMessages[0]; expect(message[FIELD_FROM]).to.equal(TEST_PROJECT_SENDER_ID); - expect(message[FIELD_COLLAPSE_KEY]).to.equal(DEFAULT_COLLAPSE_KEY_VALUE); + const collapseKey = !!message[FIELD_COLLAPSE_KEY_LEGACY] + ? message[FIELD_COLLAPSE_KEY_LEGACY] + : message[FIELD_COLLAPSE_KEY]; + expect(collapseKey).to.equal(DEFAULT_COLLAPSE_KEY_VALUE); if (expectedNotificationPayload) { expect(message[FIELD_NOTIFICATION]).to.deep.equal( @@ -280,16 +287,17 @@ function getTestDataPayload() { return { hello: 'world' }; } -async function prepareBackgroundApp(globalWebDriver) { - await globalWebDriver.get(`${testServer.serverAddress}/${TEST_DOMAIN}/`); +async function prepareBackgroundApp(globalWebDriver, domain) { + await globalWebDriver.get(`${testServer.serverAddress}/${domain}/`); - // TODO: remove the try/catch block once the underlying bug has been resolved. - // Shift window focus away from app window so that background messages can be received/processed + // TODO: remove the try/catch block once the underlying bug has been resolved. Shift window focus + // away from app window so that background messages can be received/processed try { await openNewTab(globalWebDriver); } catch (err) { - // ChromeDriver seems to have an open bug which throws "JavascriptError: javascript error: circular reference". - // Nevertheless, a new tab can still be opened. Hence, just catch and continue here. + // ChromeDriver seems to have an open bug which throws "JavascriptError: javascript error: + // circular reference". Nevertheless, a new tab can still be opened. Hence, just catch and + // continue here. console.log('FCM (ignored on purpose): ' + err); } } diff --git a/integration/messaging/test/test-updateToken.js b/integration/messaging/test/test-updateToken.js index a18311f8d28..2de606be2a9 100644 --- a/integration/messaging/test/test-updateToken.js +++ b/integration/messaging/test/test-updateToken.js @@ -43,7 +43,7 @@ describe('Firebase Messaging Integration Tests > update a token', function () { }); const availableBrowsers = seleniumAssistant.getLocalBrowsers(); - //TODO: enable testing for edge and firefox if applicable + // TODO: enable testing for edge and firefox if applicable availableBrowsers.forEach(assistantBrowser => { if (assistantBrowser.getId() !== 'chrome') { return; diff --git a/integration/messaging/test/utils/getReceivedBackgroundMessages.js b/integration/messaging/test/utils/getReceivedBackgroundMessages.js index 34dcbb26a26..3f20e4cb946 100644 --- a/integration/messaging/test/utils/getReceivedBackgroundMessages.js +++ b/integration/messaging/test/utils/getReceivedBackgroundMessages.js @@ -18,7 +18,10 @@ const TEST_DB = 'FCM_INTEGRATION_TEST_DB'; const BACKGROUND_MESSAGES_OBJECT_STORE = 'background_messages'; -/** Getting received background messages are trickier than getting foreground messages from app. It requires idb object store creation with the service worker. Idb operations are fired as async events. This method needs to be called after the idb operations inside sw is done. In tests, consider adding a brief timeout before calling the method to give sw some time to work. +/** Getting received background messages are trickier than getting foreground messages from app. It + * requires idb object store creation with the service worker. Idb operations are fired as async + * events. This method needs to be called after the idb operations inside sw is done. In tests, + * consider adding a brief timeout before calling the method to give sw some time to work. */ module.exports = async webdriver => { console.log('Getting received background messages from idb: '); diff --git a/integration/messaging/test/utils/sendMessage.js b/integration/messaging/test/utils/sendMessage.js index 7565cc5d44c..9d3b93986f1 100644 --- a/integration/messaging/test/utils/sendMessage.js +++ b/integration/messaging/test/utils/sendMessage.js @@ -17,7 +17,9 @@ const fetch = require('node-fetch'); const FCM_SEND_ENDPOINT = 'https://fcm.googleapis.com/fcm/send'; -// Rotatable fcm server key. It's generally a bad idea to expose server keys. The reason is to simplify testing process (no need to implement server side decryption of git secret). The justification is that a) this is a disposable test project b) the key itself is rotatable. +// Rotatable fcm server key. It's generally a bad idea to expose server keys. The reason is to +// simplify testing process (no need to implement server side decryption of git secret). The +// justification is that a) this is a disposable test project b) the key itself is rotatable. const FCM_KEY = 'AAAArtlRq60:APA91bHFulW1dBpIPbArYXPbFtO9M_a9ZNXhnj9hGArfGK55g8fv5s5Qset6984xRIrqhZ_3IlKcG9LgSk3DiTdHMDIOkxboNJquNK1SChC7J0ULTvHPg7t0V6AjR1UEA21DXI22BM5N'; diff --git a/integration/messaging/test/utils/test-server.js b/integration/messaging/test/utils/test-server.js index 9268e7e8508..c178f0c72d4 100644 --- a/integration/messaging/test/utils/test-server.js +++ b/integration/messaging/test/utils/test-server.js @@ -61,8 +61,8 @@ class MessagingTestServer { }); } - // Sometimes the server doesn't trigger the callback due to - // currently open sockets. So call close this._server + // Sometimes the server doesn't trigger the callback due to currently open sockets. So call close + // this._server async stop() { if (this._server) { this._server.close(); diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 83488fa28b3..8d5d4db66fa 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -6941,106 +6941,236 @@ declare namespace firebase.messaging { * Do not call this constructor directly. Instead, use * {@link firebase.messaging `firebase.messaging()`}. * - * See - * {@link - * https://firebase.google.com/docs/cloud-messaging/js/client - * Set Up a JavaScript Firebase Cloud Messaging Client App} - * for a full guide on how to use the Firebase Messaging service. + * See {@link https://firebase.google.com/docs/cloud-messaging/js/client Set Up a JavaScript + * Firebase Cloud Messaging Client App} for a full guide on how to use the Firebase Messaging + * service. * */ interface Messaging { /** - * To forcibly stop a registration token from being used, delete it - * by calling this method. + * Deletes the registration token associated with this messaging instance and unsubscribes the + * messaging instance from the push subscription. + * + * @return The promise resolves when the token has been successfully deleted. + */ + deleteToken(): Promise; + + /** + * To forcibly stop a registration token from being used, delete it by calling this method. * * @param token The token to delete. - * @return The promise resolves when the token has been - * successfully deleted. + * @return The promise resolves when the token has been successfully deleted. + * + * @deprecated Use deleteToken() instead. */ deleteToken(token: string): Promise; + /** - * Subscribes the user to push notifications and returns an FCM registration - * token that can be used to send push messages to the user. + * Subscribes the messaging instance to push notifications. Returns an FCM registration token + * that can be used to send push messages to that messaging instance. * - * If notification permission isn't already granted, this method asks the - * user for permission. The returned promise rejects if the user does not - * allow the app to show notifications. + * If a notification permission isn't already granted, this method asks the user for permission. + * The returned promise rejects if the user does not allow the app to show notifications. + * + * @param options.vapidKey The public server key provided to push services. It is used to + * authenticate the push subscribers to receive push messages only from sending servers that + * hold the corresponding private key. If it is not provided, a default VAPID key is used. Note + * that some push services (Chrome Push Service) require a non-default VAPID key. Therefore, it + * is recommended to generate and import a VAPID key for your project with + * {@link https://firebase.google.com/docs/cloud-messaging/js/client#configure_web_credentials_with_fcm Configure Web Credentials with FCM}. + * See + * {@link https://developers.google.com/web/fundamentals/push-notifications/web-push-protocol The Web Push Protocol} + * for details on web push services.} + * + * @param options.serviceWorkerRegistration The service worker registration for receiving push + * messaging. If the registration is not provided explicitly, you need to have a + * `firebase-messaging-sw.js` at your root location. See + * {@link https://firebase.google.com/docs/cloud-messaging/js/client#retrieve-the-current-registration-token Retrieve the current registration token} + * for more details. + * + * @return The promise resolves with an FCM registration token. * - * @return The promise resolves with the FCM token string. */ - getToken(): Promise; + getToken(options?: { + vapidKey?: string; + serviceWorkerRegistration?: ServiceWorkerRegistration; + }): Promise; + /** - * When a push message is received and the user is currently on a page - * for your origin, the message is passed to the page and an `onMessage()` - * event is dispatched with the payload of the push message. - * - * NOTE: These events are dispatched when you have called - * `setBackgroundMessageHandler()` in your service worker. + * When a push message is received and the user is currently on a page for your origin, the + * message is passed to the page and an `onMessage()` event is dispatched with the payload of + * the push message. * * @param * nextOrObserver This function, or observer object with `next` defined, * is called when a message is received and the user is currently viewing your page. - * @return To stop listening for messages - * execute this returned function. + * @return To stop listening for messages execute this returned function. */ onMessage( nextOrObserver: firebase.NextFn | firebase.Observer, error?: firebase.ErrorFn, completed?: firebase.CompleteFn ): firebase.Unsubscribe; + + /** + * Called when a message is received while the app is in the background. An app is considered to + * be in the background if no active window is displayed. + * + * @param + * nextOrObserver This function, or observer object with `next` defined, + * is called when a message is received and the app is currently in the background. + * + * @return To stop listening for messages execute this returned function + */ + onBackgroundMessage( + nextOrObserver: + | firebase.NextFn + | firebase.Observer, + error?: firebase.ErrorFn, + completed?: firebase.CompleteFn + ): firebase.Unsubscribe; + /** - * You should listen for token refreshes so your web app knows when FCM - * has invalidated your existing token and you need to call `getToken()` - * to get a new token. + * You should listen for token refreshes so your web app knows when FCM has invalidated your + * existing token and you need to call `getToken()` to get a new token. * * @param * nextOrObserver This function, or observer object with `next` defined, * is called when a token refresh has occurred. - * @return To stop listening for token - * refresh events execute this returned function. + * @return To stop listening for token refresh events execute this returned function. + * + * @deprecated There is no need to handle token rotation. */ onTokenRefresh( nextOrObserver: firebase.NextFn | firebase.Observer, error?: firebase.ErrorFn, completed?: firebase.CompleteFn ): firebase.Unsubscribe; + /** - * Notification permissions are required to send a user push messages. - * Calling this method displays the permission dialog to the user and - * resolves if the permission is granted. It is not necessary to call this - * method, as `getToken()` will do this automatically if required. + * Notification permissions are required to send a user push messages. Calling this method + * displays the permission dialog to the user and resolves if the permission is granted. It is + * not necessary to call this method, as `getToken()` will do this automatically if required. * - * @return The promise resolves if permission is - * granted. Otherwise, the promise is rejected with an error. + * @return The promise resolves if permission is granted. Otherwise, the promise is rejected + * with an error. * - * @deprecated Use Notification.requestPermission() instead. - * https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission + * @deprecated Use + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission Notification.requestPermission()} + * instead. */ requestPermission(): Promise; + /** - * FCM directs push messages to your web page's `onMessage()` callback - * if the user currently has it open. Otherwise, it calls - * your callback passed into `setBackgroundMessageHandler()`. + * FCM directs push messages to your web page's `onMessage()` callback if the user currently has + * it open. Otherwise, it calls your callback passed into `setBackgroundMessageHandler()`. * - * Your callback should return a promise that, once resolved, has - * shown a notification. + * Your callback should return a promise that, once resolved, has shown a notification. * * @param callback The function to handle the push message. + * + * @deprecated onBackgroundMessage(nextOrObserver: firebase.NextFn| + * firebase.Observer, error?: firebase.ErrorFn,completed?: firebase.CompleteFn): + * firebase.Unsubscribe. */ setBackgroundMessageHandler( callback: (payload: any) => Promise | void ): void; + /** - * To use your own service worker for receiving push messages, you - * can pass in your service worker registration in this method. + * To use your own service worker for receiving push messages, you can pass in your service + * worker registration in this method. * - * @param registration The service worker - * registration you wish to use for push messaging. + * @param registration The service worker registration you wish to use for push messaging. + * + * @deprecated Use getToken(options?: {vapidKey?: string; serviceWorkerRegistration?: + * ServiceWorkerRegistration;}: Promise;. */ + useServiceWorker(registration: ServiceWorkerRegistration): void; + + /** + * @deprecated Use getToken(options?: {vapidKey?: string; serviceWorkerRegistration?: + * ServiceWorkerRegistration;}): Promise;. + */ usePublicVapidKey(b64PublicKey: string): void; } + /** + * Message payload that contains the notification payload that is represented with + * {@link firebase.Messaging.NotificationPayload} and the data payload that contains an arbitrary + * number of key-value pairs sent by developers through the + * {@link https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#notification Send API} + */ + export interface MessagePayload { + /** + * See {@link firebase.Messaging.NotificationPayload}. + */ + notification?: NotificationPayload; + + /** + * Arbitrary key/value pairs. + */ + data?: { [key: string]: string }; + + /** + * See {@link firebase.Messaging.FcmOptions}. + */ + fcmOptions?: FcmOptions; + + /** + * The sender of this message. + */ + from: string; + + /** + * The collapse key of this message. See more + * {@link https://firebase.google.com/docs/cloud-messaging/concept-options#collapsible_and_non-collapsible_messages}. + */ + collapseKey: string; + } + + /** + * Options for features provided by the FCM SDK for Web. See more + * {@link https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushfcmoptions}. + */ + export interface FcmOptions { + /** + * The link to open when the user clicks on the notification. For all URL values, HTTPS is + * required. For example, by setting this value to your app's URL, a notification click event + * will put your app in focus for the user. + */ + link?: string; + + /** + * Label associated with the message's analytics data. See more + * {@link https://firebase.google.com/docs/cloud-messaging/understand-delivery#adding-analytics-labels-to-messages}. + */ + analyticsLabel?: string; + } + + /** + * Parameters that define how a push notification is displayed to users. + */ + export interface NotificationPayload { + /** + * The title of a notification. + */ + title?: string; + + /** + * The body of a notification. + */ + body?: string; + + /** + * The URL of the image that is shown with the notification. See + * {@link https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#notification} + * for supported image format. + */ + image?: string; + } + function isSupported(): boolean; } diff --git a/packages/messaging-types/index.d.ts b/packages/messaging-types/index.d.ts index 4c056594553..0d69cd19bc7 100644 --- a/packages/messaging-types/index.d.ts +++ b/packages/messaging-types/index.d.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { FirebaseApp, FirebaseNamespace } from '@firebase/app-types'; import { Observer, Unsubscribe, @@ -24,16 +23,51 @@ import { CompleteFn } from '@firebase/util'; +// Currently supported fcm notification display parameters. Note that +// {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/notifications/NotificationOptions} +// defines a full list of display notification parameters. This interface we only include what the +// SEND API support for clarity. +export interface NotificationPayload { + title?: string; + body?: string; + image?: string; +} + +export interface FcmOptions { + link?: string; + analyticsLabel?: string; +} + +export interface MessagePayload { + notification?: NotificationPayload; + data?: { [key: string]: string }; + fcmOptions?: FcmOptions; + from: string; + collapseKey: string; +} + export interface FirebaseMessaging { - // TODO: remove the token parameter and just delete the token that matches - // this app if it exists. - deleteToken(token: string): Promise; - getToken(): Promise; + /** window controller */ + deleteToken(): Promise; + getToken(options?: { + vapidKey?: string; + serviceWorkerRegistration?: ServiceWorkerRegistration; + }): Promise; onMessage( nextOrObserver: NextFn | Observer, error?: ErrorFn, completed?: CompleteFn ): Unsubscribe; + + /** service worker controller */ + onBackgroundMessage( + nextOrObserver: NextFn | Observer, + error?: ErrorFn, + completed?: CompleteFn + ): Unsubscribe; + + /** @deprecated */ + deleteToken(token: string): Promise; onTokenRefresh( nextOrObserver: NextFn | Observer, error?: ErrorFn, diff --git a/packages/messaging/src/controllers/sw-controller.test.ts b/packages/messaging/src/controllers/sw-controller.test.ts index 45a90c22aab..cfc796e074a 100644 --- a/packages/messaging/src/controllers/sw-controller.test.ts +++ b/packages/messaging/src/controllers/sw-controller.test.ts @@ -14,55 +14,67 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { expect } from 'chai'; -import { stub, spy } from 'sinon'; -import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; import '../testing/setup'; -import { SwController, BgMessageHandler } from './sw-controller'; + import * as tokenManagementModule from '../core/token-management'; -import { Stub } from '../testing/sinon-types'; -import { Writable, ValueOf, DeepPartial } from 'ts-essentials'; -import { MessagePayload } from '../interfaces/message-payload'; -import { MessageType, InternalMessage } from '../interfaces/internal-message'; -import { - mockServiceWorker, - restoreServiceWorker, - FakeEvent, - FakePushSubscription -} from '../testing/fakes/service-worker'; + +import { BgMessageHandler, SwController } from './sw-controller'; import { - FCM_MSG, - DEFAULT_VAPID_KEY, + CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, CONSOLE_CAMPAIGN_ID, CONSOLE_CAMPAIGN_NAME, CONSOLE_CAMPAIGN_TIME, - CONSOLE_CAMPAIGN_ANALYTICS_ENABLED + DEFAULT_VAPID_KEY, + FCM_MSG } from '../util/constants'; +import { DeepPartial, ValueOf, Writable } from 'ts-essentials'; +import { + FakeEvent, + FakePushSubscription, + mockServiceWorker, + restoreServiceWorker +} from '../testing/fakes/service-worker'; +import { + MessagePayloadInternal, + MessageType +} from '../interfaces/internal-message-payload'; +import { spy, stub } from 'sinon'; + +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { Stub } from '../testing/sinon-types'; import { dbSet } from '../helpers/idb-manager'; +import { expect } from 'chai'; +import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; import { getFakeTokenDetails } from '../testing/fakes/token-details'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; // Add fake SW types. declare const self: Window & Writable; -const NOTIFICATION_MESSAGE_PAYLOAD: MessagePayload = { +// internal message payload (parsed directly from the push event) that contains and only contains +// notification payload. +const DISPLAY_MESSAGE: MessagePayloadInternal = { notification: { - title: 'message title', - body: 'message body', - data: { - key: 'value' - } + title: 'title', + body: 'body' }, fcmOptions: { link: 'https://example.org' - } + }, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; -const DATA_MESSAGE_PAYLOAD: MessagePayload = { +// internal message payload (parsed directly from the push event) that contains and only contains +// data payload. +const DATA_MESSAGE: MessagePayloadInternal = { data: { key: 'value' - } + }, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; describe('SwController', () => { @@ -78,10 +90,9 @@ describe('SwController', () => { stub(Notification, 'permission').value('granted'); - // Instead of calling actual addEventListener, add the event to the - // eventListeners list. - // Actual event listeners can't be used as the tests are not running in a - // Service Worker, which means Push events do not exist. + // Instead of calling actual addEventListener, add the event to the eventListeners list. Actual + // event listeners can't be used as the tests are not running in a Service Worker, which means + // Push events do not exist. addEventListenerStub = stub(self, 'addEventListener').callsFake( (type, listener) => { eventListenerMap.set(type, listener); @@ -204,16 +215,14 @@ describe('SwController', () => { await callEventListener( makeEvent('push', { data: { - json: () => NOTIFICATION_MESSAGE_PAYLOAD + json: () => DISPLAY_MESSAGE } }) ); - const expectedMessage: InternalMessage = { - firebaseMessaging: { - type: MessageType.PUSH_RECEIVED, - payload: NOTIFICATION_MESSAGE_PAYLOAD - } + const expectedMessage: MessagePayloadInternal = { + ...DISPLAY_MESSAGE, + messageType: MessageType.PUSH_RECEIVED }; expect(postMessageSpy).to.have.been.calledOnceWith(expectedMessage); }); @@ -226,17 +235,16 @@ describe('SwController', () => { await callEventListener( makeEvent('push', { data: { - json: () => NOTIFICATION_MESSAGE_PAYLOAD + json: () => DISPLAY_MESSAGE } }) ); expect(postMessageSpy).not.to.have.been.called; - expect(showNotificationSpy).to.have.been.calledWith('message title', { - ...NOTIFICATION_MESSAGE_PAYLOAD.notification, + expect(showNotificationSpy).to.have.been.calledWith('title', { + ...DISPLAY_MESSAGE.notification, data: { - ...NOTIFICATION_MESSAGE_PAYLOAD.notification!.data, - [FCM_MSG]: NOTIFICATION_MESSAGE_PAYLOAD + [FCM_MSG]: DISPLAY_MESSAGE } }); }); @@ -247,16 +255,16 @@ describe('SwController', () => { await callEventListener( makeEvent('push', { data: { - json: () => NOTIFICATION_MESSAGE_PAYLOAD + json: () => DISPLAY_MESSAGE } }) ); - expect(showNotificationSpy).to.have.been.calledWith('message title', { - ...NOTIFICATION_MESSAGE_PAYLOAD.notification, + expect(showNotificationSpy).to.have.been.calledWith('title', { + ...DISPLAY_MESSAGE.notification, data: { - ...NOTIFICATION_MESSAGE_PAYLOAD.notification!.data, - [FCM_MSG]: NOTIFICATION_MESSAGE_PAYLOAD + ...DISPLAY_MESSAGE.notification!.data, + [FCM_MSG]: DISPLAY_MESSAGE } }); }); @@ -268,7 +276,7 @@ describe('SwController', () => { await callEventListener( makeEvent('push', { data: { - json: () => DATA_MESSAGE_PAYLOAD + json: () => DATA_MESSAGE } }) ); @@ -276,6 +284,31 @@ describe('SwController', () => { expect(bgMessageHandlerSpy).to.have.been.calledWith(); }); + it('forwards MessagePayload with a notification payload to onBackgroundMessage', async () => { + const bgMessageHandlerSpy = spy(); + const showNotificationSpy = spy(self.registration, 'showNotification'); + + swController.onBackgroundMessage(bgMessageHandlerSpy); + + await callEventListener( + makeEvent('push', { + data: { + json: () => ({ + notification: { + ...DISPLAY_MESSAGE + }, + data: { + ...DATA_MESSAGE + } + }) + } + }) + ); + + expect(bgMessageHandlerSpy).to.have.been.called; + expect(showNotificationSpy).to.have.been.called; + }); + it('warns if there are more action buttons than the browser limit', async () => { // This doesn't exist on Firefox: // https://developer.mozilla.org/en-US/docs/Web/API/notification/maxActions @@ -291,7 +324,7 @@ describe('SwController', () => { data: { json: () => ({ notification: { - ...NOTIFICATION_MESSAGE_PAYLOAD, + ...DISPLAY_MESSAGE, actions: [ { action: 'like', title: 'Like' }, { action: 'favorite', title: 'Favorite' } @@ -308,7 +341,7 @@ describe('SwController', () => { }); }); - describe('setBackgrounMessageHandler', () => { + describe('setBackgroundMessageHandler', () => { it('throws on invalid input', () => { expect(() => swController.setBackgroundMessageHandler( @@ -350,11 +383,11 @@ describe('SwController', () => { beforeEach(() => { NOTIFICATION_CLICK_PAYLOAD = { - notification: new Notification('message title', { - ...NOTIFICATION_MESSAGE_PAYLOAD.notification, + notification: new Notification('title', { + ...DISPLAY_MESSAGE.notification, data: { - ...NOTIFICATION_MESSAGE_PAYLOAD.notification!.data, - [FCM_MSG]: NOTIFICATION_MESSAGE_PAYLOAD + ...DISPLAY_MESSAGE.notification!.data, + [FCM_MSG]: DISPLAY_MESSAGE } }) }; @@ -437,10 +470,8 @@ describe('SwController', () => { expect(openWindowSpy).not.to.have.been.called; expect(focusSpy).to.have.been.called; expect(postMessageSpy).to.have.been.calledWith({ - firebaseMessaging: { - type: MessageType.NOTIFICATION_CLICKED, - payload: NOTIFICATION_MESSAGE_PAYLOAD - } + ...DISPLAY_MESSAGE, + messageType: MessageType.NOTIFICATION_CLICKED }); }); diff --git a/packages/messaging/src/controllers/sw-controller.ts b/packages/messaging/src/controllers/sw-controller.ts index ff719e199c4..164ec9d80e7 100644 --- a/packages/messaging/src/controllers/sw-controller.ts +++ b/packages/messaging/src/controllers/sw-controller.ts @@ -15,22 +15,24 @@ * limitations under the License. */ -import { deleteToken, getToken } from '../core/token-management'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; -import { FirebaseMessaging } from '@firebase/messaging-types'; +import { DEFAULT_VAPID_KEY, FCM_MSG, TAG } from '../util/constants'; import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { FirebaseMessaging, MessagePayload } from '@firebase/messaging-types'; import { - MessagePayload, - NotificationDetails -} from '../interfaces/message-payload'; -import { FCM_MSG, DEFAULT_VAPID_KEY } from '../util/constants'; -import { MessageType, InternalMessage } from '../interfaces/internal-message'; -import { dbGet } from '../helpers/idb-manager'; -import { Unsubscribe } from '@firebase/util'; -import { sleep } from '../helpers/sleep'; + MessagePayloadInternal, + MessageType, + NotificationPayloadInternal +} from '../interfaces/internal-message-payload'; +import { NextFn, Observer, Unsubscribe } from '@firebase/util'; +import { deleteToken, getToken } from '../core/token-management'; + import { FirebaseApp } from '@firebase/app-types'; -import { isConsoleMessage } from '../helpers/is-console-message'; +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; import { FirebaseService } from '@firebase/app-types/private'; +import { dbGet } from '../helpers/idb-manager'; +import { externalizePayload } from '../helpers/externalizePayload'; +import { isConsoleMessage } from '../helpers/is-console-message'; +import { sleep } from '../helpers/sleep'; // Let TS know that this is a service worker declare const self: ServiceWorkerGlobalScope; @@ -38,8 +40,17 @@ declare const self: ServiceWorkerGlobalScope; export type BgMessageHandler = (payload: MessagePayload) => unknown; export class SwController implements FirebaseMessaging, FirebaseService { + // A boolean flag to determine wether an app is using onBackgroundMessage or + // setBackgroundMessageHandler. onBackgroundMessage will receive a MessagePayload regardless of if + // a notification is displayed. Whereas, setBackgroundMessageHandler will swallow the + // MessagePayload if a NotificationPayload is included. + private isOnBackgroundMessageUsed: boolean | null = null; private vapidKey: string | null = null; - private bgMessageHandler: BgMessageHandler | null = null; + private bgMessageHandler: + | BgMessageHandler + | null + | NextFn + | Observer = null; constructor( private readonly firebaseDependencies: FirebaseInternalDependencies @@ -60,21 +71,23 @@ export class SwController implements FirebaseMessaging, FirebaseService { } /** - * Calling setBackgroundMessageHandler will opt in to some specific - * behaviours. - * 1.) If a notification doesn't need to be shown due to a window already - * being visible, then push messages will be sent to the page. - * 2.) If a notification needs to be shown, and the message contains no - * notification data this method will be called - * and the promise it returns will be passed to event.waitUntil. - * If you do not set this callback then all push messages will let and the - * developer can handle them in a their own 'push' event callback + * @deprecated. Use onBackgroundMessage(nextOrObserver: NextFn | Observer): + * Unsubscribe instead. + * + * Calling setBackgroundMessageHandler will opt in to some specific behaviors. * - * @param callback The callback to be called when a push message is received - * and a notification must be shown. The callback will be given the data from - * the push message. + * 1.) If a notification doesn't need to be shown due to a window already being visible, then push + * messages will be sent to the page. 2.) If a notification needs to be shown, and the message + * contains no notification data this method will be called and the promise it returns will be + * passed to event.waitUntil. If you do not set this callback then all push messages will let and + * the developer can handle them in a their own 'push' event callback + * + * @param callback The callback to be called when a push message is received and a notification + * must be shown. The callback will be given the data from the push message. */ setBackgroundMessageHandler(callback: BgMessageHandler): void { + this.isOnBackgroundMessageUsed = false; + if (!callback || typeof callback !== 'function') { throw ERROR_FACTORY.create(ErrorCode.INVALID_BG_HANDLER); } @@ -82,14 +95,24 @@ export class SwController implements FirebaseMessaging, FirebaseService { this.bgMessageHandler = callback; } - // TODO: Remove getToken from SW Controller. - // Calling this from an old SW can cause all kinds of trouble. + onBackgroundMessage( + nextOrObserver: NextFn | Observer + ): Unsubscribe { + this.isOnBackgroundMessageUsed = true; + this.bgMessageHandler = nextOrObserver; + + return () => { + this.bgMessageHandler = null; + }; + } + + // TODO: Remove getToken from SW Controller. Calling this from an old SW can cause all kinds of + // trouble. async getToken(): Promise { if (!this.vapidKey) { - // Call getToken using the current VAPID key if there already is a token. - // This is needed because usePublicVapidKey was not available in SW. - // It will be removed when vapidKey becomes a parameter of getToken, or - // when getToken is removed from SW. + // Call getToken using the current VAPID key if there already is a token. This is needed + // because usePublicVapidKey was not available in SW. It will be removed when vapidKey becomes + // a parameter of getToken, or when getToken is removed from SW. const tokenDetails = await dbGet(this.firebaseDependencies); this.vapidKey = tokenDetails?.subscriptionOptions?.vapidKey ?? DEFAULT_VAPID_KEY; @@ -102,8 +125,8 @@ export class SwController implements FirebaseMessaging, FirebaseService { ); } - // TODO: Remove deleteToken from SW Controller. - // Calling this from an old SW can cause all kinds of trouble. + // TODO: Remove deleteToken from SW Controller. Calling this from an old SW can cause all kinds of + // trouble. deleteToken(): Promise { return deleteToken(this.firebaseDependencies, self.registration); } @@ -112,7 +135,6 @@ export class SwController implements FirebaseMessaging, FirebaseService { throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); } - // TODO: Deprecate this and make VAPID key a parameter in getToken. // TODO: Remove this together with getToken from SW Controller. usePublicVapidKey(vapidKey: string): void { if (this.vapidKey !== null) { @@ -139,34 +161,55 @@ export class SwController implements FirebaseMessaging, FirebaseService { } /** - * A handler for push events that shows notifications based on the content of - * the payload. + * A handler for push events that shows notifications based on the content of the payload. * - * The payload must be a JSON-encoded Object with a `notification` key. The - * value of the `notification` property will be used as the NotificationOptions - * object passed to showNotification. Additionally, the `title` property of the - * notification object will be used as the title. + * The payload must be a JSON-encoded Object with a `notification` key. The value of the + * `notification` property will be used as the NotificationOptions object passed to + * showNotification. Additionally, the `title` property of the notification object will be used as + * the title. * - * If there is no notification data in the payload then no notification will be - * shown. + * If there is no notification data in the payload then no notification will be shown. */ async onPush(event: PushEvent): Promise { - const payload = getMessagePayload(event); - if (!payload) { + const internalPayload = getMessagePayloadInternal(event); + if (!internalPayload) { + console.debug( + TAG + + 'failed to get parsed MessagePayload from the PushEvent. Skip handling the push.' + ); return; } + // foreground handling: eventually passed to onMessage hook const clientList = await getClientList(); if (hasVisibleClients(clientList)) { - // App in foreground. Send to page. - return sendMessageToWindowClients(clientList, payload); + return sendMessagePayloadInternalToWindows(clientList, internalPayload); + } + + // background handling: display and pass to onBackgroundMessage hook + let isNotificationShown = false; + if (!!internalPayload.notification) { + await showNotification(wrapInternalPayload(internalPayload)); + isNotificationShown = true; + } + + // MessagePayload is only passed to `onBackgroundMessage`. Skip passing MessagePayload for + // the legacy `setBackgroundMessageHandler` to preserve the SDK behaviors. + if ( + isNotificationShown === true && + this.isOnBackgroundMessageUsed === false + ) { + return; } - const notificationDetails = getNotificationData(payload); - if (notificationDetails) { - await showNotification(notificationDetails); - } else if (this.bgMessageHandler) { - await this.bgMessageHandler(payload); + if (!!this.bgMessageHandler) { + const payload = externalizePayload(internalPayload); + + if (typeof this.bgMessageHandler === 'function') { + this.bgMessageHandler(payload); + } else { + this.bgMessageHandler.next(payload); + } } } @@ -188,14 +231,14 @@ export class SwController implements FirebaseMessaging, FirebaseService { } async onNotificationClick(event: NotificationEvent): Promise { - const payload: MessagePayload = event.notification?.data?.[FCM_MSG]; - if (!payload) { - // Not an FCM notification, do nothing. + const internalPayload: MessagePayloadInternal = + event.notification?.data?.[FCM_MSG]; + + if (!internalPayload) { return; } else if (event.action) { - // User clicked on an action button. - // This will allow devs to act on action button clicks by using a custom - // onNotificationClick listener that they define. + // User clicked on an action button. This will allow developers to act on action button clicks + // by using a custom onNotificationClick listener that they define. return; } @@ -203,18 +246,17 @@ export class SwController implements FirebaseMessaging, FirebaseService { event.stopImmediatePropagation(); event.notification.close(); - const link = getLink(payload); + const link = getLink(internalPayload); if (!link) { return; } let client = await getWindowClient(link); if (!client) { - // Unable to find window client so need to open one. - // This also focuses the opened client. + // Unable to find window client so need to open one. This also focuses the opened client. client = await self.clients.openWindow(link); - // Wait three seconds for the client to initialize and set up the message - // handler so that it can receive the message. + // Wait three seconds for the client to initialize and set up the message handler so that it + // can receive the message. await sleep(3000); } else { client = await client.focus(); @@ -225,12 +267,32 @@ export class SwController implements FirebaseMessaging, FirebaseService { return; } - const message = createNewMessage(MessageType.NOTIFICATION_CLICKED, payload); - return client.postMessage(message); + internalPayload.messageType = MessageType.NOTIFICATION_CLICKED; + internalPayload.isFirebaseMessaging = true; + return client.postMessage(internalPayload); } } -function getMessagePayload({ data }: PushEvent): MessagePayload | null { +function wrapInternalPayload( + internalPayload: MessagePayloadInternal +): NotificationPayloadInternal { + const wrappedInternalPayload: NotificationPayloadInternal = { + ...((internalPayload.notification as unknown) as NotificationPayloadInternal) + }; + + // Put the message payload under FCM_MSG name so we can identify the notification as being an FCM + // notification vs a notification from somewhere else (i.e. normal web push or developer generated + // notification). + wrappedInternalPayload.data = { + [FCM_MSG]: internalPayload + }; + + return wrappedInternalPayload; +} + +function getMessagePayloadInternal({ + data +}: PushEvent): MessagePayloadInternal | null { if (!data) { return null; } @@ -243,36 +305,13 @@ function getMessagePayload({ data }: PushEvent): MessagePayload | null { } } -function getNotificationData( - payload: MessagePayload -): NotificationDetails | undefined { - if (!payload || typeof payload.notification !== 'object') { - return; - } - - const notificationInformation: NotificationDetails = { - ...payload.notification - }; - - // Put the message payload under FCM_MSG name so we can identify the - // notification as being an FCM notification vs a notification from - // somewhere else (i.e. normal web push or developer generated - // notification). - notificationInformation.data = { - ...payload.notification.data, - [FCM_MSG]: payload - }; - - return notificationInformation; -} - /** * @param url The URL to look for when focusing a client. * @return Returns an existing window client or a newly opened WindowClient. */ async function getWindowClient(url: string): Promise { - // Use URL to normalize the URL when comparing to windowClients. - // This at least handles whether to include trailing slashes or not + // Use URL to normalize the URL when comparing to windowClients. This at least handles whether to + // include trailing slashes or not const parsedURL = new URL(url, self.location.href); const clientList = await getClientList(); @@ -288,33 +327,28 @@ async function getWindowClient(url: string): Promise { } /** - * @returns If there is currently a visible WindowClient, this method will - * resolve to true, otherwise false. + * @returns If there is currently a visible WindowClient, this method will resolve to true, + * otherwise false. */ function hasVisibleClients(clientList: WindowClient[]): boolean { return clientList.some( client => client.visibilityState === 'visible' && - // Ignore chrome-extension clients as that matches the background pages - // of extensions, which are always considered visible for some reason. + // Ignore chrome-extension clients as that matches the background pages of extensions, which + // are always considered visible for some reason. !client.url.startsWith('chrome-extension://') ); } -/** - * @param payload The data from the push event that should be sent to all - * available pages. - * @returns Returns a promise that resolves once the message has been sent to - * all WindowClients. - */ -function sendMessageToWindowClients( +function sendMessagePayloadInternalToWindows( clientList: WindowClient[], - payload: MessagePayload + internalPayload: MessagePayloadInternal ): void { - const message = createNewMessage(MessageType.PUSH_RECEIVED, payload); + internalPayload.isFirebaseMessaging = true; + internalPayload.messageType = MessageType.PUSH_RECEIVED; for (const client of clientList) { - client.postMessage(message); + client.postMessage(internalPayload); } } @@ -326,21 +360,12 @@ function getClientList(): Promise { }) as Promise; } -function createNewMessage( - type: MessageType, - payload: MessagePayload -): InternalMessage { - return { - firebaseMessaging: { type, payload } - }; -} - -function showNotification(details: NotificationDetails): Promise { - const title = details.title ?? ''; - - const { actions } = details; +function showNotification( + notificationPayloadInternal: NotificationPayloadInternal +): Promise { // Note: Firefox does not support the maxActions property. // https://developer.mozilla.org/en-US/docs/Web/API/notification/maxActions + const { actions } = notificationPayloadInternal; const { maxActions } = Notification; if (actions && maxActions && actions.length > maxActions) { console.warn( @@ -348,10 +373,13 @@ function showNotification(details: NotificationDetails): Promise { ); } - return self.registration.showNotification(title, details); + return self.registration.showNotification( + /* title= */ notificationPayloadInternal.title ?? '', + notificationPayloadInternal + ); } -function getLink(payload: MessagePayload): string | null { +function getLink(payload: MessagePayloadInternal): string | null { // eslint-disable-next-line camelcase const link = payload.fcmOptions?.link ?? payload.notification?.click_action; if (link) { diff --git a/packages/messaging/src/controllers/window-controller.test.ts b/packages/messaging/src/controllers/window-controller.test.ts index a504e553a14..373b4eb42f7 100644 --- a/packages/messaging/src/controllers/window-controller.test.ts +++ b/packages/messaging/src/controllers/window-controller.test.ts @@ -1,3 +1,20 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * 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 '../testing/setup'; import * as tokenManagementModule from '../core/token-management'; @@ -11,7 +28,10 @@ import { DEFAULT_SW_SCOPE, DEFAULT_VAPID_KEY } from '../util/constants'; -import { InternalMessage, MessageType } from '../interfaces/internal-message'; +import { + MessagePayloadInternal, + MessageType +} from '../interfaces/internal-message-payload'; import { SinonFakeTimers, SinonSpy, spy, stub, useFakeTimers } from 'sinon'; import { Spy, Stub } from '../testing/sinon-types'; @@ -20,28 +40,12 @@ import { FakeServiceWorkerRegistration } from '../testing/fakes/service-worker'; import { FirebaseAnalyticsInternal } from '@firebase/analytics-interop-types'; import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; import { WindowController } from './window-controller'; -/** - * @license - * Copyright 2017 Google LLC - * - * 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 { expect } from 'chai'; import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; type MessageEventListener = (event: Event) => Promise; -const ORIGINAL_SW_REGISTRATION = ServiceWorkerRegistration; +const ORIGINAL_SW_REGISTRATION = FakeServiceWorkerRegistration; describe('WindowController', () => { let firebaseDependencies: FirebaseInternalDependencies; @@ -104,6 +108,129 @@ describe('WindowController', () => { }); describe('getToken', () => { + it('uses default sw if none was registered nor provided', async () => { + expect(windowController.getSwReg()).to.be.undefined; + + await windowController.getToken({}); + + expect(registerStub).to.have.been.calledOnceWith(DEFAULT_SW_PATH, { + scope: DEFAULT_SW_SCOPE + }); + }); + + it('uses option-provided swReg if non was registered', async () => { + expect(windowController.getSwReg()).to.be.undefined; + + await windowController.getToken({ + serviceWorkerRegistration: swRegistration + }); + + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration, + DEFAULT_VAPID_KEY + ); + }); + + it('uses previously stored sw if non is provided in the option parameter', async () => { + windowController.useServiceWorker(swRegistration); + expect(windowController.getSwReg()).to.be.deep.equal(swRegistration); + + await windowController.getToken({}); + + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration, + DEFAULT_VAPID_KEY + ); + }); + + it('new swReg overrides existing swReg ', async () => { + windowController.useServiceWorker(swRegistration); + expect(windowController.getSwReg()).to.be.deep.equal(swRegistration); + + const otherSwReg = new FakeServiceWorkerRegistration(); + + await windowController.getToken({ + serviceWorkerRegistration: otherSwReg + }); + + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + otherSwReg, + DEFAULT_VAPID_KEY + ); + }); + + it('uses default VAPID if: a) no VAPID was stored and b) none is provided in option', async () => { + expect(windowController.getVapidKey()).is.null; + + await windowController.getToken({}); + + expect(windowController.getVapidKey()).to.equal(DEFAULT_VAPID_KEY); + + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration, + DEFAULT_VAPID_KEY + ); + }); + + it('uses option-provided VAPID if no VAPID has been registered', async () => { + expect(windowController.getVapidKey()).is.null; + + await windowController.getToken({ vapidKey: 'test_vapid_key' }); + + expect(windowController.getVapidKey()).to.equal('test_vapid_key'); + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration, + 'test_vapid_key' + ); + }); + + it('uses option-provided VAPID if it is different from currently registered VAPID', async () => { + windowController.usePublicVapidKey('old_key'); + expect(windowController.getVapidKey()).to.equal('old_key'); + + await windowController.getToken({ vapidKey: 'new_key' }); + + expect(windowController.getVapidKey()).to.equal('new_key'); + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration, + 'new_key' + ); + }); + + it('uses existing VAPID if newly provided has the same value', async () => { + windowController.usePublicVapidKey('key'); + expect(windowController.getVapidKey()).to.equal('key'); + + await windowController.getToken({ vapidKey: 'key' }); + + expect(windowController.getVapidKey()).to.equal('key'); + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration, + 'key' + ); + }); + + it('uses existing VAPID if non is provided in the option parameter', async () => { + windowController.usePublicVapidKey('key'); + expect(windowController.getVapidKey()).to.equal('key'); + + await windowController.getToken({}); + + expect(windowController.getVapidKey()).to.equal('key'); + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration, + 'key' + ); + }); + it('throws if permission is denied', async () => { stub(Notification, 'permission').value('denied'); @@ -247,15 +374,17 @@ describe('WindowController', () => { const onMessageCallback = spy(); windowController.onMessage(onMessageCallback); - const message: InternalMessage = { - firebaseMessaging: { - type: MessageType.PUSH_RECEIVED, - payload: { notification: { title: 'hello', body: 'world' } } - } + const internalPayload: MessagePayloadInternal = { + notification: { title: 'hello', body: 'world' }, + messageType: MessageType.PUSH_RECEIVED, + isFirebaseMessaging: true, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; await messageEventListener( - new MessageEvent('message', { data: message }) + new MessageEvent('message', { data: internalPayload }) ); expect(onMessageCallback).to.have.been.called; @@ -269,34 +398,38 @@ describe('WindowController', () => { complete: () => {} }); - const message: InternalMessage = { - firebaseMessaging: { - type: MessageType.PUSH_RECEIVED, - payload: { notification: { title: 'hello', body: 'world' } } - } + const internalPayload: MessagePayloadInternal = { + notification: { title: 'hello', body: 'world' }, + messageType: MessageType.PUSH_RECEIVED, + isFirebaseMessaging: true, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; await messageEventListener( - new MessageEvent('message', { data: message }) + new MessageEvent('message', { data: internalPayload }) ); expect(onMessageCallback).to.have.been.called; }); - it('returns a function that unsets the onMessage callback', async () => { + it('returns a function that clears the onMessage callback', async () => { const onMessageCallback = spy(); const unsubscribe = windowController.onMessage(onMessageCallback); unsubscribe(); - const message: InternalMessage = { - firebaseMessaging: { - type: MessageType.PUSH_RECEIVED, - payload: { notification: { title: 'hello', body: 'world' } } - } + const internalPayload: MessagePayloadInternal = { + notification: { title: 'hello', body: 'world' }, + messageType: MessageType.PUSH_RECEIVED, + isFirebaseMessaging: true, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; await messageEventListener( - new MessageEvent('message', { data: message }) + new MessageEvent('message', { data: internalPayload }) ); expect(onMessageCallback).not.to.have.been.called; @@ -380,33 +513,40 @@ describe('WindowController', () => { }); it('calls onMessage callback when it receives a PUSH_RECEIVED message', async () => { - const message: InternalMessage = { - firebaseMessaging: { - type: MessageType.PUSH_RECEIVED, - payload: { notification: { title: 'hello', body: 'world' } } - } + const internalPayload: MessagePayloadInternal = { + notification: { title: 'hello', body: 'world' }, + messageType: MessageType.PUSH_RECEIVED, + isFirebaseMessaging: true, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; await messageEventListener( - new MessageEvent('message', { data: message }) + new MessageEvent('message', { data: internalPayload }) ); - expect(onMessageSpy).to.have.been.calledOnceWith( - message.firebaseMessaging.payload - ); + expect(onMessageSpy).to.have.been.calledOnceWith({ + notification: { title: 'hello', body: 'world' }, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' + }); expect(logEventSpy).not.to.have.been.called; }); it('does not call onMessage callback when it receives a NOTIFICATION_CLICKED message', async () => { - const message: InternalMessage = { - firebaseMessaging: { - type: MessageType.NOTIFICATION_CLICKED, - payload: { notification: { title: 'hello', body: 'world' } } - } + const internalPayload: MessagePayloadInternal = { + notification: { title: 'hello', body: 'world' }, + messageType: MessageType.NOTIFICATION_CLICKED, + isFirebaseMessaging: true, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; await messageEventListener( - new MessageEvent('message', { data: message }) + new MessageEvent('message', { data: internalPayload }) ); expect(onMessageSpy).not.to.have.been.called; @@ -414,36 +554,44 @@ describe('WindowController', () => { }); it('calls analytics.logEvent if the message has analytics enabled for PUSH_RECEIVED', async () => { - const data = { - [CONSOLE_CAMPAIGN_ID]: '123456', - [CONSOLE_CAMPAIGN_NAME]: 'Campaign Name', - [CONSOLE_CAMPAIGN_TIME]: '1234567890', - [CONSOLE_CAMPAIGN_ANALYTICS_ENABLED]: '1' - }; - const message: InternalMessage = { - firebaseMessaging: { - type: MessageType.PUSH_RECEIVED, - payload: { - notification: { title: 'hello', body: 'world' }, - data - } - } + const internalPayload: MessagePayloadInternal = { + notification: { title: 'hello', body: 'world' }, + data: { + [CONSOLE_CAMPAIGN_ID]: '123456', + [CONSOLE_CAMPAIGN_NAME]: 'Campaign Name', + [CONSOLE_CAMPAIGN_TIME]: '1234567890', + [CONSOLE_CAMPAIGN_ANALYTICS_ENABLED]: '1' + }, + messageType: MessageType.PUSH_RECEIVED, + isFirebaseMessaging: true, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; await messageEventListener( - new MessageEvent('message', { data: message }) + new MessageEvent('message', { data: internalPayload }) ); - expect(onMessageSpy).to.have.been.calledOnceWith( - message.firebaseMessaging.payload - ); + expect(onMessageSpy).to.have.been.calledOnceWith({ + notification: { title: 'hello', body: 'world' }, + data: { + [CONSOLE_CAMPAIGN_ID]: '123456', + [CONSOLE_CAMPAIGN_NAME]: 'Campaign Name', + [CONSOLE_CAMPAIGN_TIME]: '1234567890', + [CONSOLE_CAMPAIGN_ANALYTICS_ENABLED]: '1' + }, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' + }); expect(logEventSpy).to.have.been.calledOnceWith( 'notification_foreground', { /* eslint-disable camelcase */ - message_id: data[CONSOLE_CAMPAIGN_ID], - message_name: data[CONSOLE_CAMPAIGN_NAME], - message_time: data[CONSOLE_CAMPAIGN_TIME], + message_id: '123456', + message_name: 'Campaign Name', + message_time: '1234567890', message_device_time: clock.now /* eslint-enable camelcase */ } @@ -451,32 +599,31 @@ describe('WindowController', () => { }); it('calls analytics.logEvent if the message has analytics enabled for NOTIFICATION_CLICKED', async () => { - const data = { - [CONSOLE_CAMPAIGN_ID]: '123456', - [CONSOLE_CAMPAIGN_NAME]: 'Campaign Name', - [CONSOLE_CAMPAIGN_TIME]: '1234567890', - [CONSOLE_CAMPAIGN_ANALYTICS_ENABLED]: '1' - }; - const message: InternalMessage = { - firebaseMessaging: { - type: MessageType.NOTIFICATION_CLICKED, - payload: { - notification: { title: 'hello', body: 'world' }, - data - } - } + const internalPayload: MessagePayloadInternal = { + notification: { title: 'hello', body: 'world' }, + data: { + [CONSOLE_CAMPAIGN_ID]: '123456', + [CONSOLE_CAMPAIGN_NAME]: 'Campaign Name', + [CONSOLE_CAMPAIGN_TIME]: '1234567890', + [CONSOLE_CAMPAIGN_ANALYTICS_ENABLED]: '1' + }, + messageType: MessageType.NOTIFICATION_CLICKED, + isFirebaseMessaging: true, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' }; await messageEventListener( - new MessageEvent('message', { data: message }) + new MessageEvent('message', { data: internalPayload }) ); expect(onMessageSpy).not.to.have.been.called; expect(logEventSpy).to.have.been.calledOnceWith('notification_open', { /* eslint-disable camelcase */ - message_id: data[CONSOLE_CAMPAIGN_ID], - message_name: data[CONSOLE_CAMPAIGN_NAME], - message_time: data[CONSOLE_CAMPAIGN_TIME], + message_id: '123456', + message_name: 'Campaign Name', + message_time: '1234567890', message_device_time: clock.now /* eslint-enable camelcase */ }); diff --git a/packages/messaging/src/controllers/window-controller.ts b/packages/messaging/src/controllers/window-controller.ts index 1907224c29c..0ac24de9fe4 100644 --- a/packages/messaging/src/controllers/window-controller.ts +++ b/packages/messaging/src/controllers/window-controller.ts @@ -24,12 +24,15 @@ import { DEFAULT_SW_SCOPE, DEFAULT_VAPID_KEY } from '../util/constants'; +import { + ConsoleMessageData, + MessagePayloadInternal, + MessageType +} from '../interfaces/internal-message-payload'; import { ERROR_FACTORY, ErrorCode } from '../util/errors'; -import { InternalMessage, MessageType } from '../interfaces/internal-message'; import { NextFn, Observer, Unsubscribe } from '@firebase/util'; import { deleteToken, getToken } from '../core/token-management'; -import { ConsoleMessageData } from '../interfaces/message-payload'; import { FirebaseApp } from '@firebase/app-types'; import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; import { FirebaseMessaging } from '@firebase/messaging-types'; @@ -53,16 +56,52 @@ export class WindowController implements FirebaseMessaging, FirebaseService { return this.firebaseDependencies.app; } - async getToken(): Promise { - if (!this.vapidKey) { - this.vapidKey = DEFAULT_VAPID_KEY; + private async messageEventListener(event: MessageEvent): Promise { + const internalPayload = event.data as MessagePayloadInternal; + + if (!internalPayload.isFirebaseMessaging) { + return; } - const swRegistration = await this.getServiceWorkerRegistration(); + // onMessageCallback is either a function or observer/subscriber. + // TODO: in the modularization release, have onMessage handle type MessagePayload as supposed to + // the legacy payload where some fields are in snake cases. + if ( + this.onMessageCallback && + internalPayload.messageType === MessageType.PUSH_RECEIVED + ) { + if (typeof this.onMessageCallback === 'function') { + this.onMessageCallback( + stripInternalFields(Object.assign({}, internalPayload)) + ); + } else { + this.onMessageCallback.next(Object.assign({}, internalPayload)); + } + } + + const dataPayload = internalPayload.data; + + if ( + isConsoleMessage(dataPayload) && + dataPayload[CONSOLE_CAMPAIGN_ANALYTICS_ENABLED] === '1' + ) { + await this.logEvent(internalPayload.messageType!, dataPayload); + } + } + + getVapidKey(): string | null { + return this.vapidKey; + } + + getSwReg(): ServiceWorkerRegistration | undefined { + return this.swRegistration; + } - // Check notification permission. + async getToken(options?: { + vapidKey?: string; + serviceWorkerRegistration?: ServiceWorkerRegistration; + }): Promise { if (Notification.permission === 'default') { - // The user hasn't allowed or denied notifications yet. Ask them. await Notification.requestPermission(); } @@ -70,13 +109,72 @@ export class WindowController implements FirebaseMessaging, FirebaseService { throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); } - return getToken(this.firebaseDependencies, swRegistration, this.vapidKey); + await this.updateVapidKey(options?.vapidKey); + await this.updateSwReg(options?.serviceWorkerRegistration); + + return getToken( + this.firebaseDependencies, + this.swRegistration!, + this.vapidKey! + ); + } + + async updateVapidKey(vapidKey?: string | undefined): Promise { + if (!!vapidKey) { + this.vapidKey = vapidKey; + } else if (!this.vapidKey) { + this.vapidKey = DEFAULT_VAPID_KEY; + } + } + + async updateSwReg( + swRegistration?: ServiceWorkerRegistration | undefined + ): Promise { + if (!swRegistration && !this.swRegistration) { + await this.registerDefaultSw(); + } + + if (!swRegistration && !!this.swRegistration) { + return; + } + + if (!(swRegistration instanceof ServiceWorkerRegistration)) { + throw ERROR_FACTORY.create(ErrorCode.INVALID_SW_REGISTRATION); + } + + this.swRegistration = swRegistration; + } + + private async registerDefaultSw(): Promise { + try { + this.swRegistration = await navigator.serviceWorker.register( + DEFAULT_SW_PATH, + { + scope: DEFAULT_SW_SCOPE + } + ); + + // The timing when browser updates sw when sw has an update is unreliable by my experiment. It + // leads to version conflict when the SDK upgrades to a newer version in the main page, but sw + // is stuck with the old version. For example, + // https://github.com/firebase/firebase-js-sdk/issues/2590 The following line reliably updates + // sw if there was an update. + this.swRegistration.update().catch(() => { + /* it is non blocking and we don't care if it failed */ + }); + } catch (e) { + throw ERROR_FACTORY.create(ErrorCode.FAILED_DEFAULT_REGISTRATION, { + browserErrorMessage: e.message + }); + } } async deleteToken(): Promise { - const swRegistration = await this.getServiceWorkerRegistration(); + if (!this.swRegistration) { + await this.registerDefaultSw(); + } - return deleteToken(this.firebaseDependencies, swRegistration); + return deleteToken(this.firebaseDependencies, this.swRegistration!); } /** @@ -102,7 +200,10 @@ export class WindowController implements FirebaseMessaging, FirebaseService { } } - // TODO: Deprecate this and make VAPID key a parameter in getToken. + /** + * @deprecated. Use getToken(options?: {vapidKey?: string; serviceWorkerRegistration?: + * ServiceWorkerRegistration;}): Promise instead. + */ usePublicVapidKey(vapidKey: string): void { if (this.vapidKey !== null) { throw ERROR_FACTORY.create(ErrorCode.USE_VAPID_KEY_AFTER_GET_TOKEN); @@ -115,6 +216,10 @@ export class WindowController implements FirebaseMessaging, FirebaseService { this.vapidKey = vapidKey; } + /** + * @deprecated. Use getToken(options?: {vapidKey?: string; serviceWorkerRegistration?: + * ServiceWorkerRegistration;}): Promise instead. + */ useServiceWorker(swRegistration: ServiceWorkerRegistration): void { if (!(swRegistration instanceof ServiceWorkerRegistration)) { throw ERROR_FACTORY.create(ErrorCode.INVALID_SW_REGISTRATION); @@ -128,8 +233,8 @@ export class WindowController implements FirebaseMessaging, FirebaseService { } /** - * @param nextOrObserver An observer object or a function triggered on - * message. + * @param nextOrObserver An observer object or a function triggered on message. + * * @return The unsubscribe function for the observer. */ onMessage(nextOrObserver: NextFn | Observer): Unsubscribe { @@ -144,69 +249,16 @@ export class WindowController implements FirebaseMessaging, FirebaseService { throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_SW); } - // Unimplemented - onTokenRefresh(): Unsubscribe { - return () => {}; + onBackgroundMessage(): Unsubscribe { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_SW); } /** - * Creates or updates the default service worker registration. - * @return The service worker registration to be used for the push service. + * @deprecated No-op. It was initially designed with token rotation requests from server in mind. + * However, the plan to implement such feature was abandoned. */ - private async getServiceWorkerRegistration(): Promise< - ServiceWorkerRegistration - > { - if (!this.swRegistration) { - try { - this.swRegistration = await navigator.serviceWorker.register( - DEFAULT_SW_PATH, - { - scope: DEFAULT_SW_SCOPE - } - ); - - // The timing when browser updates sw when sw has an update is unreliable by my experiment. - // It leads to version conflict when the SDK upgrades to a newer version in the main page, but - // sw is stuck with the old version. For example, https://github.com/firebase/firebase-js-sdk/issues/2590 - // The following line reliably updates sw if there was an update. - this.swRegistration.update().catch(() => { - /* it is non blocking and we don't care if it failed */ - }); - } catch (e) { - throw ERROR_FACTORY.create(ErrorCode.FAILED_DEFAULT_REGISTRATION, { - browserErrorMessage: e.message - }); - } - } - - return this.swRegistration; - } - - private async messageEventListener(event: MessageEvent): Promise { - if (!event.data?.firebaseMessaging) { - // Not a message from FCM - return; - } - - const { type, payload } = (event.data as InternalMessage).firebaseMessaging; - - // onMessageCallback is either a function or observer/subscriber. - if (this.onMessageCallback && type === MessageType.PUSH_RECEIVED) { - if (typeof this.onMessageCallback === 'function') { - this.onMessageCallback(payload); - } else { - this.onMessageCallback.next(payload); - } - } - - const { data } = payload; - if ( - isConsoleMessage(data) && - data[CONSOLE_CAMPAIGN_ANALYTICS_ENABLED] === '1' - ) { - // Analytics is enabled on this message, so we should log it. - await this.logEvent(type, data); - } + onTokenRefresh(): Unsubscribe { + return () => {}; } private async logEvent( @@ -236,3 +288,11 @@ function getEventType(messageType: MessageType): string { throw new Error(); } } + +function stripInternalFields( + internalPayload: MessagePayloadInternal +): MessagePayloadInternal { + delete internalPayload.messageType; + delete internalPayload.isFirebaseMessaging; + return internalPayload; +} diff --git a/packages/messaging/src/core/api.test.ts b/packages/messaging/src/core/api.test.ts index 12343359e0e..da3368191c8 100644 --- a/packages/messaging/src/core/api.test.ts +++ b/packages/messaging/src/core/api.test.ts @@ -15,22 +15,24 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { stub } from 'sinon'; import '../testing/setup'; -import { Stub } from '../testing/sinon-types'; + import { ApiRequestBody, + requestDeleteToken, requestGetToken, - requestUpdateToken, - requestDeleteToken + requestUpdateToken } from './api'; -import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; + +import { ENDPOINT } from '../util/constants'; import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { Stub } from '../testing/sinon-types'; import { TokenDetails } from '../interfaces/token-details'; -import { getFakeTokenDetails } from '../testing/fakes/token-details'; -import { ENDPOINT } from '../util/constants'; import { compareHeaders } from '../testing/compare-headers'; +import { expect } from 'chai'; +import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; +import { getFakeTokenDetails } from '../testing/fakes/token-details'; +import { stub } from 'sinon'; describe('API', () => { let tokenDetails: TokenDetails; diff --git a/packages/messaging/src/core/api.ts b/packages/messaging/src/core/api.ts index 6fb0fd1ae77..b93f8623601 100644 --- a/packages/messaging/src/core/api.ts +++ b/packages/messaging/src/core/api.ts @@ -15,11 +15,12 @@ * limitations under the License. */ -import { ErrorCode, ERROR_FACTORY } from '../util/errors'; import { DEFAULT_VAPID_KEY, ENDPOINT } from '../util/constants'; -import { TokenDetails, SubscriptionOptions } from '../interfaces/token-details'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { SubscriptionOptions, TokenDetails } from '../interfaces/token-details'; + import { AppConfig } from '../interfaces/app-config'; +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; export interface ApiResponse { token?: string; diff --git a/packages/messaging/src/core/token-management.test.ts b/packages/messaging/src/core/token-management.test.ts index 26421971f0f..db9f3e39268 100644 --- a/packages/messaging/src/core/token-management.test.ts +++ b/packages/messaging/src/core/token-management.test.ts @@ -16,20 +16,23 @@ */ import '../testing/setup'; + +import * as apiModule from './api'; + +import { SubscriptionOptions, TokenDetails } from '../interfaces/token-details'; +import { dbGet, dbSet } from '../helpers/idb-manager'; +import { deleteToken, getToken } from './token-management'; import { spy, stub, useFakeTimers } from 'sinon'; -import { getToken, deleteToken } from './token-management'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; -import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; -import { FakeServiceWorkerRegistration } from '../testing/fakes/service-worker'; + import { DEFAULT_VAPID_KEY } from '../util/constants'; -import { expect } from 'chai'; import { ErrorCode } from '../util/errors'; -import { dbGet, dbSet } from '../helpers/idb-manager'; -import * as apiModule from './api'; +import { FakeServiceWorkerRegistration } from '../testing/fakes/service-worker'; +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; import { Stub } from '../testing/sinon-types'; -import { getFakeTokenDetails } from '../testing/fakes/token-details'; -import { TokenDetails, SubscriptionOptions } from '../interfaces/token-details'; import { arrayToBase64 } from '../helpers/array-base64-translator'; +import { expect } from 'chai'; +import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; +import { getFakeTokenDetails } from '../testing/fakes/token-details'; describe('Token Management', () => { let tokenDetails: TokenDetails; diff --git a/packages/messaging/src/core/token-management.ts b/packages/messaging/src/core/token-management.ts index 99fdc179be9..b314c89fc77 100644 --- a/packages/messaging/src/core/token-management.ts +++ b/packages/messaging/src/core/token-management.ts @@ -15,15 +15,16 @@ * limitations under the License. */ -import { dbGet, dbSet, dbRemove } from '../helpers/idb-manager'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; -import { TokenDetails, SubscriptionOptions } from '../interfaces/token-details'; -import { requestUpdateToken, requestGetToken, requestDeleteToken } from './api'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { SubscriptionOptions, TokenDetails } from '../interfaces/token-details'; import { arrayToBase64, base64ToArray } from '../helpers/array-base64-translator'; -import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { dbGet, dbRemove, dbSet } from '../helpers/idb-manager'; +import { requestDeleteToken, requestGetToken, requestUpdateToken } from './api'; + +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; /** UpdateRegistration will be called once every week. */ const TOKEN_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -37,8 +38,8 @@ export async function getToken( throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); } - // If a PushSubscription exists it's returned, otherwise a new subscription - // is generated and returned. + // If a PushSubscription exists it's returned, otherwise a new subscription is generated and + // returned. const pushSubscription = await getPushSubscription(swRegistration, vapidKey); const tokenDetails = await dbGet(firebaseDependencies); @@ -83,8 +84,8 @@ export async function getToken( } /** - * This method deletes the token from the database, unsubscribes the token from - * FCM, and unregisters the push subscription if it exists. + * This method deletes the token from the database, unsubscribes the token from FCM, and unregisters + * the push subscription if it exists. */ export async function deleteToken( firebaseDependencies: FirebaseInternalDependencies, diff --git a/packages/messaging/src/helpers/array-base64-translator.test.ts b/packages/messaging/src/helpers/array-base64-translator.test.ts index 1274311e0cb..c161b365dbc 100644 --- a/packages/messaging/src/helpers/array-base64-translator.test.ts +++ b/packages/messaging/src/helpers/array-base64-translator.test.ts @@ -15,9 +15,11 @@ * limitations under the License. */ +import '../testing/setup'; + import { arrayToBase64, base64ToArray } from './array-base64-translator'; + import { expect } from 'chai'; -import '../testing/setup'; // prettier-ignore const TEST_P256_ARRAY = new Uint8Array([ diff --git a/packages/messaging/src/helpers/externalizePayload.test.ts b/packages/messaging/src/helpers/externalizePayload.test.ts new file mode 100644 index 00000000000..c42fb662cac --- /dev/null +++ b/packages/messaging/src/helpers/externalizePayload.test.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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 { MessagePayload } from '@firebase/messaging-types'; +import { MessagePayloadInternal } from '../interfaces/internal-message-payload'; +import { expect } from 'chai'; +import { externalizePayload } from './externalizePayload'; + +describe('externalizePayload', () => { + it('externalizes internalMessage with only notification payload', () => { + const internalPayload: MessagePayloadInternal = { + notification: { + title: 'title', + body: 'body', + image: 'image' + }, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' + }; + + const payload: MessagePayload = { + notification: { title: 'title', body: 'body', image: 'image' }, + from: 'from', + collapseKey: 'collapse' + }; + expect(externalizePayload(internalPayload)).to.deep.equal(payload); + }); + + it('externalizes internalMessage with only data payload', () => { + const internalPayload: MessagePayloadInternal = { + data: { + foo: 'foo', + bar: 'bar', + baz: 'baz' + }, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' + }; + + const payload: MessagePayload = { + data: { foo: 'foo', bar: 'bar', baz: 'baz' }, + from: 'from', + collapseKey: 'collapse' + }; + expect(externalizePayload(internalPayload)).to.deep.equal(payload); + }); + + it('externalizes internalMessage with all three payloads', () => { + const internalPayload: MessagePayloadInternal = { + notification: { + title: 'title', + body: 'body', + image: 'image' + }, + data: { + foo: 'foo', + bar: 'bar', + baz: 'baz' + }, + fcmOptions: { + link: 'link', + // eslint-disable-next-line camelcase + analytics_label: 'label' + }, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' + }; + + const payload: MessagePayload = { + notification: { + title: 'title', + body: 'body', + image: 'image' + }, + data: { + foo: 'foo', + bar: 'bar', + baz: 'baz' + }, + fcmOptions: { + link: 'link', + analyticsLabel: 'label' + }, + from: 'from', + collapseKey: 'collapse' + }; + expect(externalizePayload(internalPayload)).to.deep.equal(payload); + }); +}); diff --git a/packages/messaging/src/helpers/externalizePayload.ts b/packages/messaging/src/helpers/externalizePayload.ts new file mode 100644 index 00000000000..f86cafd089e --- /dev/null +++ b/packages/messaging/src/helpers/externalizePayload.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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 { MessagePayload } from '@firebase/messaging-types'; +import { MessagePayloadInternal } from '../interfaces/internal-message-payload'; + +export function externalizePayload( + internalPayload: MessagePayloadInternal +): MessagePayload { + const payload: MessagePayload = { + from: internalPayload.from, + // eslint-disable-next-line camelcase + collapseKey: internalPayload.collapse_key + } as MessagePayload; + + propagateNotificationPayload(payload, internalPayload); + propagateDataPayload(payload, internalPayload); + propagateFcmOptions(payload, internalPayload); + + return payload; +} + +function propagateNotificationPayload( + payload: MessagePayload, + messagePayloadInternal: MessagePayloadInternal +): void { + if (!messagePayloadInternal.notification) { + return; + } + + payload.notification = {}; + + const title = messagePayloadInternal.notification!.title; + if (!!title) { + payload.notification!.title = title; + } + + const body = messagePayloadInternal.notification!.body; + if (!!body) { + payload.notification!.body = body; + } + + const image = messagePayloadInternal.notification!.image; + if (!!image) { + payload.notification!.image = image; + } +} + +function propagateDataPayload( + payload: MessagePayload, + messagePayloadInternal: MessagePayloadInternal +): void { + if (!messagePayloadInternal.data) { + return; + } + + payload.data = messagePayloadInternal.data as { [key: string]: string }; +} + +function propagateFcmOptions( + payload: MessagePayload, + messagePayloadInternal: MessagePayloadInternal +): void { + if (!messagePayloadInternal.fcmOptions) { + return; + } + + payload.fcmOptions = {}; + + const link = messagePayloadInternal.fcmOptions!.link; + if (!!link) { + payload.fcmOptions!.link = link; + } + + // eslint-disable-next-line camelcase + const analyticsLabel = messagePayloadInternal.fcmOptions!.analytics_label; + if (!!analyticsLabel) { + payload.fcmOptions!.analyticsLabel = analyticsLabel; + } +} diff --git a/packages/messaging/src/helpers/extract-app-config.test.ts b/packages/messaging/src/helpers/extract-app-config.test.ts index 63d27bb463f..0d8d5c08561 100644 --- a/packages/messaging/src/helpers/extract-app-config.test.ts +++ b/packages/messaging/src/helpers/extract-app-config.test.ts @@ -15,12 +15,13 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { AppConfig } from '../interfaces/app-config'; -import { getFakeApp } from '../testing/fakes/firebase-dependencies'; import '../testing/setup'; -import { extractAppConfig } from './extract-app-config'; + +import { AppConfig } from '../interfaces/app-config'; import { FirebaseApp } from '@firebase/app-types'; +import { expect } from 'chai'; +import { extractAppConfig } from './extract-app-config'; +import { getFakeApp } from '../testing/fakes/firebase-dependencies'; describe('extractAppConfig', () => { it('returns AppConfig if the argument is a FirebaseApp object that includes an appId', () => { diff --git a/packages/messaging/src/helpers/extract-app-config.ts b/packages/messaging/src/helpers/extract-app-config.ts index 67033bffa55..e95a45ced7e 100644 --- a/packages/messaging/src/helpers/extract-app-config.ts +++ b/packages/messaging/src/helpers/extract-app-config.ts @@ -15,10 +15,11 @@ * limitations under the License. */ +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; import { FirebaseApp, FirebaseOptions } from '@firebase/app-types'; -import { FirebaseError } from '@firebase/util'; + import { AppConfig } from '../interfaces/app-config'; -import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { FirebaseError } from '@firebase/util'; export function extractAppConfig(app: FirebaseApp): AppConfig { if (!app || !app.options) { diff --git a/packages/messaging/src/helpers/idb-manager.test.ts b/packages/messaging/src/helpers/idb-manager.test.ts index 98f652840aa..b98ad933378 100644 --- a/packages/messaging/src/helpers/idb-manager.test.ts +++ b/packages/messaging/src/helpers/idb-manager.test.ts @@ -15,16 +15,19 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { stub } from 'sinon'; import '../testing/setup'; -import { TokenDetails } from '../interfaces/token-details'; -import { getFakeTokenDetails } from '../testing/fakes/token-details'; -import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; -import { dbSet, dbGet, dbRemove } from './idb-manager'; + import * as migrateOldDatabaseModule from './migrate-old-database'; -import { Stub } from '../testing/sinon-types'; + +import { dbGet, dbRemove, dbSet } from './idb-manager'; + import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { Stub } from '../testing/sinon-types'; +import { TokenDetails } from '../interfaces/token-details'; +import { expect } from 'chai'; +import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; +import { getFakeTokenDetails } from '../testing/fakes/token-details'; +import { stub } from 'sinon'; describe('idb manager', () => { let firebaseDependencies: FirebaseInternalDependencies; diff --git a/packages/messaging/src/helpers/idb-manager.ts b/packages/messaging/src/helpers/idb-manager.ts index 96c0b524f4c..dbe957de244 100644 --- a/packages/messaging/src/helpers/idb-manager.ts +++ b/packages/messaging/src/helpers/idb-manager.ts @@ -15,10 +15,11 @@ * limitations under the License. */ -import { DB, openDb, deleteDb } from 'idb'; +import { DB, deleteDb, openDb } from 'idb'; + +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; import { TokenDetails } from '../interfaces/token-details'; import { migrateOldDatabase } from './migrate-old-database'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; // Exported for tests. export const DATABASE_NAME = 'firebase-messaging-database'; @@ -29,10 +30,9 @@ let dbPromise: Promise | null = null; function getDbPromise(): Promise { if (!dbPromise) { dbPromise = openDb(DATABASE_NAME, DATABASE_VERSION, upgradeDb => { - // We don't use 'break' in this switch statement, the fall-through - // behavior is what we want, because if there are multiple versions between - // the old version and the current version, we want ALL the migrations - // that correspond to those versions to run, not only the last one. + // We don't use 'break' in this switch statement, the fall-through behavior is what we want, + // because if there are multiple versions between the old version and the current version, we + // want ALL the migrations that correspond to those versions to run, not only the last one. // eslint-disable-next-line default-case switch (upgradeDb.oldVersion) { case 0: diff --git a/packages/messaging/src/helpers/is-console-message.ts b/packages/messaging/src/helpers/is-console-message.ts index c2ed882ae73..151713be132 100644 --- a/packages/messaging/src/helpers/is-console-message.ts +++ b/packages/messaging/src/helpers/is-console-message.ts @@ -15,11 +15,10 @@ * limitations under the License. */ -import { ConsoleMessageData } from '../interfaces/message-payload'; import { CONSOLE_CAMPAIGN_ID } from '../util/constants'; +import { ConsoleMessageData } from '../interfaces/internal-message-payload'; export function isConsoleMessage(data: unknown): data is ConsoleMessageData { - // This message has a campaign ID, meaning it was sent using the - // Firebase Console. + // This message has a campaign ID, meaning it was sent using the Firebase Console. return typeof data === 'object' && !!data && CONSOLE_CAMPAIGN_ID in data; } diff --git a/packages/messaging/src/helpers/migrate-old-database.test.ts b/packages/messaging/src/helpers/migrate-old-database.test.ts index 0ff131621ad..020295ca2fd 100644 --- a/packages/messaging/src/helpers/migrate-old-database.test.ts +++ b/packages/messaging/src/helpers/migrate-old-database.test.ts @@ -15,18 +15,20 @@ * limitations under the License. */ -import { expect } from 'chai'; import '../testing/setup'; -import { openDb } from 'idb'; + import { - migrateOldDatabase, V2TokenDetails, V3TokenDetails, - V4TokenDetails + V4TokenDetails, + migrateOldDatabase } from './migrate-old-database'; + import { FakePushSubscription } from '../testing/fakes/service-worker'; -import { getFakeTokenDetails } from '../testing/fakes/token-details'; import { base64ToArray } from './array-base64-translator'; +import { expect } from 'chai'; +import { getFakeTokenDetails } from '../testing/fakes/token-details'; +import { openDb } from 'idb'; describe('migrateOldDb', () => { it("does nothing if old DB didn't exist", async () => { diff --git a/packages/messaging/src/helpers/migrate-old-database.ts b/packages/messaging/src/helpers/migrate-old-database.ts index 8a935eedd2b..40fdece6171 100644 --- a/packages/messaging/src/helpers/migrate-old-database.ts +++ b/packages/messaging/src/helpers/migrate-old-database.ts @@ -15,7 +15,8 @@ * limitations under the License. */ -import { openDb, deleteDb } from 'idb'; +import { deleteDb, openDb } from 'idb'; + import { TokenDetails } from '../interfaces/token-details'; import { arrayToBase64 } from './array-base64-translator'; @@ -60,8 +61,8 @@ export interface V4TokenDetails { const OLD_DB_NAME = 'fcm_token_details_db'; /** - * The last DB version of 'fcm_token_details_db' was 4. This is one higher, - * so that the upgrade callback is called for all versions of the old DB. + * The last DB version of 'fcm_token_details_db' was 4. This is one higher, so that the upgrade + * callback is called for all versions of the old DB. */ const OLD_DB_VERSION = 5; const OLD_OBJECT_STORE_NAME = 'fcm_token_object_Store'; @@ -70,9 +71,8 @@ export async function migrateOldDatabase( senderId: string ): Promise { if ('databases' in indexedDB) { - // indexedDb.databases() is an IndexedDB v3 API - // and does not exist in all browsers. - // TODO: Remove typecast when it lands in TS types. + // indexedDb.databases() is an IndexedDB v3 API and does not exist in all browsers. TODO: Remove + // typecast when it lands in TS types. const databases = await (indexedDB as { databases(): Promise>; }).databases(); diff --git a/packages/messaging/src/helpers/sleep.test.ts b/packages/messaging/src/helpers/sleep.test.ts index 6dfc4b328ee..b7c4e228f10 100644 --- a/packages/messaging/src/helpers/sleep.test.ts +++ b/packages/messaging/src/helpers/sleep.test.ts @@ -15,9 +15,11 @@ * limitations under the License. */ -import { expect } from 'chai'; -import { SinonFakeTimers, useFakeTimers } from 'sinon'; import '../testing/setup'; + +import { SinonFakeTimers, useFakeTimers } from 'sinon'; + +import { expect } from 'chai'; import { sleep } from './sleep'; describe('sleep', () => { diff --git a/packages/messaging/src/index.ts b/packages/messaging/src/index.ts index 9435f177260..4b0ba76a7c5 100644 --- a/packages/messaging/src/index.ts +++ b/packages/messaging/src/index.ts @@ -15,23 +15,25 @@ * limitations under the License. */ -import firebase from '@firebase/app'; import '@firebase/installations'; -import { - _FirebaseNamespace, - FirebaseService -} from '@firebase/app-types/private'; -import { FirebaseMessaging } from '@firebase/messaging-types'; + import { Component, - ComponentType, - ComponentContainer + ComponentContainer, + ComponentType } from '@firebase/component'; -import { extractAppConfig } from './helpers/extract-app-config'; -import { FirebaseInternalDependencies } from './interfaces/internal-dependencies'; import { ERROR_FACTORY, ErrorCode } from './util/errors'; -import { WindowController } from './controllers/window-controller'; +import { + FirebaseService, + _FirebaseNamespace +} from '@firebase/app-types/private'; + +import { FirebaseInternalDependencies } from './interfaces/internal-dependencies'; +import { FirebaseMessaging } from '@firebase/messaging-types'; import { SwController } from './controllers/sw-controller'; +import { WindowController } from './controllers/window-controller'; +import { extractAppConfig } from './helpers/extract-app-config'; +import firebase from '@firebase/app'; const MESSAGING_NAME = 'messaging'; function factoryMethod( diff --git a/packages/messaging/src/interfaces/internal-dependencies.ts b/packages/messaging/src/interfaces/internal-dependencies.ts index 9ef1e4963b7..ecc380f2089 100644 --- a/packages/messaging/src/interfaces/internal-dependencies.ts +++ b/packages/messaging/src/interfaces/internal-dependencies.ts @@ -15,11 +15,11 @@ * limitations under the License. */ -import { FirebaseInstallations } from '@firebase/installations-types'; -import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; -import { Provider } from '@firebase/component'; import { AppConfig } from './app-config'; +import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; import { FirebaseApp } from '@firebase/app-types'; +import { FirebaseInstallations } from '@firebase/installations-types'; +import { Provider } from '@firebase/component'; export interface FirebaseInternalDependencies { app: FirebaseApp; diff --git a/packages/messaging/src/interfaces/internal-message-payload.ts b/packages/messaging/src/interfaces/internal-message-payload.ts new file mode 100644 index 00000000000..92f7b19fa02 --- /dev/null +++ b/packages/messaging/src/interfaces/internal-message-payload.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2018 Google LLC + * + * 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 { + CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, + CONSOLE_CAMPAIGN_ID, + CONSOLE_CAMPAIGN_NAME, + CONSOLE_CAMPAIGN_TIME +} from '../util/constants'; + +export interface MessagePayloadInternal { + notification?: NotificationPayloadInternal; + data?: unknown; + fcmOptions?: FcmOptionsInternal; + messageType?: MessageType; + isFirebaseMessaging?: boolean; + from: string; + // eslint-disable-next-line camelcase + collapse_key: string; +} + +export interface NotificationPayloadInternal extends NotificationOptions { + title: string; + // Supported in the Legacy Send API. + // See:https://firebase.google.com/docs/cloud-messaging/xmpp-server-ref. + // eslint-disable-next-line camelcase + click_action?: string; +} + +// Defined in +// https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushfcmoptions. Note +// that the keys are sent to the clients in snake cases which we need to convert to camel so it can +// be exposed as a type to match the Firebase API convention. +export interface FcmOptionsInternal { + link?: string; + + // eslint-disable-next-line camelcase + analytics_label?: string; +} + +export enum MessageType { + PUSH_RECEIVED = 'push-received', + NOTIFICATION_CLICKED = 'notification-clicked' +} + +/** Additional data of a message sent from the FN Console. */ +export interface ConsoleMessageData { + [CONSOLE_CAMPAIGN_ID]: string; + [CONSOLE_CAMPAIGN_TIME]: string; + [CONSOLE_CAMPAIGN_NAME]?: string; + [CONSOLE_CAMPAIGN_ANALYTICS_ENABLED]?: '1'; +} diff --git a/packages/messaging/src/interfaces/message-payload.ts b/packages/messaging/src/interfaces/message-payload.ts deleted file mode 100644 index 96dfd76b5b3..00000000000 --- a/packages/messaging/src/interfaces/message-payload.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @license - * Copyright 2018 Google LLC - * - * 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 { - CONSOLE_CAMPAIGN_ID, - CONSOLE_CAMPAIGN_TIME, - CONSOLE_CAMPAIGN_NAME, - CONSOLE_CAMPAIGN_ANALYTICS_ENABLED -} from '../util/constants'; - -export interface NotificationDetails extends NotificationOptions { - title: string; - click_action?: string; // eslint-disable-line camelcase -} - -export interface FcmOptions { - link?: string; -} - -export interface MessagePayload { - fcmOptions?: FcmOptions; - notification?: NotificationDetails; - data?: unknown; -} - -/** Additional data of a message sent from the FN Console. */ -export interface ConsoleMessageData { - [CONSOLE_CAMPAIGN_ID]: string; - [CONSOLE_CAMPAIGN_TIME]: string; - [CONSOLE_CAMPAIGN_NAME]?: string; - [CONSOLE_CAMPAIGN_ANALYTICS_ENABLED]?: '1'; -} diff --git a/packages/messaging/src/testing/compare-headers.test.ts b/packages/messaging/src/testing/compare-headers.test.ts index 8bd6fb81203..21fe9551874 100644 --- a/packages/messaging/src/testing/compare-headers.test.ts +++ b/packages/messaging/src/testing/compare-headers.test.ts @@ -15,8 +15,10 @@ * limitations under the License. */ -import { AssertionError, expect } from 'chai'; import '../testing/setup'; + +import { AssertionError, expect } from 'chai'; + import { compareHeaders } from './compare-headers'; describe('compareHeaders', () => { diff --git a/packages/messaging/src/testing/compare-headers.ts b/packages/messaging/src/testing/compare-headers.ts index 4d4aeb52c0c..98bb73259df 100644 --- a/packages/messaging/src/testing/compare-headers.ts +++ b/packages/messaging/src/testing/compare-headers.ts @@ -15,16 +15,16 @@ * limitations under the License. */ -import { expect } from 'chai'; import '../testing/setup'; +import { expect } from 'chai'; + // Trick TS since it's set to target ES5. declare class HeadersWithEntries extends Headers { entries?(): Iterable<[string, string]>; } -// Chai doesn't check if Headers objects contain the same entries, -// so we need to do that manually. +// Chai doesn't check if Headers objects contain the same entries, so we need to do that manually. export function compareHeaders( expectedHeaders: HeadersWithEntries, actualHeaders: HeadersWithEntries diff --git a/packages/messaging/src/testing/fakes/firebase-dependencies.ts b/packages/messaging/src/testing/fakes/firebase-dependencies.ts index cde6318d4d6..58b0a6812c1 100644 --- a/packages/messaging/src/testing/fakes/firebase-dependencies.ts +++ b/packages/messaging/src/testing/fakes/firebase-dependencies.ts @@ -15,14 +15,15 @@ * limitations under the License. */ -import { FirebaseApp, FirebaseOptions } from '@firebase/app-types'; -import { FirebaseInstallations } from '@firebase/installations-types'; import { - FirebaseAnalyticsInternalName, - FirebaseAnalyticsInternal + FirebaseAnalyticsInternal, + FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; -import { Provider } from '@firebase/component'; +import { FirebaseApp, FirebaseOptions } from '@firebase/app-types'; + +import { FirebaseInstallations } from '@firebase/installations-types'; import { FirebaseInternalDependencies } from '../../interfaces/internal-dependencies'; +import { Provider } from '@firebase/component'; import { extractAppConfig } from '../../helpers/extract-app-config'; export function getFakeFirebaseDependencies( diff --git a/packages/messaging/src/testing/fakes/service-worker.ts b/packages/messaging/src/testing/fakes/service-worker.ts index ca46cbb50a2..0e9a31ad9d0 100644 --- a/packages/messaging/src/testing/fakes/service-worker.ts +++ b/packages/messaging/src/testing/fakes/service-worker.ts @@ -20,15 +20,14 @@ import { Writable } from 'ts-essentials'; // Add fake SW types. declare const self: Window & Writable; -// When trying to stub self.clients self.registration, Sinon complains that -// these properties do not exist. This is because we're not actually running -// these tests in a service worker context. -// -// Here we're adding placeholders for Sinon to overwrite, which prevents the -// "Cannot stub non-existent own property" errors. -// -// Casting to any is needed because TS also thinks that we're in a SW -// context and considers these properties readonly. +// When trying to stub self.clients self.registration, Sinon complains that these properties do not +// exist. This is because we're not actually running these tests in a service worker context. + +// Here we're adding placeholders for Sinon to overwrite, which prevents the "Cannot stub +// non-existent own property" errors. + +// Casting to any is needed because TS also thinks that we're in a SW context and considers these +// properties readonly. // Missing function types are implemented from interfaces, so types are actually defined. /* eslint-disable @typescript-eslint/explicit-function-return-type */ @@ -98,7 +97,6 @@ export class FakeServiceWorkerRegistration active = null; installing = null; waiting = null; - onupdatefound = null; pushManager = new FakePushManager(); scope = '/scope-value'; @@ -167,8 +165,8 @@ export class FakePushSubscription implements PushSubscription { } /** - * Most of the fields in here are unused / deprecated. - * They are only added here to match the TS Event interface. + * Most of the fields in here are unused / deprecated. They are only added here to match the TS + * Event interface. */ export class FakeEvent implements ExtendableEvent { NONE = Event.NONE; diff --git a/packages/messaging/src/testing/fakes/token-details.ts b/packages/messaging/src/testing/fakes/token-details.ts index f40ff7ce459..73eea06b2e5 100644 --- a/packages/messaging/src/testing/fakes/token-details.ts +++ b/packages/messaging/src/testing/fakes/token-details.ts @@ -15,8 +15,8 @@ * limitations under the License. */ -import { TokenDetails } from '../../interfaces/token-details'; import { FakePushSubscription } from './service-worker'; +import { TokenDetails } from '../../interfaces/token-details'; import { arrayToBase64 } from '../../helpers/array-base64-translator'; export function getFakeTokenDetails(): TokenDetails { diff --git a/packages/messaging/src/testing/setup.ts b/packages/messaging/src/testing/setup.ts index 29546a8ad23..0d55cf43ef2 100644 --- a/packages/messaging/src/testing/setup.ts +++ b/packages/messaging/src/testing/setup.ts @@ -15,12 +15,13 @@ * limitations under the License. */ -import { use } from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; -import { restore } from 'sinon'; import * as sinonChai from 'sinon-chai'; + import { dbDelete } from '../helpers/idb-manager'; import { deleteDb } from 'idb'; +import { restore } from 'sinon'; +import { use } from 'chai'; use(chaiAsPromised); use(sinonChai); diff --git a/packages/messaging/src/testing/sinon-types.ts b/packages/messaging/src/testing/sinon-types.ts index decd2506161..13eafbac969 100644 --- a/packages/messaging/src/testing/sinon-types.ts +++ b/packages/messaging/src/testing/sinon-types.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { SinonStub, SinonSpy } from 'sinon'; +import { SinonSpy, SinonStub } from 'sinon'; // Helper types for Sinon stubs and spies. diff --git a/packages/messaging/src/util/constants.ts b/packages/messaging/src/util/constants.ts index 1c3a83765f2..4bafce33b0a 100644 --- a/packages/messaging/src/util/constants.ts +++ b/packages/messaging/src/util/constants.ts @@ -31,3 +31,4 @@ export const CONSOLE_CAMPAIGN_NAME = 'google.c.a.c_l'; export const CONSOLE_CAMPAIGN_TIME = 'google.c.a.ts'; /** Set to '1' if Analytics is enabled for the campaign */ export const CONSOLE_CAMPAIGN_ANALYTICS_ENABLED = 'google.c.a.e'; +export const TAG = 'FirebaseMessaging: '; diff --git a/packages/messaging/src/util/sw-types.ts b/packages/messaging/src/util/sw-types.ts index 0c11f7ba84f..9c54a5ccc03 100644 --- a/packages/messaging/src/util/sw-types.ts +++ b/packages/messaging/src/util/sw-types.ts @@ -19,14 +19,14 @@ * Subset of Web Worker types from lib.webworker.d.ts * https://github.com/Microsoft/TypeScript/blob/master/lib/lib.webworker.d.ts * - * Since it's not possible to have both "dom" and "webworker" libs in a single - * project, we have to manually declare the web worker types we need. + * Since it's not possible to have both "dom" and "webworker" libs in a single project, we have to + * manually declare the web worker types we need. */ /* eslint-disable @typescript-eslint/no-explicit-any */ // These types are from TS -// Not the whole interface, just the parts we're currently using. -// If TS claims that something does not exist on this, feel free to add it. +// Not the whole interface, just the parts we're currently using. If TS claims that something does +// not exist on this, feel free to add it. interface ServiceWorkerGlobalScope { readonly location: WorkerLocation; readonly clients: Clients;