From 6a4a669b02a84570df01cd95a09dfee7612fb338 Mon Sep 17 00:00:00 2001 From: kai Date: Mon, 21 Sep 2020 15:05:25 -0700 Subject: [PATCH 01/17] FM Modualization --- packages-exp/messaging-exp/.eslintrc.js | 26 + packages-exp/messaging-exp/.npmignore | 9 + packages-exp/messaging-exp/README.md | 5 + packages-exp/messaging-exp/karma.conf.js | 32 + packages-exp/messaging-exp/package.json | 57 ++ packages-exp/messaging-exp/rollup.config.js | 76 +++ packages-exp/messaging-exp/src/api.ts | 94 +++ .../src/controllers/sw-controller.test.ts | 604 +++++++++++++++++ .../src/controllers/sw-controller.ts | 408 +++++++++++ .../src/controllers/window-controller.test.ts | 639 ++++++++++++++++++ .../src/controllers/window-controller.ts | 301 +++++++++ .../messaging-exp/src/core/api.test.ts | 217 ++++++ packages-exp/messaging-exp/src/core/api.ts | 186 +++++ .../src/core/token-management.test.ts | 296 ++++++++ .../src/core/token-management.ts | 184 +++++ .../helpers/array-base64-translator.test.ts | 84 +++ .../src/helpers/array-base64-translator.ts | 37 + .../src/helpers/externalizePayload.test.ts | 106 +++ .../src/helpers/externalizePayload.ts | 94 +++ .../src/helpers/extract-app-config.test.ts | 82 +++ .../src/helpers/extract-app-config.ts | 61 ++ .../src/helpers/idb-manager.test.ts | 124 ++++ .../messaging-exp/src/helpers/idb-manager.ts | 106 +++ .../src/helpers/is-console-message.ts | 24 + .../messaging-exp/src/helpers/isSupported.ts | 57 ++ .../src/helpers/migrate-old-database.test.ts | 204 ++++++ .../src/helpers/migrate-old-database.ts | 193 ++++++ .../messaging-exp/src/helpers/sleep.test.ts | 39 ++ .../messaging-exp/src/helpers/sleep.ts | 23 + packages-exp/messaging-exp/src/index.ts | 74 ++ .../src/interfaces/app-config.ts | 25 + .../src/interfaces/internal-dependencies.ts | 29 + .../interfaces/internal-message-payload.ts | 64 ++ .../src/interfaces/token-details.ts | 34 + .../src/testing/compare-headers.test.ts | 46 ++ .../src/testing/compare-headers.ts | 40 ++ .../testing/fakes/firebase-dependencies.ts | 80 +++ .../src/testing/fakes/service-worker.ts | 211 ++++++ .../src/testing/fakes/token-details.ts | 36 + .../messaging-exp/src/testing/setup.ts | 33 + .../messaging-exp/src/testing/sinon-types.ts | 30 + .../messaging-exp/src/util/constants.ts | 34 + packages-exp/messaging-exp/src/util/errors.ts | 93 +++ .../messaging-exp/src/util/sw-types.ts | 114 ++++ packages-exp/messaging-exp/tsconfig.json | 15 + packages-exp/messaging-types-exp/README.md | 3 + packages-exp/messaging-types-exp/index.d.ts | 94 +++ packages-exp/messaging-types-exp/package.json | 29 + .../messaging-types-exp/tsconfig.json | 9 + 49 files changed, 5461 insertions(+) create mode 100644 packages-exp/messaging-exp/.eslintrc.js create mode 100644 packages-exp/messaging-exp/.npmignore create mode 100644 packages-exp/messaging-exp/README.md create mode 100644 packages-exp/messaging-exp/karma.conf.js create mode 100644 packages-exp/messaging-exp/package.json create mode 100644 packages-exp/messaging-exp/rollup.config.js create mode 100644 packages-exp/messaging-exp/src/api.ts create mode 100644 packages-exp/messaging-exp/src/controllers/sw-controller.test.ts create mode 100644 packages-exp/messaging-exp/src/controllers/sw-controller.ts create mode 100644 packages-exp/messaging-exp/src/controllers/window-controller.test.ts create mode 100644 packages-exp/messaging-exp/src/controllers/window-controller.ts create mode 100644 packages-exp/messaging-exp/src/core/api.test.ts create mode 100644 packages-exp/messaging-exp/src/core/api.ts create mode 100644 packages-exp/messaging-exp/src/core/token-management.test.ts create mode 100644 packages-exp/messaging-exp/src/core/token-management.ts create mode 100644 packages-exp/messaging-exp/src/helpers/array-base64-translator.test.ts create mode 100644 packages-exp/messaging-exp/src/helpers/array-base64-translator.ts create mode 100644 packages-exp/messaging-exp/src/helpers/externalizePayload.test.ts create mode 100644 packages-exp/messaging-exp/src/helpers/externalizePayload.ts create mode 100644 packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts create mode 100644 packages-exp/messaging-exp/src/helpers/extract-app-config.ts create mode 100644 packages-exp/messaging-exp/src/helpers/idb-manager.test.ts create mode 100644 packages-exp/messaging-exp/src/helpers/idb-manager.ts create mode 100644 packages-exp/messaging-exp/src/helpers/is-console-message.ts create mode 100644 packages-exp/messaging-exp/src/helpers/isSupported.ts create mode 100644 packages-exp/messaging-exp/src/helpers/migrate-old-database.test.ts create mode 100644 packages-exp/messaging-exp/src/helpers/migrate-old-database.ts create mode 100644 packages-exp/messaging-exp/src/helpers/sleep.test.ts create mode 100644 packages-exp/messaging-exp/src/helpers/sleep.ts create mode 100644 packages-exp/messaging-exp/src/index.ts create mode 100644 packages-exp/messaging-exp/src/interfaces/app-config.ts create mode 100644 packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts create mode 100644 packages-exp/messaging-exp/src/interfaces/internal-message-payload.ts create mode 100644 packages-exp/messaging-exp/src/interfaces/token-details.ts create mode 100644 packages-exp/messaging-exp/src/testing/compare-headers.test.ts create mode 100644 packages-exp/messaging-exp/src/testing/compare-headers.ts create mode 100644 packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts create mode 100644 packages-exp/messaging-exp/src/testing/fakes/service-worker.ts create mode 100644 packages-exp/messaging-exp/src/testing/fakes/token-details.ts create mode 100644 packages-exp/messaging-exp/src/testing/setup.ts create mode 100644 packages-exp/messaging-exp/src/testing/sinon-types.ts create mode 100644 packages-exp/messaging-exp/src/util/constants.ts create mode 100644 packages-exp/messaging-exp/src/util/errors.ts create mode 100644 packages-exp/messaging-exp/src/util/sw-types.ts create mode 100644 packages-exp/messaging-exp/tsconfig.json create mode 100644 packages-exp/messaging-types-exp/README.md create mode 100644 packages-exp/messaging-types-exp/index.d.ts create mode 100644 packages-exp/messaging-types-exp/package.json create mode 100644 packages-exp/messaging-types-exp/tsconfig.json diff --git a/packages-exp/messaging-exp/.eslintrc.js b/packages-exp/messaging-exp/.eslintrc.js new file mode 100644 index 00000000000..ca80aa0f69a --- /dev/null +++ b/packages-exp/messaging-exp/.eslintrc.js @@ -0,0 +1,26 @@ +/** + * @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. + */ + +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.json', + // to make vscode-eslint work with monorepo + // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 + tsconfigRootDir: __dirname + } +}; diff --git a/packages-exp/messaging-exp/.npmignore b/packages-exp/messaging-exp/.npmignore new file mode 100644 index 00000000000..682c8f74a52 --- /dev/null +++ b/packages-exp/messaging-exp/.npmignore @@ -0,0 +1,9 @@ +# Directories not needed by end users +/src +test + +# Files not needed by end users +gulpfile.js +index.ts +karma.conf.js +tsconfig.json \ No newline at end of file diff --git a/packages-exp/messaging-exp/README.md b/packages-exp/messaging-exp/README.md new file mode 100644 index 00000000000..8f3fd52738a --- /dev/null +++ b/packages-exp/messaging-exp/README.md @@ -0,0 +1,5 @@ +# @firebase/messaging + +This is the Firebase Cloud Messaging component of the Firebase JS SDK. + +**This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.** diff --git a/packages-exp/messaging-exp/karma.conf.js b/packages-exp/messaging-exp/karma.conf.js new file mode 100644 index 00000000000..c9bc6b770c9 --- /dev/null +++ b/packages-exp/messaging-exp/karma.conf.js @@ -0,0 +1,32 @@ +/** + * @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. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = [`src/**/*.test.ts`]; + +module.exports = function (config) { + const karmaConfig = { + ...karmaBase, + files, + frameworks: ['mocha'] + }; + + config.set(karmaConfig); +}; + +module.exports.files = files; diff --git a/packages-exp/messaging-exp/package.json b/packages-exp/messaging-exp/package.json new file mode 100644 index 00000000000..bf6ead9bc1c --- /dev/null +++ b/packages-exp/messaging-exp/package.json @@ -0,0 +1,57 @@ +{ + "name": "@firebase/messaging-exp", + "private": true, + "version": "0.0.800", + "description": "", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c", + "build:deps": "lerna run --scope @firebase/'{app,messaging}' --include-dependencies build", + "dev": "rollup -c -w", + "test": "run-p test:karma type-check lint ", + "test:integration": "run-p test:karma type-check lint && cd ../../integration/messaging && npm run-script test", + "test:ci": "node ../../scripts/run_tests_in_ci.js", + "test:karma": "karma start --single-run", + "test:debug": "karma start --browsers=Chrome --auto-watch", + "prepare": "yarn build", + "type-check": "tsc --noEmit" + }, + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-exp": "0.x", + "@firebase/app-types-exp": "0.x" + }, + "dependencies": { + "@firebase/component": "0.1.19", + "@firebase/installations-exp": "0.0.800", + "@firebase/messaging-types-exp": "0.0.800", + "@firebase/util": "0.3.2", + "eslint": "^7.3.1", + "idb": "3.0.2", + "npm-run-all": "^4.1.5", + "tslib": "^1.11.1" + }, + "devDependencies": { + "rollup": "2.7.6", + "rollup-plugin-typescript2": "0.27.0", + "ts-essentials": "^6.0.7", + "typescript": "3.8.3" + }, + "repository": { + "directory": "packages/messaging", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "dist/index.d.ts" +} diff --git a/packages-exp/messaging-exp/rollup.config.js b/packages-exp/messaging-exp/rollup.config.js new file mode 100644 index 00000000000..693cea56ae1 --- /dev/null +++ b/packages-exp/messaging-exp/rollup.config.js @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2018 Google Inc. + * + * 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 json from 'rollup-plugin-json'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import pkg from './package.json'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }), + json() +]; + +const es5Builds = [ + { + input: 'src/index.ts', + output: [ + { file: pkg.main, format: 'cjs', sourcemap: true }, + { file: pkg.module, format: 'es', sourcemap: true } + ], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }), + json({ preferConst: true }) +]; + +const es2017Builds = [ + { + input: 'src/index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/messaging-exp/src/api.ts b/packages-exp/messaging-exp/src/api.ts new file mode 100644 index 00000000000..9eaeaf3f014 --- /dev/null +++ b/packages-exp/messaging-exp/src/api.ts @@ -0,0 +1,94 @@ +/** + * @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 { ComponentContainer, Provider } from '@firebase/component'; +import { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; +import { + FirebaseMessaging, + MessagePayload +} from '@firebase/messaging-types-exp'; +import { NextFn, Observer, Unsubscribe } from '@firebase/util'; + +import { FirebaseInternalDependencies } from './interfaces/internal-dependencies'; +import { SwController } from './controllers/sw-controller'; +import { WindowController } from './controllers/window-controller'; +import { _getProvider } from '@firebase/app-exp'; +import { extractAppConfig } from './helpers/extract-app-config'; + +export function getMessaging(app: FirebaseApp): FirebaseMessaging { + const messagingProvider: Provider<'messaging'> = _getProvider( + app, + 'messaging' + ); + + return messagingProvider.getImmediate(); +} + +export function getToken( + messaging: FirebaseMessaging, + options?: { vapidKey?: string; swReg?: ServiceWorkerRegistration } +): Promise { + return messaging.getToken(options); +} + +export function deleteToken(messaging: FirebaseMessaging): Promise { + return messaging.deleteToken(); +} + +export function onMessage( + messaging: FirebaseMessaging, + nextOrObserver: NextFn | Observer +): Unsubscribe { + return messaging.onMessage(nextOrObserver); +} + +export function onBackgroundMessage( + messaging: FirebaseMessaging, + nextOrObserver: NextFn | Observer +): Unsubscribe { + return messaging.onBackgroundMessage(nextOrObserver); +} + +export class MessagingService implements _FirebaseService { + app!: FirebaseApp; + readonly windowController: WindowController | null = null; + readonly swController: SwController | null = null; + + constructor(container: ComponentContainer) { + const app = container.getProvider('app').getImmediate(); + const appConfig = extractAppConfig(app); + const installations = container.getProvider('installations').getImmediate(); + const analyticsProvider = container.getProvider('analytics-internal'); + + const firebaseDependencies: FirebaseInternalDependencies = { + app, + appConfig, + installations, + analyticsProvider + }; + + if (self && 'ServiceWorkerGlobalScope' in self) { + this.swController = new SwController(firebaseDependencies); + } else { + this.windowController = new WindowController(firebaseDependencies); + } + } + + _delete(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages-exp/messaging-exp/src/controllers/sw-controller.test.ts b/packages-exp/messaging-exp/src/controllers/sw-controller.test.ts new file mode 100644 index 00000000000..27e2643c353 --- /dev/null +++ b/packages-exp/messaging-exp/src/controllers/sw-controller.test.ts @@ -0,0 +1,604 @@ +/** + * @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 '../testing/setup'; + +import * as tokenManagementModule from '../core/token-management'; + +import { BgMessageHandler, SwController } from './sw-controller'; +import { + CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, + CONSOLE_CAMPAIGN_ID, + CONSOLE_CAMPAIGN_NAME, + CONSOLE_CAMPAIGN_TIME, + 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'; + +const LOCAL_HOST = self.location.host; +const TEST_LINK = 'https://' + LOCAL_HOST + '/test-link.org'; +const TEST_CLICK_ACTION = 'https://' + LOCAL_HOST + '/test-click-action.org'; + +// Add fake SW types. +declare const self: Window & Writable; + +// internal message payload (parsed directly from the push event) that contains and only contains +// notification payload. +const DISPLAY_MESSAGE: MessagePayloadInternal = { + notification: { + title: 'title', + body: 'body' + }, + fcmOptions: { + link: TEST_LINK + }, + from: 'from', + // eslint-disable-next-line camelcase + collapse_key: 'collapse' +}; + +// 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', () => { + let addEventListenerStub: Stub; + // eslint-disable-next-line @typescript-eslint/ban-types + let eventListenerMap: Map; + let swController: SwController; + let firebaseDependencies: FirebaseInternalDependencies; + let getTokenStub: Stub; + let deleteTokenStub: Stub; + + beforeEach(() => { + mockServiceWorker(); + + 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. + addEventListenerStub = stub(self, 'addEventListener').callsFake( + (type, listener) => { + eventListenerMap.set(type, listener); + } + ); + eventListenerMap = new Map(); + + getTokenStub = stub(tokenManagementModule, 'getToken').resolves( + 'token-value' + ); + deleteTokenStub = stub(tokenManagementModule, 'deleteToken').resolves(true); + + firebaseDependencies = getFakeFirebaseDependencies(); + swController = new SwController(firebaseDependencies); + }); + + afterEach(() => { + restoreServiceWorker(); + }); + + it('has app', () => { + expect(swController.app).to.equal(firebaseDependencies.app); + }); + + it('sets event listeners on initialization', () => { + expect(addEventListenerStub).to.have.been.calledThrice; + expect(addEventListenerStub).to.have.been.calledWith('push'); + expect(addEventListenerStub).to.have.been.calledWith( + 'pushsubscriptionchange' + ); + expect(addEventListenerStub).to.have.been.calledWith('notificationclick'); + }); + + it('throws when window-only methods are called', () => { + expect(() => swController.requestPermission()).to.throw( + 'messaging/only-available-in-window' + ); + expect(() => swController.useServiceWorker()).to.throw( + 'messaging/only-available-in-window' + ); + expect(() => swController.onMessage()).to.throw( + 'messaging/only-available-in-window' + ); + expect(() => swController.onTokenRefresh()).to.throw( + 'messaging/only-available-in-window' + ); + }); + + describe('getToken', () => { + it('calls getToken with the set VAPID key', async () => { + swController.usePublicVapidKey('use-vapid-key'); + await swController.getToken(); + + expect(getTokenStub).to.have.been.calledWith( + firebaseDependencies, + self.registration, + 'use-vapid-key' + ); + }); + + it('calls getToken with the current VAPID key if it is not set', async () => { + const tokenDetails = getFakeTokenDetails(); + await dbSet(firebaseDependencies, tokenDetails); + + await swController.getToken(); + + expect(getTokenStub).to.have.been.calledWith( + firebaseDependencies, + self.registration, + 'dmFwaWQta2V5LXZhbHVl' + ); + }); + + it('calls getToken with the default VAPID key if there is no token in db', async () => { + await swController.getToken(); + + expect(getTokenStub).to.have.been.calledWith( + firebaseDependencies, + self.registration, + DEFAULT_VAPID_KEY + ); + }); + }); + + describe('deleteToken', () => { + it('calls deleteToken', async () => { + await swController.deleteToken(); + + expect(deleteTokenStub).to.have.been.calledWith( + firebaseDependencies, + self.registration + ); + }); + }); + + describe('onPush', () => { + it('does nothing if push is not from FCM', async () => { + const showNotificationSpy = spy(self.registration, 'showNotification'); + const matchAllSpy = spy(self.clients, 'matchAll'); + + await callEventListener(makeEvent('push', {})); + + await callEventListener( + makeEvent('push', { + data: {} + }) + ); + + expect(showNotificationSpy).not.to.have.been.called; + expect(matchAllSpy).not.to.have.been.called; + }); + + it('sends a message to window clients if a window client is visible', async () => { + const client: Writable = (await self.clients.openWindow( + 'https://example.org' + ))!; + client.visibilityState = 'visible'; + const postMessageSpy = spy(client, 'postMessage'); + + await callEventListener( + makeEvent('push', { + data: { + json: () => DISPLAY_MESSAGE + } + }) + ); + + const expectedMessage: MessagePayloadInternal = { + ...DISPLAY_MESSAGE, + messageType: MessageType.PUSH_RECEIVED + }; + expect(postMessageSpy).to.have.been.calledOnceWith(expectedMessage); + }); + + it('does not send a message to window clients if window clients are hidden', async () => { + const client = (await self.clients.openWindow('https://example.org'))!; + const postMessageSpy = spy(client, 'postMessage'); + const showNotificationSpy = spy(self.registration, 'showNotification'); + + await callEventListener( + makeEvent('push', { + data: { + json: () => DISPLAY_MESSAGE + } + }) + ); + + expect(postMessageSpy).not.to.have.been.called; + expect(showNotificationSpy).to.have.been.calledWith('title', { + ...DISPLAY_MESSAGE.notification, + data: { + [FCM_MSG]: DISPLAY_MESSAGE + } + }); + }); + + it('displays a notification if a window client does not exist', async () => { + const showNotificationSpy = spy(self.registration, 'showNotification'); + + await callEventListener( + makeEvent('push', { + data: { + json: () => DISPLAY_MESSAGE + } + }) + ); + + expect(showNotificationSpy).to.have.been.calledWith('title', { + ...DISPLAY_MESSAGE.notification, + data: { + ...DISPLAY_MESSAGE.notification!.data, + [FCM_MSG]: DISPLAY_MESSAGE + } + }); + }); + + it('calls bgMessageHandler if message is not a notification', async () => { + const bgMessageHandlerSpy = spy(); + swController.setBackgroundMessageHandler(bgMessageHandlerSpy); + + await callEventListener( + makeEvent('push', { + data: { + json: () => DATA_MESSAGE + } + }) + ); + + 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 + if (!Notification.maxActions) { + return; + } + stub(Notification, 'maxActions').value(1); + + const warnStub = stub(console, 'warn'); + + await callEventListener( + makeEvent('push', { + data: { + json: () => ({ + notification: { + ...DISPLAY_MESSAGE, + actions: [ + { action: 'like', title: 'Like' }, + { action: 'favorite', title: 'Favorite' } + ] + } + }) + } + }) + ); + + expect(warnStub).to.have.been.calledOnceWith( + 'This browser only supports 1 actions. The remaining actions will not be displayed.' + ); + }); + }); + + describe('setBackgroundMessageHandler', () => { + it('throws on invalid input', () => { + expect(() => + swController.setBackgroundMessageHandler( + (null as unknown) as BgMessageHandler + ) + ).to.throw('messaging/invalid-bg-handler'); + }); + }); + + describe('usePublicVapidKey', () => { + it('throws on invalid input', () => { + expect(() => + swController.usePublicVapidKey((null as unknown) as string) + ).to.throw('messaging/invalid-vapid-key'); + + expect(() => swController.usePublicVapidKey('')).to.throw( + 'messaging/invalid-vapid-key' + ); + }); + + it('throws if called twice', () => { + swController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl'); + expect(() => + swController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl') + ).to.throw('messaging/use-vapid-key-after-get-token'); + }); + + it('throws if called after getToken', async () => { + await swController.getToken(); + + expect(() => + swController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl') + ).to.throw('messaging/use-vapid-key-after-get-token'); + }); + }); + + describe('onNotificationClick', () => { + let NOTIFICATION_CLICK_PAYLOAD: DeepPartial; + + beforeEach(() => { + NOTIFICATION_CLICK_PAYLOAD = { + notification: new Notification('title', { + ...DISPLAY_MESSAGE.notification, + data: { + ...DISPLAY_MESSAGE.notification!.data, + [FCM_MSG]: DISPLAY_MESSAGE + } + }) + }; + }); + + it('does nothing if notification is not from FCM', async () => { + delete NOTIFICATION_CLICK_PAYLOAD.notification!.data![FCM_MSG]; + + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + const stopImmediatePropagationSpy = spy( + event, + 'stopImmediatePropagation' + ); + + await callEventListener(event); + + expect(stopImmediatePropagationSpy).not.to.have.been.called; + }); + + it('does nothing if an action button was clicked', async () => { + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + event.action = 'actionName'; + const stopImmediatePropagationSpy = spy( + event, + 'stopImmediatePropagation' + ); + + await callEventListener(event); + + expect(stopImmediatePropagationSpy).not.to.have.been.called; + }); + + it('calls stopImmediatePropagation and notification.close', async () => { + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + const stopImmediatePropagationSpy = spy( + event, + 'stopImmediatePropagation' + ); + const notificationCloseSpy = spy(event.notification, 'close'); + + await callEventListener(event); + + expect(stopImmediatePropagationSpy).to.have.been.called; + expect(notificationCloseSpy).to.have.been.called; + }); + + it('does not redirect if there is no link', async () => { + // Remove link. + delete NOTIFICATION_CLICK_PAYLOAD.notification!.data![FCM_MSG].fcmOptions; + + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + const stopImmediatePropagationSpy = spy( + event, + 'stopImmediatePropagation' + ); + const notificationCloseSpy = spy(event.notification, 'close'); + const matchAllSpy = spy(self.clients, 'matchAll'); + + await callEventListener(event); + + expect(stopImmediatePropagationSpy).to.have.been.called; + expect(notificationCloseSpy).to.have.been.called; + expect(matchAllSpy).not.to.have.been.called; + }); + + it('does not redirect if link is not from origin', async () => { + // Remove link. + NOTIFICATION_CLICK_PAYLOAD.notification!.data![FCM_MSG].fcmOptions.link = + 'https://www.youtube.com'; + + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + const stopImmediatePropagationSpy = spy( + event, + 'stopImmediatePropagation' + ); + const notificationCloseSpy = spy(event.notification, 'close'); + const matchAllSpy = spy(self.clients, 'matchAll'); + + await callEventListener(event); + + expect(stopImmediatePropagationSpy).to.have.been.called; + expect(notificationCloseSpy).to.have.been.called; + expect(matchAllSpy).not.to.have.been.called; + }); + + it('focuses on and sends the message to an open WindowClient', async () => { + const client: Writable = (await self.clients.openWindow( + TEST_LINK + ))!; + const focusSpy = spy(client, 'focus'); + const matchAllSpy = spy(self.clients, 'matchAll'); + const openWindowSpy = spy(self.clients, 'openWindow'); + const postMessageSpy = spy(client, 'postMessage'); + + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + + await callEventListener(event); + + expect(matchAllSpy).to.have.been.called; + expect(openWindowSpy).not.to.have.been.called; + expect(focusSpy).to.have.been.called; + expect(postMessageSpy).to.have.been.calledWith({ + ...DISPLAY_MESSAGE, + messageType: MessageType.NOTIFICATION_CLICKED + }); + }); + + it("opens a new client if there isn't one already open", async () => { + const matchAllSpy = spy(self.clients, 'matchAll'); + const openWindowSpy = spy(self.clients, 'openWindow'); + + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + + await callEventListener(event); + + expect(matchAllSpy).to.have.been.called; + expect(openWindowSpy).to.have.been.calledWith(TEST_LINK); + }); + + it('works with click_action', async () => { + // Replace link with the deprecated click_action. + delete NOTIFICATION_CLICK_PAYLOAD.notification!.data![FCM_MSG].fcmOptions; + NOTIFICATION_CLICK_PAYLOAD.notification!.data![ + FCM_MSG + ].notification.click_action = TEST_CLICK_ACTION; // eslint-disable-line camelcase + + const matchAllSpy = spy(self.clients, 'matchAll'); + const openWindowSpy = spy(self.clients, 'openWindow'); + + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + + await callEventListener(event); + + expect(matchAllSpy).to.have.been.called; + expect(openWindowSpy).to.have.been.calledWith(TEST_CLICK_ACTION); + }); + + it('redirects to origin if message was sent from the FN Console', async () => { + // Remove link. + delete NOTIFICATION_CLICK_PAYLOAD.notification!.data![FCM_MSG].fcmOptions; + // Add FN data. + NOTIFICATION_CLICK_PAYLOAD.notification!.data![FCM_MSG].data = { + [CONSOLE_CAMPAIGN_ID]: '123456', + [CONSOLE_CAMPAIGN_NAME]: 'Campaign Name', + [CONSOLE_CAMPAIGN_TIME]: '1234567890', + [CONSOLE_CAMPAIGN_ANALYTICS_ENABLED]: '1' + }; + + const matchAllSpy = spy(self.clients, 'matchAll'); + const openWindowSpy = spy(self.clients, 'openWindow'); + + const event = makeEvent('notificationclick', NOTIFICATION_CLICK_PAYLOAD); + + await callEventListener(event); + + expect(matchAllSpy).to.have.been.called; + expect(openWindowSpy).to.have.been.calledWith(self.location.origin); + }); + }); + + describe('onSubChange', () => { + it('calls deleteToken if there is no new subscription', async () => { + const event = makeEvent('pushsubscriptionchange', { + oldSubscription: new FakePushSubscription(), + newSubscription: undefined + }); + + await callEventListener(event); + + expect(deleteTokenStub).to.have.been.called; + expect(getTokenStub).not.to.have.been.called; + }); + + it('calls deleteToken and getToken if subscription changed', async () => { + const event = makeEvent('pushsubscriptionchange', { + oldSubscription: new FakePushSubscription(), + newSubscription: new FakePushSubscription() + }); + + await callEventListener(event); + + expect(deleteTokenStub).to.have.been.called; + expect(getTokenStub).to.have.been.called; + }); + }); + + async function callEventListener( + event: ValueOf + ): Promise { + const listener = eventListenerMap.get(event.type); + if (!listener) { + throw new Error(`Event listener for ${event.type} was not defined.`); + } + + const waitUntil = spy(event, 'waitUntil'); + listener(event); + await waitUntil.getCall(0).args[0]; + } +}); + +/** Makes fake push events. */ +function makeEvent( + type: K, + data: DeepPartial +): Writable { + const event = new FakeEvent(type); + Object.assign(event, data); + return (event as unknown) as ServiceWorkerGlobalScopeEventMap[K]; +} diff --git a/packages-exp/messaging-exp/src/controllers/sw-controller.ts b/packages-exp/messaging-exp/src/controllers/sw-controller.ts new file mode 100644 index 00000000000..426d631cb37 --- /dev/null +++ b/packages-exp/messaging-exp/src/controllers/sw-controller.ts @@ -0,0 +1,408 @@ +/** + * @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 { DEFAULT_VAPID_KEY, FCM_MSG, TAG } from '../util/constants'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; +import { + FirebaseMessaging, + MessagePayload +} from '@firebase/messaging-types-exp'; +import { + MessagePayloadInternal, + MessageType, + NotificationPayloadInternal +} from '../interfaces/internal-message-payload'; +import { NextFn, Observer, Unsubscribe } from '@firebase/util'; +import { deleteToken, getToken } from '../core/token-management'; + +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +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; + +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 + | NextFn + | Observer = null; + + constructor( + private readonly firebaseDependencies: FirebaseInternalDependencies + ) { + self.addEventListener('push', e => { + e.waitUntil(this.onPush(e)); + }); + self.addEventListener('pushsubscriptionchange', e => { + e.waitUntil(this.onSubChange(e)); + }); + self.addEventListener('notificationclick', e => { + e.waitUntil(this.onNotificationClick(e)); + }); + } + + _delete(): Promise { + throw new Error('Method not implemented.'); + } + + get app(): FirebaseApp { + return this.firebaseDependencies.app; + } + + /** + * @deprecated. Use onBackgroundMessage(nextOrObserver: NextFn | Observer): + * Unsubscribe instead. + * + * Calling setBackgroundMessageHandler will opt in to some specific behaviors. + * + * 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); + } + + this.bgMessageHandler = callback; + } + + 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. + const tokenDetails = await dbGet(this.firebaseDependencies); + this.vapidKey = + tokenDetails?.subscriptionOptions?.vapidKey ?? DEFAULT_VAPID_KEY; + } + + return getToken( + this.firebaseDependencies, + self.registration, + this.vapidKey + ); + } + + // 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); + } + + requestPermission(): Promise { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); + } + + // TODO: Remove this together with getToken from SW Controller. + usePublicVapidKey(vapidKey: string): void { + if (this.vapidKey !== null) { + throw ERROR_FACTORY.create(ErrorCode.USE_VAPID_KEY_AFTER_GET_TOKEN); + } + + if (typeof vapidKey !== 'string' || vapidKey.length === 0) { + throw ERROR_FACTORY.create(ErrorCode.INVALID_VAPID_KEY); + } + + this.vapidKey = vapidKey; + } + + useServiceWorker(): void { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); + } + + onMessage(): Unsubscribe { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); + } + + onTokenRefresh(): Unsubscribe { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); + } + + /** + * 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. + * + * If there is no notification data in the payload then no notification will be shown. + */ + async onPush(event: PushEvent): Promise { + 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)) { + 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; + } + + if (!!this.bgMessageHandler) { + const payload = externalizePayload(internalPayload); + + if (typeof this.bgMessageHandler === 'function') { + this.bgMessageHandler(payload); + } else { + this.bgMessageHandler.next(payload); + } + } + } + + async onSubChange(event: PushSubscriptionChangeEvent): Promise { + const { newSubscription } = event; + if (!newSubscription) { + // Subscription revoked, delete token + await deleteToken(this.firebaseDependencies, self.registration); + return; + } + + const tokenDetails = await dbGet(this.firebaseDependencies); + await deleteToken(this.firebaseDependencies, self.registration); + await getToken( + this.firebaseDependencies, + self.registration, + tokenDetails?.subscriptionOptions?.vapidKey ?? DEFAULT_VAPID_KEY + ); + } + + async onNotificationClick(event: NotificationEvent): Promise { + const internalPayload: MessagePayloadInternal = + event.notification?.data?.[FCM_MSG]; + + if (!internalPayload) { + return; + } else if (event.action) { + // 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; + } + + // Prevent other listeners from receiving the event + event.stopImmediatePropagation(); + event.notification.close(); + + // Note clicking on a notification with no link set will focus the Chrome's current tab. + const link = getLink(internalPayload); + if (!link) { + return; + } + + // FM should only open/focus links from app's origin. + const url = new URL(link, self.location.href); + const originUrl = new URL(self.location.origin); + + if (url.host !== originUrl.host) { + return; + } + + let client = await getWindowClient(url); + + if (!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. + await sleep(3000); + } else { + client = await client.focus(); + } + + if (!client) { + // Window Client will not be returned if it's for a third party origin. + return; + } + + internalPayload.messageType = MessageType.NOTIFICATION_CLICKED; + internalPayload.isFirebaseMessaging = true; + return client.postMessage(internalPayload); + } +} + +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; + } + + try { + return data.json(); + } catch (err) { + // Not JSON so not an FCM message. + return null; + } +} + +/** + * @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: URL): Promise { + const clientList = await getClientList(); + + for (const client of clientList) { + const clientUrl = new URL(client.url, self.location.href); + + if (url.host === clientUrl.host) { + return client; + } + } + + return null; +} + +/** + * @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. + !client.url.startsWith('chrome-extension://') + ); +} + +function sendMessagePayloadInternalToWindows( + clientList: WindowClient[], + internalPayload: MessagePayloadInternal +): void { + internalPayload.isFirebaseMessaging = true; + internalPayload.messageType = MessageType.PUSH_RECEIVED; + + for (const client of clientList) { + client.postMessage(internalPayload); + } +} + +function getClientList(): Promise { + return self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + // TS doesn't know that "type: 'window'" means it'll return WindowClient[] + }) as Promise; +} + +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( + `This browser only supports ${maxActions} actions. The remaining actions will not be displayed.` + ); + } + + return self.registration.showNotification( + /* title= */ notificationPayloadInternal.title ?? '', + notificationPayloadInternal + ); +} + +function getLink(payload: MessagePayloadInternal): string | null { + // eslint-disable-next-line camelcase + const link = payload.fcmOptions?.link ?? payload.notification?.click_action; + if (link) { + return link; + } + + if (isConsoleMessage(payload.data)) { + // Notification created in the Firebase Console. Redirect to origin. + return self.location.origin; + } else { + return null; + } +} diff --git a/packages-exp/messaging-exp/src/controllers/window-controller.test.ts b/packages-exp/messaging-exp/src/controllers/window-controller.test.ts new file mode 100644 index 00000000000..373b4eb42f7 --- /dev/null +++ b/packages-exp/messaging-exp/src/controllers/window-controller.test.ts @@ -0,0 +1,639 @@ +/** + * @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'; + +import { + CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, + CONSOLE_CAMPAIGN_ID, + CONSOLE_CAMPAIGN_NAME, + CONSOLE_CAMPAIGN_TIME, + DEFAULT_SW_PATH, + DEFAULT_SW_SCOPE, + DEFAULT_VAPID_KEY +} from '../util/constants'; +import { + MessagePayloadInternal, + MessageType +} from '../interfaces/internal-message-payload'; +import { SinonFakeTimers, SinonSpy, spy, stub, useFakeTimers } from 'sinon'; +import { Spy, Stub } from '../testing/sinon-types'; + +import { ErrorCode } from '../util/errors'; +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'; +import { expect } from 'chai'; +import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; + +type MessageEventListener = (event: Event) => Promise; + +const ORIGINAL_SW_REGISTRATION = FakeServiceWorkerRegistration; + +describe('WindowController', () => { + let firebaseDependencies: FirebaseInternalDependencies; + let swRegistration: ServiceWorkerRegistration; + let windowController: WindowController; + + let getTokenStub: Stub; + let deleteTokenStub: Stub; + let registerStub: Stub; + let addEventListenerStub: Stub; + + /** The event listener that WindowController adds to the message event. */ + let messageEventListener: MessageEventListener; + + beforeEach(() => { + // To trick the instanceof check in useServiceWorker. + self.ServiceWorkerRegistration = FakeServiceWorkerRegistration; + + firebaseDependencies = getFakeFirebaseDependencies(); + swRegistration = new FakeServiceWorkerRegistration(); + + stub(Notification, 'permission').value('granted'); + registerStub = stub(navigator.serviceWorker, 'register').resolves( + swRegistration + ); + addEventListenerStub = stub( + navigator.serviceWorker, + 'addEventListener' + ).callsFake((type, listener) => { + expect(type).to.equal('message'); + + if ('handleEvent' in listener) { + messageEventListener = listener.handleEvent as MessageEventListener; + } else { + messageEventListener = listener as MessageEventListener; + } + }); + getTokenStub = stub(tokenManagementModule, 'getToken').resolves('fcmToken'); + deleteTokenStub = stub(tokenManagementModule, 'deleteToken').resolves(true); + + windowController = new WindowController(firebaseDependencies); + }); + + afterEach(() => { + self.ServiceWorkerRegistration = ORIGINAL_SW_REGISTRATION; + }); + + it('has app', () => { + expect(windowController.app).to.equal(firebaseDependencies.app); + }); + + it('adds the message event listener on creation', () => { + expect(addEventListenerStub).to.have.been.called; + }); + + it('throws when service-worker-only methods are called', () => { + expect(() => windowController.setBackgroundMessageHandler()).to.throw( + 'messaging/only-available-in-sw' + ); + }); + + 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'); + + try { + await windowController.getToken(); + throw new Error('should have thrown'); + } catch (err) { + expect(err.code).to.equal(`messaging/${ErrorCode.PERMISSION_BLOCKED}`); + } + }); + + it('asks for permission if permission is default', async () => { + stub(Notification, 'permission').value('default'); + const requestPermissionStub = stub( + Notification, + 'requestPermission' + ).resolves('denied'); + + try { + await windowController.getToken(); + throw new Error('should have thrown'); + } catch (err) { + expect(err.code).to.equal(`messaging/${ErrorCode.PERMISSION_BLOCKED}`); + } + + expect(requestPermissionStub).to.have.been.calledOnce; + }); + + it('registers the default SW', async () => { + await windowController.getToken(); + + expect(registerStub).to.have.been.calledOnceWith(DEFAULT_SW_PATH, { + scope: DEFAULT_SW_SCOPE + }); + }); + + it('throws if there is a failure to get SW registration', async () => { + registerStub.rejects(); + + try { + await windowController.getToken(); + throw new Error('should have thrown'); + } catch (err) { + expect(err.code).to.equal( + `messaging/${ErrorCode.FAILED_DEFAULT_REGISTRATION}` + ); + } + }); + + it('calls tokenManagement.getToken with the default SW and VAPID key', async () => { + await windowController.getToken(); + + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration, + DEFAULT_VAPID_KEY + ); + }); + + it('calls tokenManagement.getToken with the specified SW and VAPID key', async () => { + const differentSwRegistration = new FakeServiceWorkerRegistration(); + differentSwRegistration.scope = '/different-scope'; + + windowController.usePublicVapidKey('newVapidKey'); + windowController.useServiceWorker(differentSwRegistration); + await windowController.getToken(); + + expect(getTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + differentSwRegistration, + 'newVapidKey' + ); + }); + }); + + describe('deleteToken', () => { + it('calls tokenManagement.deleteToken with the default SW', async () => { + await windowController.deleteToken(); + + expect(deleteTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + swRegistration + ); + }); + + it('calls tokenManagement.deleteToken with the specified SW', async () => { + const differentSwRegistration = new FakeServiceWorkerRegistration(); + differentSwRegistration.scope = '/different-scope'; + + windowController.useServiceWorker(differentSwRegistration); + await windowController.deleteToken(); + + expect(deleteTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + differentSwRegistration + ); + }); + }); + + describe('requestPermission', () => { + it('should resolve if the permission is already granted', async () => { + stub(Notification, 'permission').value('granted'); + + return windowController.requestPermission(); + }); + + it('should reject if requestPermission resolves with "denied"', async () => { + stub(Notification, 'permission').value('default'); + stub(Notification, 'requestPermission').resolves('denied'); + + try { + await windowController.requestPermission(); + throw new Error('Expected an error.'); + } catch (err) { + expect(err.code).to.equal('messaging/permission-blocked'); + } + }); + + it('should reject if requestPermission resolves with "default"', async () => { + stub(Notification, 'permission').value('default'); + stub(Notification, 'requestPermission').resolves('default'); + + try { + await windowController.requestPermission(); + throw new Error('Expected an error.'); + } catch (err) { + expect(err.code).to.equal('messaging/permission-default'); + } + }); + + it('should resolve if requestPermission resolves with "granted"', async () => { + stub(Notification, 'permission').value('default'); + stub(Notification, 'requestPermission').resolves('granted'); + + return windowController.requestPermission(); + }); + }); + + describe('onMessage', () => { + it('sets the onMessage callback', async () => { + const onMessageCallback = spy(); + windowController.onMessage(onMessageCallback); + + 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: internalPayload }) + ); + + expect(onMessageCallback).to.have.been.called; + }); + + it('works with an observer', async () => { + const onMessageCallback = spy(); + windowController.onMessage({ + next: onMessageCallback, + error: () => {}, + complete: () => {} + }); + + 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: internalPayload }) + ); + + expect(onMessageCallback).to.have.been.called; + }); + + it('returns a function that clears the onMessage callback', async () => { + const onMessageCallback = spy(); + const unsubscribe = windowController.onMessage(onMessageCallback); + unsubscribe(); + + 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: internalPayload }) + ); + + expect(onMessageCallback).not.to.have.been.called; + }); + }); + + describe('usePublicVapidKey', () => { + it('throws on invalid input', () => { + expect(() => + windowController.usePublicVapidKey((null as unknown) as string) + ).to.throw('messaging/invalid-vapid-key'); + + expect(() => windowController.usePublicVapidKey('')).to.throw( + 'messaging/invalid-vapid-key' + ); + }); + + it('throws if called twice', () => { + windowController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl'); + expect(() => + windowController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl') + ).to.throw('messaging/use-vapid-key-after-get-token'); + }); + + it('throws if called after getToken', async () => { + await windowController.getToken(); + + expect(() => + windowController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl') + ).to.throw('messaging/use-vapid-key-after-get-token'); + }); + }); + + describe('useServiceWorker', () => { + it('throws on invalid input', () => { + expect(() => + windowController.useServiceWorker( + ({} as unknown) as ServiceWorkerRegistration + ) + ).to.throw('messaging/invalid-sw-registration'); + }); + + it('throws if called twice', () => { + windowController.useServiceWorker(swRegistration); + expect(() => windowController.useServiceWorker(swRegistration)).to.throw( + 'messaging/use-sw-after-get-token' + ); + }); + + it('throws if called after getToken', async () => { + await windowController.getToken(); + + expect(() => windowController.useServiceWorker(swRegistration)).to.throw( + 'messaging/use-sw-after-get-token' + ); + }); + }); + + describe('SW message event handler', () => { + let clock: SinonFakeTimers; + let onMessageSpy: SinonSpy; + let logEventSpy: Spy; + + beforeEach(() => { + clock = useFakeTimers(); + + const analytics = firebaseDependencies.analyticsProvider.getImmediate(); + logEventSpy = spy(analytics, 'logEvent'); + + onMessageSpy = spy(); + windowController.onMessage(onMessageSpy); + }); + + it('does nothing when non-fcm message is passed in', async () => { + await messageEventListener( + new MessageEvent('message', { data: 'non-fcm-message' }) + ); + + expect(onMessageSpy).not.to.have.been.called; + expect(logEventSpy).not.to.have.been.called; + }); + + it('calls onMessage callback when it receives a PUSH_RECEIVED message', async () => { + 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: internalPayload }) + ); + + 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 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: internalPayload }) + ); + + expect(onMessageSpy).not.to.have.been.called; + expect(logEventSpy).not.to.have.been.called; + }); + + it('calls analytics.logEvent if the message has analytics enabled for PUSH_RECEIVED', async () => { + 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: internalPayload }) + ); + + 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: '123456', + message_name: 'Campaign Name', + message_time: '1234567890', + message_device_time: clock.now + /* eslint-enable camelcase */ + } + ); + }); + + it('calls analytics.logEvent if the message has analytics enabled for NOTIFICATION_CLICKED', async () => { + 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: internalPayload }) + ); + + expect(onMessageSpy).not.to.have.been.called; + expect(logEventSpy).to.have.been.calledOnceWith('notification_open', { + /* eslint-disable camelcase */ + message_id: '123456', + message_name: 'Campaign Name', + message_time: '1234567890', + message_device_time: clock.now + /* eslint-enable camelcase */ + }); + }); + }); + + describe('onTokenRefresh', () => { + it('returns an unsubscribe function that does nothing', () => { + const unsubscribe = windowController.onTokenRefresh(); + expect(unsubscribe).not.to.throw; + }); + }); +}); diff --git a/packages-exp/messaging-exp/src/controllers/window-controller.ts b/packages-exp/messaging-exp/src/controllers/window-controller.ts new file mode 100644 index 00000000000..8ae001b1b9f --- /dev/null +++ b/packages-exp/messaging-exp/src/controllers/window-controller.ts @@ -0,0 +1,301 @@ +/** + * @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 { + CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, + CONSOLE_CAMPAIGN_ID, + CONSOLE_CAMPAIGN_NAME, + CONSOLE_CAMPAIGN_TIME, + DEFAULT_SW_PATH, + 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 { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; +import { NextFn, Observer, Unsubscribe } from '@firebase/util'; +import { deleteToken, getToken } from '../core/token-management'; + +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { FirebaseMessaging } from '@firebase/messaging-types-exp'; +import { isConsoleMessage } from '../helpers/is-console-message'; + +export class WindowController implements FirebaseMessaging, _FirebaseService { + private vapidKey: string | null = null; + private swRegistration?: ServiceWorkerRegistration; + private onMessageCallback: NextFn | Observer | null = null; + + constructor( + private readonly firebaseDependencies: FirebaseInternalDependencies + ) { + navigator.serviceWorker.addEventListener('message', e => + this.messageEventListener(e) + ); + } + + _delete(): Promise { + throw new Error('Method not implemented.'); + } + + get app(): FirebaseApp { + return this.firebaseDependencies.app; + } + + private async messageEventListener(event: MessageEvent): Promise { + const internalPayload = event.data as MessagePayloadInternal; + + if (!internalPayload.isFirebaseMessaging) { + return; + } + + // 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; + } + + async getToken(options?: { + vapidKey?: string; + serviceWorkerRegistration?: ServiceWorkerRegistration; + }): Promise { + if (Notification.permission === 'default') { + await Notification.requestPermission(); + } + + if (Notification.permission !== 'granted') { + throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); + } + + 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 { + if (!this.swRegistration) { + await this.registerDefaultSw(); + } + + return deleteToken(this.firebaseDependencies, this.swRegistration!); + } + + /** + * Request permission if it is not currently granted. + * + * @return Resolves if the permission was granted, rejects otherwise. + * + * @deprecated Use Notification.requestPermission() instead. + * https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission + */ + async requestPermission(): Promise { + if (Notification.permission === 'granted') { + return; + } + + const permissionResult = await Notification.requestPermission(); + if (permissionResult === 'granted') { + return; + } else if (permissionResult === 'denied') { + throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); + } else { + throw ERROR_FACTORY.create(ErrorCode.PERMISSION_DEFAULT); + } + } + + /** + * @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); + } + + if (typeof vapidKey !== 'string' || vapidKey.length === 0) { + throw ERROR_FACTORY.create(ErrorCode.INVALID_VAPID_KEY); + } + + 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); + } + + if (this.swRegistration) { + throw ERROR_FACTORY.create(ErrorCode.USE_SW_AFTER_GET_TOKEN); + } + + this.swRegistration = swRegistration; + } + + /** + * @param nextOrObserver An observer object or a function triggered on message. + * + * @return The unsubscribe function for the observer. + */ + onMessage(nextOrObserver: NextFn | Observer): Unsubscribe { + this.onMessageCallback = nextOrObserver; + + return () => { + this.onMessageCallback = null; + }; + } + + setBackgroundMessageHandler(): void { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_SW); + } + + onBackgroundMessage(): Unsubscribe { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_SW); + } + + /** + * @deprecated No-op. It was initially designed with token rotation requests from server in mind. + * However, the plan to implement such feature was abandoned. + */ + onTokenRefresh(): Unsubscribe { + return () => {}; + } + + private async logEvent( + messageType: MessageType, + data: ConsoleMessageData + ): Promise { + const eventType = getEventType(messageType); + const analytics = await this.firebaseDependencies.analyticsProvider.get(); + analytics.logEvent(eventType, { + /* eslint-disable camelcase */ + message_id: data[CONSOLE_CAMPAIGN_ID], + message_name: data[CONSOLE_CAMPAIGN_NAME], + message_time: data[CONSOLE_CAMPAIGN_TIME], + message_device_time: Math.floor(Date.now() / 1000) + /* eslint-enable camelcase */ + }); + } +} + +function getEventType(messageType: MessageType): string { + switch (messageType) { + case MessageType.NOTIFICATION_CLICKED: + return 'notification_open'; + case MessageType.PUSH_RECEIVED: + return 'notification_foreground'; + default: + throw new Error(); + } +} + +function stripInternalFields( + internalPayload: MessagePayloadInternal +): MessagePayloadInternal { + delete internalPayload.messageType; + delete internalPayload.isFirebaseMessaging; + return internalPayload; +} diff --git a/packages-exp/messaging-exp/src/core/api.test.ts b/packages-exp/messaging-exp/src/core/api.test.ts new file mode 100644 index 00000000000..da3368191c8 --- /dev/null +++ b/packages-exp/messaging-exp/src/core/api.test.ts @@ -0,0 +1,217 @@ +/** + * @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 { + ApiRequestBody, + requestDeleteToken, + requestGetToken, + requestUpdateToken +} from './api'; + +import { ENDPOINT } from '../util/constants'; +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { Stub } from '../testing/sinon-types'; +import { TokenDetails } from '../interfaces/token-details'; +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; + let firebaseDependencies: FirebaseInternalDependencies; + let fetchStub: Stub; + + beforeEach(() => { + tokenDetails = getFakeTokenDetails(); + firebaseDependencies = getFakeFirebaseDependencies(); + fetchStub = stub(self, 'fetch'); + }); + + describe('getToken', () => { + it('calls the createRegistration server API with correct parameters', async () => { + fetchStub.resolves( + new Response(JSON.stringify({ token: 'fcm-token-from-server' })) + ); + + const response = await requestGetToken( + firebaseDependencies, + tokenDetails.subscriptionOptions! + ); + + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': 'apiKey', + 'x-goog-firebase-installations-auth': `FIS authToken` + }); + const expectedBody: ApiRequestBody = { + web: { + endpoint: 'https://example.org', + auth: 'YXV0aC12YWx1ZQ', + p256dh: 'cDI1Ni12YWx1ZQ', + applicationPubKey: 'dmFwaWQta2V5LXZhbHVl' + } + }; + const expectedRequest: RequestInit = { + method: 'POST', + headers: expectedHeaders, + body: JSON.stringify(expectedBody) + }; + const expectedEndpoint = `${ENDPOINT}/projects/projectId/registrations`; + + expect(response).to.equal('fcm-token-from-server'); + expect(fetchStub).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchStub.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); + + it('throws if there is a problem with the response', async () => { + fetchStub.rejects(new Error('Fetch failed')); + await expect( + requestGetToken(firebaseDependencies, tokenDetails.subscriptionOptions!) + ).to.be.rejectedWith('Fetch failed'); + + fetchStub.resolves( + new Response(JSON.stringify({ error: { message: 'error message' } })) + ); + await expect( + requestGetToken(firebaseDependencies, tokenDetails.subscriptionOptions!) + ).to.be.rejectedWith('messaging/token-subscribe-failed'); + + fetchStub.resolves( + new Response( + JSON.stringify({ + /* no token */ + }) + ) + ); + await expect( + requestGetToken(firebaseDependencies, tokenDetails.subscriptionOptions!) + ).to.be.rejectedWith('messaging/token-subscribe-no-token'); + }); + }); + + describe('updateToken', () => { + it('calls the updateRegistration server API with correct parameters', async () => { + fetchStub.resolves( + new Response(JSON.stringify({ token: 'fcm-token-from-server' })) + ); + + const response = await requestUpdateToken( + firebaseDependencies, + tokenDetails + ); + + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': 'apiKey', + 'x-goog-firebase-installations-auth': `FIS authToken` + }); + const expectedBody: ApiRequestBody = { + web: { + endpoint: 'https://example.org', + auth: 'YXV0aC12YWx1ZQ', + p256dh: 'cDI1Ni12YWx1ZQ', + applicationPubKey: 'dmFwaWQta2V5LXZhbHVl' + } + }; + const expectedRequest: RequestInit = { + method: 'PATCH', + headers: expectedHeaders, + body: JSON.stringify(expectedBody) + }; + const expectedEndpoint = `${ENDPOINT}/projects/projectId/registrations/token-value`; + + expect(response).to.equal('fcm-token-from-server'); + expect(fetchStub).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchStub.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); + + it('throws if there is a problem with the response', async () => { + fetchStub.rejects(new Error('Fetch failed')); + await expect( + requestUpdateToken(firebaseDependencies, tokenDetails) + ).to.be.rejectedWith('Fetch failed'); + + fetchStub.resolves( + new Response(JSON.stringify({ error: { message: 'error message' } })) + ); + await expect( + requestUpdateToken(firebaseDependencies, tokenDetails) + ).to.be.rejectedWith('messaging/token-update-failed'); + + fetchStub.resolves( + new Response( + JSON.stringify({ + /* no token */ + }) + ) + ); + await expect( + requestUpdateToken(firebaseDependencies, tokenDetails) + ).to.be.rejectedWith('messaging/token-update-no-token'); + }); + }); + + describe('deleteToken', () => { + it('calls the deleteRegistration server API with correct parameters', async () => { + fetchStub.resolves(new Response(JSON.stringify({}))); + + const response = await requestDeleteToken( + firebaseDependencies, + tokenDetails.token + ); + + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': 'apiKey', + 'x-goog-firebase-installations-auth': `FIS authToken` + }); + const expectedRequest: RequestInit = { + method: 'DELETE', + headers: expectedHeaders + }; + const expectedEndpoint = `${ENDPOINT}/projects/projectId/registrations/token-value`; + + expect(response).to.be.undefined; + expect(fetchStub).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchStub.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); + + it('throws if there is a problem with the response', async () => { + fetchStub.rejects(new Error('Fetch failed')); + await expect( + requestDeleteToken(firebaseDependencies, tokenDetails.token) + ).to.be.rejectedWith('Fetch failed'); + + fetchStub.resolves( + new Response(JSON.stringify({ error: { message: 'error message' } })) + ); + await expect( + requestDeleteToken(firebaseDependencies, tokenDetails.token) + ).to.be.rejectedWith('messaging/token-unsubscribe-failed'); + }); + }); +}); diff --git a/packages-exp/messaging-exp/src/core/api.ts b/packages-exp/messaging-exp/src/core/api.ts new file mode 100644 index 00000000000..b93f8623601 --- /dev/null +++ b/packages-exp/messaging-exp/src/core/api.ts @@ -0,0 +1,186 @@ +/** + * @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 { DEFAULT_VAPID_KEY, ENDPOINT } from '../util/constants'; +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; + error?: { message: string }; +} + +export interface ApiRequestBody { + web: { + endpoint: string; + p256dh: string; + auth: string; + applicationPubKey?: string; + }; +} + +export async function requestGetToken( + firebaseDependencies: FirebaseInternalDependencies, + subscriptionOptions: SubscriptionOptions +): Promise { + const headers = await getHeaders(firebaseDependencies); + const body = getBody(subscriptionOptions); + + const subscribeOptions = { + method: 'POST', + headers, + body: JSON.stringify(body) + }; + + let responseData: ApiResponse; + try { + const response = await fetch( + getEndpoint(firebaseDependencies.appConfig), + subscribeOptions + ); + responseData = await response.json(); + } catch (err) { + throw ERROR_FACTORY.create(ErrorCode.TOKEN_SUBSCRIBE_FAILED, { + errorInfo: err + }); + } + + if (responseData.error) { + const message = responseData.error.message; + throw ERROR_FACTORY.create(ErrorCode.TOKEN_SUBSCRIBE_FAILED, { + errorInfo: message + }); + } + + if (!responseData.token) { + throw ERROR_FACTORY.create(ErrorCode.TOKEN_SUBSCRIBE_NO_TOKEN); + } + + return responseData.token; +} + +export async function requestUpdateToken( + firebaseDependencies: FirebaseInternalDependencies, + tokenDetails: TokenDetails +): Promise { + const headers = await getHeaders(firebaseDependencies); + const body = getBody(tokenDetails.subscriptionOptions!); + + const updateOptions = { + method: 'PATCH', + headers, + body: JSON.stringify(body) + }; + + let responseData: ApiResponse; + try { + const response = await fetch( + `${getEndpoint(firebaseDependencies.appConfig)}/${tokenDetails.token}`, + updateOptions + ); + responseData = await response.json(); + } catch (err) { + throw ERROR_FACTORY.create(ErrorCode.TOKEN_UPDATE_FAILED, { + errorInfo: err + }); + } + + if (responseData.error) { + const message = responseData.error.message; + throw ERROR_FACTORY.create(ErrorCode.TOKEN_UPDATE_FAILED, { + errorInfo: message + }); + } + + if (!responseData.token) { + throw ERROR_FACTORY.create(ErrorCode.TOKEN_UPDATE_NO_TOKEN); + } + + return responseData.token; +} + +export async function requestDeleteToken( + firebaseDependencies: FirebaseInternalDependencies, + token: string +): Promise { + const headers = await getHeaders(firebaseDependencies); + + const unsubscribeOptions = { + method: 'DELETE', + headers + }; + + try { + const response = await fetch( + `${getEndpoint(firebaseDependencies.appConfig)}/${token}`, + unsubscribeOptions + ); + const responseData: ApiResponse = await response.json(); + if (responseData.error) { + const message = responseData.error.message; + throw ERROR_FACTORY.create(ErrorCode.TOKEN_UNSUBSCRIBE_FAILED, { + errorInfo: message + }); + } + } catch (err) { + throw ERROR_FACTORY.create(ErrorCode.TOKEN_UNSUBSCRIBE_FAILED, { + errorInfo: err + }); + } +} + +function getEndpoint({ projectId }: AppConfig): string { + return `${ENDPOINT}/projects/${projectId!}/registrations`; +} + +async function getHeaders({ + appConfig, + installations +}: FirebaseInternalDependencies): Promise { + const authToken = await installations.getToken(); + + return new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': appConfig.apiKey!, + 'x-goog-firebase-installations-auth': `FIS ${authToken}` + }); +} + +function getBody({ + p256dh, + auth, + endpoint, + vapidKey +}: SubscriptionOptions): ApiRequestBody { + const body: ApiRequestBody = { + web: { + endpoint, + auth, + p256dh + } + }; + + if (vapidKey !== DEFAULT_VAPID_KEY) { + body.web.applicationPubKey = vapidKey; + } + + return body; +} diff --git a/packages-exp/messaging-exp/src/core/token-management.test.ts b/packages-exp/messaging-exp/src/core/token-management.test.ts new file mode 100644 index 00000000000..db9f3e39268 --- /dev/null +++ b/packages-exp/messaging-exp/src/core/token-management.test.ts @@ -0,0 +1,296 @@ +/** + * @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 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 { DEFAULT_VAPID_KEY } from '../util/constants'; +import { ErrorCode } from '../util/errors'; +import { FakeServiceWorkerRegistration } from '../testing/fakes/service-worker'; +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { Stub } from '../testing/sinon-types'; +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; + let firebaseDependencies: FirebaseInternalDependencies; + let swRegistration: FakeServiceWorkerRegistration; + let requestGetTokenStub: Stub; + let requestUpdateTokenStub: Stub; + let requestDeleteTokenStub: Stub; + + beforeEach(() => { + useFakeTimers({ now: 1234567890 }); + + tokenDetails = getFakeTokenDetails(); + firebaseDependencies = getFakeFirebaseDependencies(); + swRegistration = new FakeServiceWorkerRegistration(); + + requestGetTokenStub = stub(apiModule, 'requestGetToken').resolves( + 'token-from-server' // new token. + ); + requestUpdateTokenStub = stub(apiModule, 'requestUpdateToken').resolves( + tokenDetails.token // same as current token. + ); + requestDeleteTokenStub = stub(apiModule, 'requestDeleteToken').resolves(); + }); + + describe('getToken', () => { + it("throws if notification permission isn't granted", async () => { + stub(Notification, 'permission').value('denied'); + + try { + await getToken(firebaseDependencies, swRegistration, DEFAULT_VAPID_KEY); + throw new Error('should have thrown'); + } catch (err) { + expect(err.code).to.equal(`messaging/${ErrorCode.PERMISSION_BLOCKED}`); + } + + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.called; + }); + + it('gets a new token if there is none', async () => { + stub(Notification, 'permission').value('granted'); + + const token = await getToken( + firebaseDependencies, + swRegistration, + tokenDetails.subscriptionOptions!.vapidKey + ); + + expect(token).to.equal('token-from-server'); + expect(requestGetTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + tokenDetails.subscriptionOptions + ); + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.called; + + const tokenFromDb = await dbGet(firebaseDependencies); + expect(token).to.equal(tokenFromDb!.token); + expect(tokenFromDb).to.deep.equal({ + ...tokenDetails, + token: 'token-from-server' + }); + }); + + it('deletes the token and requests a new one if the token is invalid', async () => { + stub(Notification, 'permission').value('granted'); + + await dbSet(firebaseDependencies, tokenDetails); + + // Change the auth in the Push subscription, invalidating the token. + const subscription = await swRegistration.pushManager.subscribe(); + subscription.auth = 'different-auth'; + const newAuth = arrayToBase64(subscription.getKey('auth')); + + const token = await getToken( + firebaseDependencies, + swRegistration, + tokenDetails.subscriptionOptions!.vapidKey + ); + + const expectedSubscriptionOptions: SubscriptionOptions = { + ...tokenDetails.subscriptionOptions!, + auth: newAuth + }; + + expect(token).to.equal('token-from-server'); + expect(requestGetTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + expectedSubscriptionOptions + ); + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + tokenDetails.token + ); + + const tokenFromDb = await dbGet(firebaseDependencies); + expect(token).to.equal(tokenFromDb!.token); + expect(tokenFromDb).to.deep.equal({ + ...tokenDetails, + token, + subscriptionOptions: expectedSubscriptionOptions + }); + }); + + it('deletes the token and requests a new one if the VAPID key changes', async () => { + stub(Notification, 'permission').value('granted'); + + await dbSet(firebaseDependencies, tokenDetails); + + const token = await getToken( + firebaseDependencies, + swRegistration, + 'some-other-vapid-key' + ); + + const expectedSubscriptionOptions: SubscriptionOptions = { + ...tokenDetails.subscriptionOptions!, + vapidKey: 'some-other-vapid-key' + }; + + expect(token).to.equal('token-from-server'); + expect(requestGetTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + expectedSubscriptionOptions + ); + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + tokenDetails.token + ); + + const tokenFromDb = await dbGet(firebaseDependencies); + expect(token).to.equal(tokenFromDb!.token); + expect(tokenFromDb).to.deep.equal({ + ...tokenDetails, + token, + subscriptionOptions: expectedSubscriptionOptions + }); + }); + + it('updates the token if it was last updated more than a week ago', async () => { + stub(Notification, 'permission').value('granted'); + + // Change create time to be older than a week. + tokenDetails.createTime = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days + + await dbSet(firebaseDependencies, tokenDetails); + + const token = await getToken( + firebaseDependencies, + swRegistration, + tokenDetails.subscriptionOptions!.vapidKey + ); + const expectedTokenDetails: TokenDetails = { + ...tokenDetails, + createTime: Date.now() + }; + + expect(token).to.equal(tokenDetails.token); // Same token. + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + expectedTokenDetails + ); + expect(requestDeleteTokenStub).not.to.have.been.called; + + const tokenFromDb = await dbGet(firebaseDependencies); + expect(token).to.equal(tokenFromDb!.token); + expect(tokenFromDb).to.deep.equal(expectedTokenDetails); + }); + + it('deletes the token if the update fails', async () => { + stub(Notification, 'permission').value('granted'); + + // Change create time to be older than a week. + tokenDetails.createTime = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days + + await dbSet(firebaseDependencies, tokenDetails); + + requestUpdateTokenStub.rejects(new Error('Update failed.')); + + await expect( + getToken( + firebaseDependencies, + swRegistration, + tokenDetails.subscriptionOptions!.vapidKey + ) + ).to.be.rejectedWith('Update failed.'); + + const expectedTokenDetails: TokenDetails = { + ...tokenDetails, + createTime: Date.now() + }; + + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + expectedTokenDetails + ); + expect(requestDeleteTokenStub).to.have.been.calledOnceWith( + firebaseDependencies, + tokenDetails.token + ); + + const tokenFromDb = await dbGet(firebaseDependencies); + expect(tokenFromDb).to.be.undefined; + }); + + it('returns the token if it is valid', async () => { + stub(Notification, 'permission').value('granted'); + + await dbSet(firebaseDependencies, tokenDetails); + + const token = await getToken( + firebaseDependencies, + swRegistration, + tokenDetails.subscriptionOptions!.vapidKey + ); + + expect(token).to.equal(tokenDetails.token); + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.called; + + const tokenFromDb = await dbGet(firebaseDependencies); + expect(tokenFromDb).to.deep.equal(tokenDetails); + }); + }); + + describe('deleteToken', () => { + it('returns if there is no token in the db', async () => { + await deleteToken(firebaseDependencies, swRegistration); + + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.called; + }); + + it('removes token from the db, calls requestDeleteToken and unsubscribes the push subscription', async () => { + const unsubscribeSpy = spy( + await swRegistration.pushManager.subscribe(), + 'unsubscribe' + ); + await dbSet(firebaseDependencies, tokenDetails); + + await deleteToken(firebaseDependencies, swRegistration); + + expect(await dbGet(firebaseDependencies)).to.be.undefined; + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.calledOnceWith( + firebaseDependencies, + tokenDetails + ); + expect(unsubscribeSpy).to.have.been.called; + }); + }); +}); diff --git a/packages-exp/messaging-exp/src/core/token-management.ts b/packages-exp/messaging-exp/src/core/token-management.ts new file mode 100644 index 00000000000..b314c89fc77 --- /dev/null +++ b/packages-exp/messaging-exp/src/core/token-management.ts @@ -0,0 +1,184 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { SubscriptionOptions, TokenDetails } from '../interfaces/token-details'; +import { + arrayToBase64, + base64ToArray +} from '../helpers/array-base64-translator'; +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 + +export async function getToken( + firebaseDependencies: FirebaseInternalDependencies, + swRegistration: ServiceWorkerRegistration, + vapidKey: string +): Promise { + if (Notification.permission !== 'granted') { + throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); + } + + // 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); + + const subscriptionOptions: SubscriptionOptions = { + vapidKey, + swScope: swRegistration.scope, + endpoint: pushSubscription.endpoint, + auth: arrayToBase64(pushSubscription.getKey('auth')!), + p256dh: arrayToBase64(pushSubscription.getKey('p256dh')!) + }; + + if (!tokenDetails) { + // No token, get a new one. + return getNewToken(firebaseDependencies, subscriptionOptions); + } else if ( + !isTokenValid(tokenDetails.subscriptionOptions!, subscriptionOptions) + ) { + // Invalid token, get a new one. + try { + await requestDeleteToken(firebaseDependencies, tokenDetails.token); + } catch (e) { + // Suppress errors because of #2364 + console.warn(e); + } + + return getNewToken(firebaseDependencies, subscriptionOptions); + } else if (Date.now() >= tokenDetails.createTime + TOKEN_EXPIRATION_MS) { + // Weekly token refresh + return updateToken( + { + token: tokenDetails.token, + createTime: Date.now(), + subscriptionOptions + }, + firebaseDependencies, + swRegistration + ); + } else { + // Valid token, nothing to do. + return tokenDetails.token; + } +} + +/** + * 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, + swRegistration: ServiceWorkerRegistration +): Promise { + const tokenDetails = await dbGet(firebaseDependencies); + if (tokenDetails) { + await requestDeleteToken(firebaseDependencies, tokenDetails.token); + await dbRemove(firebaseDependencies); + } + + // Unsubscribe from the push subscription. + const pushSubscription = await swRegistration.pushManager.getSubscription(); + if (pushSubscription) { + return pushSubscription.unsubscribe(); + } + + // If there's no SW, consider it a success. + return true; +} + +async function updateToken( + tokenDetails: TokenDetails, + firebaseDependencies: FirebaseInternalDependencies, + swRegistration: ServiceWorkerRegistration +): Promise { + try { + const updatedToken = await requestUpdateToken( + firebaseDependencies, + tokenDetails + ); + + const updatedTokenDetails: TokenDetails = { + ...tokenDetails, + token: updatedToken, + createTime: Date.now() + }; + + await dbSet(firebaseDependencies, updatedTokenDetails); + return updatedToken; + } catch (e) { + await deleteToken(firebaseDependencies, swRegistration); + throw e; + } +} + +async function getNewToken( + firebaseDependencies: FirebaseInternalDependencies, + subscriptionOptions: SubscriptionOptions +): Promise { + const token = await requestGetToken( + firebaseDependencies, + subscriptionOptions + ); + const tokenDetails: TokenDetails = { + token, + createTime: Date.now(), + subscriptionOptions + }; + await dbSet(firebaseDependencies, tokenDetails); + return tokenDetails.token; +} + +/** + * Gets a PushSubscription for the current user. + */ +async function getPushSubscription( + swRegistration: ServiceWorkerRegistration, + vapidKey: string +): Promise { + const subscription = await swRegistration.pushManager.getSubscription(); + if (subscription) { + return subscription; + } + return swRegistration.pushManager.subscribe({ + userVisibleOnly: true, + // Chrome <= 75 doesn't support base64-encoded VAPID key. For backward compatibility, VAPID key + // submitted to pushManager#subscribe must be of type Uint8Array. + applicationServerKey: base64ToArray(vapidKey) + }); +} + +/** + * Checks if the saved tokenDetails object matches the configuration provided. + */ +function isTokenValid( + dbOptions: SubscriptionOptions, + currentOptions: SubscriptionOptions +): boolean { + const isVapidKeyEqual = currentOptions.vapidKey === dbOptions.vapidKey; + const isEndpointEqual = currentOptions.endpoint === dbOptions.endpoint; + const isAuthEqual = currentOptions.auth === dbOptions.auth; + const isP256dhEqual = currentOptions.p256dh === dbOptions.p256dh; + + return isVapidKeyEqual && isEndpointEqual && isAuthEqual && isP256dhEqual; +} diff --git a/packages-exp/messaging-exp/src/helpers/array-base64-translator.test.ts b/packages-exp/messaging-exp/src/helpers/array-base64-translator.test.ts new file mode 100644 index 00000000000..c161b365dbc --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/array-base64-translator.test.ts @@ -0,0 +1,84 @@ +/** + * @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 '../testing/setup'; + +import { arrayToBase64, base64ToArray } from './array-base64-translator'; + +import { expect } from 'chai'; + +// prettier-ignore +const TEST_P256_ARRAY = new Uint8Array([ + 4, 181, 98, 240, 48, 62, 75, 119, 193, 227, 154, 69, 250, 216, 53, 110, + 157, 120, 62, 76, 213, 249, 11, 62, 12, 19, 149, 36, 5, 82, 140, 37, 141, + 134, 132, 98, 87, 152, 175, 98, 53, 83, 196, 242, 202, 155, 19, 173, 157, + 216, 45, 147, 20, 12, 151, 160, 147, 159, 205, 219, 75, 133, 156, 129, 152 +]); +const TEST_P256_BASE64 = + 'BLVi8DA-S3fB45pF-tg1bp14PkzV-Qs-DBOVJAVSjCWNhoRi' + + 'V5ivYjVTxPLKmxOtndgtkxQMl6CTn83bS4WcgZg'; + +// prettier-ignore +const TEST_AUTH_ARRAY = new Uint8Array([ + 255, 237, 107, 177, 171, 78, 84, 131, 221, 231, 87, 188, 22, 232, 71, 15 +]); +const TEST_AUTH_BASE64 = '_-1rsatOVIPd51e8FuhHDw'; + +// prettier-ignore +const TEST_VAPID_ARRAY = new Uint8Array([4, 48, 191, 217, 11, 218, 74, 124, 103, 143, 63, 182, 203, + 91, 0, 68, 221, 68, 172, 74, 89, 133, 198, 252, 145, 164, 136, 243, 186, 75, 198, 32, 45, 64, 240, + 120, 141, 173, 240, 131, 253, 83, 209, 193, 129, 50, 155, 126, 189, 23, 127, 232, 109, 75, 101, + 229, 92, 85, 137, 80, 121, 35, 229, 118, 207]); +const TEST_VAPID_BASE64 = + 'BDC_2QvaSnxnjz-2y1sARN1ErEpZhcb8kaSI87pLxiAtQPB4ja3wg_1T0cGBMpt' + + '-vRd_6G1LZeVcVYlQeSPlds8'; + +describe('arrayToBase64', () => { + it('array to base64 translation succeed', () => { + expect(arrayToBase64(TEST_P256_ARRAY)).to.equal(TEST_P256_BASE64); + expect(arrayToBase64(TEST_AUTH_ARRAY)).to.equal(TEST_AUTH_BASE64); + expect(arrayToBase64(TEST_VAPID_ARRAY)).to.equal(TEST_VAPID_BASE64); + }); +}); + +describe('base64ToArray', () => { + it('base64 to array translation succeed', () => { + expect(isEqual(base64ToArray(TEST_P256_BASE64), TEST_P256_ARRAY)).to.equal( + true + ); + expect(isEqual(base64ToArray(TEST_AUTH_BASE64), TEST_AUTH_ARRAY)).to.equal( + true + ); + expect( + isEqual(base64ToArray(TEST_VAPID_BASE64), TEST_VAPID_ARRAY) + ).to.equal(true); + }); +}); + +function isEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} diff --git a/packages-exp/messaging-exp/src/helpers/array-base64-translator.ts b/packages-exp/messaging-exp/src/helpers/array-base64-translator.ts new file mode 100644 index 00000000000..bbade845ae4 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/array-base64-translator.ts @@ -0,0 +1,37 @@ +/** + * @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. + */ + +export function arrayToBase64(array: Uint8Array | ArrayBuffer): string { + const uint8Array = new Uint8Array(array); + const base64String = btoa(String.fromCharCode(...uint8Array)); + return base64String.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +export function base64ToArray(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} diff --git a/packages-exp/messaging-exp/src/helpers/externalizePayload.test.ts b/packages-exp/messaging-exp/src/helpers/externalizePayload.test.ts new file mode 100644 index 00000000000..c42fb662cac --- /dev/null +++ b/packages-exp/messaging-exp/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-exp/messaging-exp/src/helpers/externalizePayload.ts b/packages-exp/messaging-exp/src/helpers/externalizePayload.ts new file mode 100644 index 00000000000..f86cafd089e --- /dev/null +++ b/packages-exp/messaging-exp/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-exp/messaging-exp/src/helpers/extract-app-config.test.ts b/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts new file mode 100644 index 00000000000..27d27c18aee --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts @@ -0,0 +1,82 @@ +/** + * @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 { 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', () => { + const firebaseApp = getFakeApp(); + const expected: AppConfig = { + appName: 'appName', + apiKey: 'apiKey', + projectId: 'projectId', + appId: '1:777777777777:web:d93b5ca1475efe57', + senderId: '1234567890' + }; + expect(extractAppConfig(firebaseApp)).to.deep.equal(expected); + }); + + it('throws if a necessary value is missing', () => { + expect(() => + extractAppConfig((undefined as unknown) as FirebaseApp) + ).to.throw('Missing App configuration value: "App Configuration Object"'); + + let firebaseApp = getFakeApp(); + // @ts-expect-error + delete firebaseApp.options; + expect(() => extractAppConfig(firebaseApp)).to.throw( + 'Missing App configuration value: "App Configuration Object"' + ); + + firebaseApp = getFakeApp(); + // @ts-expect-error + delete firebaseApp.name; + expect(() => extractAppConfig(firebaseApp)).to.throw( + 'Missing App configuration value: "App Name"' + ); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.projectId; + expect(() => extractAppConfig(firebaseApp)).to.throw( + 'Missing App configuration value: "projectId"' + ); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.apiKey; + expect(() => extractAppConfig(firebaseApp)).to.throw( + 'Missing App configuration value: "apiKey"' + ); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.appId; + expect(() => extractAppConfig(firebaseApp)).to.throw( + 'Missing App configuration value: "appId"' + ); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.messagingSenderId; + expect(() => extractAppConfig(firebaseApp)).to.throw( + 'Missing App configuration value: "messagingSenderId"' + ); + }); +}); diff --git a/packages-exp/messaging-exp/src/helpers/extract-app-config.ts b/packages-exp/messaging-exp/src/helpers/extract-app-config.ts new file mode 100644 index 00000000000..e95a45ced7e --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/extract-app-config.ts @@ -0,0 +1,61 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { FirebaseApp, FirebaseOptions } from '@firebase/app-types'; + +import { AppConfig } from '../interfaces/app-config'; +import { FirebaseError } from '@firebase/util'; + +export function extractAppConfig(app: FirebaseApp): AppConfig { + if (!app || !app.options) { + throw getMissingValueError('App Configuration Object'); + } + + if (!app.name) { + throw getMissingValueError('App Name'); + } + + // Required app config keys + const configKeys: ReadonlyArray = [ + 'projectId', + 'apiKey', + 'appId', + 'messagingSenderId' + ]; + + const { options } = app; + for (const keyName of configKeys) { + if (!options[keyName]) { + throw getMissingValueError(keyName); + } + } + + return { + appName: app.name, + projectId: options.projectId!, + apiKey: options.apiKey!, + appId: options.appId!, + senderId: options.messagingSenderId! + }; +} + +function getMissingValueError(valueName: string): FirebaseError { + return ERROR_FACTORY.create(ErrorCode.MISSING_APP_CONFIG_VALUES, { + valueName + }); +} diff --git a/packages-exp/messaging-exp/src/helpers/idb-manager.test.ts b/packages-exp/messaging-exp/src/helpers/idb-manager.test.ts new file mode 100644 index 00000000000..b98ad933378 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/idb-manager.test.ts @@ -0,0 +1,124 @@ +/** + * @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 migrateOldDatabaseModule from './migrate-old-database'; + +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; + let tokenDetailsA: TokenDetails; + let tokenDetailsB: TokenDetails; + + beforeEach(() => { + firebaseDependencies = getFakeFirebaseDependencies(); + tokenDetailsA = getFakeTokenDetails(); + tokenDetailsB = getFakeTokenDetails(); + tokenDetailsA.token = 'TOKEN_A'; + tokenDetailsB.token = 'TOKEN_B'; + }); + + describe('get / set', () => { + it('sets a value and then gets the same value back', async () => { + await dbSet(firebaseDependencies, tokenDetailsA); + const value = await dbGet(firebaseDependencies); + expect(value).to.deep.equal(tokenDetailsA); + }); + + it('gets undefined for a key that does not exist', async () => { + const value = await dbGet(firebaseDependencies); + expect(value).to.be.undefined; + }); + + it('sets and gets multiple values with different keys', async () => { + const firebaseDependenciesB = getFakeFirebaseDependencies({ + appId: 'different-app-id' + }); + await dbSet(firebaseDependencies, tokenDetailsA); + await dbSet(firebaseDependenciesB, tokenDetailsB); + expect(await dbGet(firebaseDependencies)).to.deep.equal(tokenDetailsA); + expect(await dbGet(firebaseDependenciesB)).to.deep.equal(tokenDetailsB); + }); + + it('overwrites a value', async () => { + await dbSet(firebaseDependencies, tokenDetailsA); + await dbSet(firebaseDependencies, tokenDetailsB); + expect(await dbGet(firebaseDependencies)).to.deep.equal(tokenDetailsB); + }); + + describe('old DB migration', () => { + let migrateOldDatabaseStub: Stub< + typeof migrateOldDatabaseModule['migrateOldDatabase'] + >; + + beforeEach(() => { + migrateOldDatabaseStub = stub( + migrateOldDatabaseModule, + 'migrateOldDatabase' + ).resolves(tokenDetailsA); + }); + + it('gets value from old DB if there is one', async () => { + await dbGet(firebaseDependencies); + + expect(migrateOldDatabaseStub).to.have.been.calledOnceWith( + firebaseDependencies.appConfig.senderId + ); + }); + + it('does not call migrateOldDatabase a second time', async () => { + await dbGet(firebaseDependencies); + await dbGet(firebaseDependencies); + + expect(migrateOldDatabaseStub).to.have.been.calledOnceWith( + firebaseDependencies.appConfig.senderId + ); + }); + + it('does not call migrateOldDatabase if there is already a value in the DB', async () => { + await dbSet(firebaseDependencies, tokenDetailsA); + + await dbGet(firebaseDependencies); + + expect(migrateOldDatabaseStub).not.to.have.been.called; + }); + }); + }); + + describe('remove', () => { + it('deletes a key', async () => { + await dbSet(firebaseDependencies, tokenDetailsA); + await dbRemove(firebaseDependencies); + expect(await dbGet(firebaseDependencies)).to.be.undefined; + }); + + it('does not throw if key does not exist', async () => { + await dbRemove(firebaseDependencies); + expect(await dbGet(firebaseDependencies)).to.be.undefined; + }); + }); +}); diff --git a/packages-exp/messaging-exp/src/helpers/idb-manager.ts b/packages-exp/messaging-exp/src/helpers/idb-manager.ts new file mode 100644 index 00000000000..dbe957de244 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/idb-manager.ts @@ -0,0 +1,106 @@ +/** + * @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 { DB, deleteDb, openDb } from 'idb'; + +import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { TokenDetails } from '../interfaces/token-details'; +import { migrateOldDatabase } from './migrate-old-database'; + +// Exported for tests. +export const DATABASE_NAME = 'firebase-messaging-database'; +const DATABASE_VERSION = 1; +const OBJECT_STORE_NAME = 'firebase-messaging-store'; + +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. + // eslint-disable-next-line default-case + switch (upgradeDb.oldVersion) { + case 0: + upgradeDb.createObjectStore(OBJECT_STORE_NAME); + } + }); + } + return dbPromise; +} + +/** Gets record(s) from the objectStore that match the given key. */ +export async function dbGet( + firebaseDependencies: FirebaseInternalDependencies +): Promise { + const key = getKey(firebaseDependencies); + const db = await getDbPromise(); + const tokenDetails = await db + .transaction(OBJECT_STORE_NAME) + .objectStore(OBJECT_STORE_NAME) + .get(key); + + if (tokenDetails) { + return tokenDetails; + } else { + // Check if there is a tokenDetails object in the old DB. + const oldTokenDetails = await migrateOldDatabase( + firebaseDependencies.appConfig.senderId + ); + if (oldTokenDetails) { + await dbSet(firebaseDependencies, oldTokenDetails); + return oldTokenDetails; + } + } +} + +/** Assigns or overwrites the record for the given key with the given value. */ +export async function dbSet( + firebaseDependencies: FirebaseInternalDependencies, + tokenDetails: TokenDetails +): Promise { + const key = getKey(firebaseDependencies); + const db = await getDbPromise(); + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + await tx.objectStore(OBJECT_STORE_NAME).put(tokenDetails, key); + await tx.complete; + return tokenDetails; +} + +/** Removes record(s) from the objectStore that match the given key. */ +export async function dbRemove( + firebaseDependencies: FirebaseInternalDependencies +): Promise { + const key = getKey(firebaseDependencies); + const db = await getDbPromise(); + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + await tx.objectStore(OBJECT_STORE_NAME).delete(key); + await tx.complete; +} + +/** Deletes the DB. Useful for tests. */ +export async function dbDelete(): Promise { + if (dbPromise) { + (await dbPromise).close(); + await deleteDb(DATABASE_NAME); + dbPromise = null; + } +} + +function getKey({ appConfig }: FirebaseInternalDependencies): string { + return appConfig.appId; +} diff --git a/packages-exp/messaging-exp/src/helpers/is-console-message.ts b/packages-exp/messaging-exp/src/helpers/is-console-message.ts new file mode 100644 index 00000000000..151713be132 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/is-console-message.ts @@ -0,0 +1,24 @@ +/** + * @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 { 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. + return typeof data === 'object' && !!data && CONSOLE_CAMPAIGN_ID in data; +} diff --git a/packages-exp/messaging-exp/src/helpers/isSupported.ts b/packages-exp/messaging-exp/src/helpers/isSupported.ts new file mode 100644 index 00000000000..c73122eaad1 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/isSupported.ts @@ -0,0 +1,57 @@ +/** + * @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. + */ + +export function isSupported(): boolean { + if (self && 'ServiceWorkerGlobalScope' in self) { + // Running in ServiceWorker context + return isSWControllerSupported(); + } else { + // Assume we are in the window context. + return isWindowControllerSupported(); + } +} + +/** + * Checks to see if the required APIs exist. + */ +function isWindowControllerSupported(): boolean { + return ( + 'indexedDB' in window && + indexedDB !== null && + navigator.cookieEnabled && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window && + 'fetch' in window && + ServiceWorkerRegistration.prototype.hasOwnProperty('showNotification') && + PushSubscription.prototype.hasOwnProperty('getKey') + ); +} + +/** + * Checks to see if the required APIs exist within SW Context. + */ +function isSWControllerSupported(): boolean { + return ( + 'indexedDB' in self && + indexedDB !== null && + 'PushManager' in self && + 'Notification' in self && + ServiceWorkerRegistration.prototype.hasOwnProperty('showNotification') && + PushSubscription.prototype.hasOwnProperty('getKey') + ); +} diff --git a/packages-exp/messaging-exp/src/helpers/migrate-old-database.test.ts b/packages-exp/messaging-exp/src/helpers/migrate-old-database.test.ts new file mode 100644 index 00000000000..020295ca2fd --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/migrate-old-database.test.ts @@ -0,0 +1,204 @@ +/** + * @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 { + V2TokenDetails, + V3TokenDetails, + V4TokenDetails, + migrateOldDatabase +} from './migrate-old-database'; + +import { FakePushSubscription } from '../testing/fakes/service-worker'; +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 () => { + const tokenDetails = await migrateOldDatabase('1234567890'); + expect(tokenDetails).to.be.null; + }); + + it('does nothing if old DB was too old', async () => { + await put(1, { + swScope: '/scope-value', + fcmSenderId: '1234567890', + fcmToken: 'token-value' + }); + + const tokenDetails = await migrateOldDatabase('1234567890'); + expect(tokenDetails).to.be.null; + }); + + describe('version 2', () => { + beforeEach(async () => { + const v2TokenDetails: V2TokenDetails = { + fcmToken: 'token-value', + swScope: '/scope-value', + vapidKey: base64ToArray('dmFwaWQta2V5LXZhbHVl'), + fcmSenderId: '1234567890', + fcmPushSet: '7654321', + auth: 'YXV0aC12YWx1ZQ', + p256dh: 'cDI1Ni12YWx1ZQ', + endpoint: 'https://example.org', + subscription: new FakePushSubscription() + }; + + await put(2, v2TokenDetails); + }); + + it('can get a value from old DB', async () => { + const tokenDetails = await migrateOldDatabase('1234567890'); + + const expectedTokenDetails = getFakeTokenDetails(); + // Ignore createTime difference. + expectedTokenDetails.createTime = tokenDetails!.createTime; + + expect(tokenDetails).to.deep.equal(expectedTokenDetails); + }); + + it('only migrates once', async () => { + await migrateOldDatabase('1234567890'); + const tokenDetails = await migrateOldDatabase('1234567890'); + + expect(tokenDetails).to.be.null; + }); + + it('does not get a value that has a different sender ID', async () => { + const tokenDetails = await migrateOldDatabase('321321321'); + expect(tokenDetails).to.be.null; + }); + + it('does not migrate an entry with missing optional values', async () => { + const v2TokenDetails: V2TokenDetails = { + fcmToken: 'token-value', + swScope: '/scope-value', + vapidKey: base64ToArray('dmFwaWQta2V5LXZhbHVl'), + fcmSenderId: '1234567890', + fcmPushSet: '7654321', + subscription: new FakePushSubscription() + }; + await put(2, v2TokenDetails); + + const tokenDetails = await migrateOldDatabase('1234567890'); + expect(tokenDetails).to.be.null; + }); + }); + + describe('version 3', () => { + beforeEach(async () => { + const v3TokenDetails: V3TokenDetails = { + createTime: 1234567890, + fcmToken: 'token-value', + swScope: '/scope-value', + vapidKey: base64ToArray('dmFwaWQta2V5LXZhbHVl'), + fcmSenderId: '1234567890', + fcmPushSet: '7654321', + auth: base64ToArray('YXV0aC12YWx1ZQ'), + p256dh: base64ToArray('cDI1Ni12YWx1ZQ'), + endpoint: 'https://example.org' + }; + + await put(3, v3TokenDetails); + }); + + it('can get a value from old DB', async () => { + const tokenDetails = await migrateOldDatabase('1234567890'); + + const expectedTokenDetails = getFakeTokenDetails(); + + expect(tokenDetails).to.deep.equal(expectedTokenDetails); + }); + + it('only migrates once', async () => { + await migrateOldDatabase('1234567890'); + const tokenDetails = await migrateOldDatabase('1234567890'); + + expect(tokenDetails).to.be.null; + }); + + it('does not get a value that has a different sender ID', async () => { + const tokenDetails = await migrateOldDatabase('321321321'); + expect(tokenDetails).to.be.null; + }); + }); + + describe('version 4', () => { + beforeEach(async () => { + const v4TokenDetails: V4TokenDetails = { + createTime: 1234567890, + fcmToken: 'token-value', + swScope: '/scope-value', + vapidKey: base64ToArray('dmFwaWQta2V5LXZhbHVl'), + fcmSenderId: '1234567890', + auth: base64ToArray('YXV0aC12YWx1ZQ'), + p256dh: base64ToArray('cDI1Ni12YWx1ZQ'), + endpoint: 'https://example.org' + }; + + await put(4, v4TokenDetails); + }); + + it('can get a value from old DB', async () => { + const tokenDetails = await migrateOldDatabase('1234567890'); + + const expectedTokenDetails = getFakeTokenDetails(); + + expect(tokenDetails).to.deep.equal(expectedTokenDetails); + }); + + it('only migrates once', async () => { + await migrateOldDatabase('1234567890'); + const tokenDetails = await migrateOldDatabase('1234567890'); + + expect(tokenDetails).to.be.null; + }); + + it('does not get a value that has a different sender ID', async () => { + const tokenDetails = await migrateOldDatabase('321321321'); + expect(tokenDetails).to.be.null; + }); + }); +}); + +async function put(version: number, value: object): Promise { + const db = await openDb('fcm_token_details_db', version, upgradeDb => { + if (upgradeDb.oldVersion === 0) { + const objectStore = upgradeDb.createObjectStore( + 'fcm_token_object_Store', + { + keyPath: 'swScope' + } + ); + objectStore.createIndex('fcmSenderId', 'fcmSenderId', { + unique: false + }); + objectStore.createIndex('fcmToken', 'fcmToken', { unique: true }); + } + }); + + try { + const tx = db.transaction('fcm_token_object_Store', 'readwrite'); + await tx.objectStore('fcm_token_object_Store').put(value); + await tx.complete; + } finally { + db.close(); + } +} diff --git a/packages-exp/messaging-exp/src/helpers/migrate-old-database.ts b/packages-exp/messaging-exp/src/helpers/migrate-old-database.ts new file mode 100644 index 00000000000..40fdece6171 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/migrate-old-database.ts @@ -0,0 +1,193 @@ +/** + * @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 { deleteDb, openDb } from 'idb'; + +import { TokenDetails } from '../interfaces/token-details'; +import { arrayToBase64 } from './array-base64-translator'; + +// https://github.com/firebase/firebase-js-sdk/blob/7857c212f944a2a9eb421fd4cb7370181bc034b5/packages/messaging/src/interfaces/token-details.ts +export interface V2TokenDetails { + fcmToken: string; + swScope: string; + vapidKey: string | Uint8Array; + subscription: PushSubscription; + fcmSenderId: string; + fcmPushSet: string; + createTime?: number; + endpoint?: string; + auth?: string; + p256dh?: string; +} + +// https://github.com/firebase/firebase-js-sdk/blob/6b5b15ce4ea3df5df5df8a8b33a4e41e249c7715/packages/messaging/src/interfaces/token-details.ts +export interface V3TokenDetails { + fcmToken: string; + swScope: string; + vapidKey: Uint8Array; + fcmSenderId: string; + fcmPushSet: string; + endpoint: string; + auth: ArrayBuffer; + p256dh: ArrayBuffer; + createTime: number; +} + +// https://github.com/firebase/firebase-js-sdk/blob/9567dba664732f681fa7fe60f5b7032bb1daf4c9/packages/messaging/src/interfaces/token-details.ts +export interface V4TokenDetails { + fcmToken: string; + swScope: string; + vapidKey: Uint8Array; + fcmSenderId: string; + endpoint: string; + auth: ArrayBufferLike; + p256dh: ArrayBufferLike; + createTime: number; +} + +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. + */ +const OLD_DB_VERSION = 5; +const OLD_OBJECT_STORE_NAME = 'fcm_token_object_Store'; + +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. + const databases = await (indexedDB as { + databases(): Promise>; + }).databases(); + const dbNames = databases.map(db => db.name); + + if (!dbNames.includes(OLD_DB_NAME)) { + // old DB didn't exist, no need to open. + return null; + } + } + + let tokenDetails: TokenDetails | null = null; + + const db = await openDb(OLD_DB_NAME, OLD_DB_VERSION, async db => { + if (db.oldVersion < 2) { + // Database too old, skip migration. + return; + } + + if (!db.objectStoreNames.contains(OLD_OBJECT_STORE_NAME)) { + // Database did not exist. Nothing to do. + return; + } + + const objectStore = db.transaction.objectStore(OLD_OBJECT_STORE_NAME); + const value = await objectStore.index('fcmSenderId').get(senderId); + await objectStore.clear(); + + if (!value) { + // No entry in the database, nothing to migrate. + return; + } + + if (db.oldVersion === 2) { + const oldDetails = value as V2TokenDetails; + + if (!oldDetails.auth || !oldDetails.p256dh || !oldDetails.endpoint) { + return; + } + + tokenDetails = { + token: oldDetails.fcmToken, + createTime: oldDetails.createTime ?? Date.now(), + subscriptionOptions: { + auth: oldDetails.auth, + p256dh: oldDetails.p256dh, + endpoint: oldDetails.endpoint, + swScope: oldDetails.swScope, + vapidKey: + typeof oldDetails.vapidKey === 'string' + ? oldDetails.vapidKey + : arrayToBase64(oldDetails.vapidKey) + } + }; + } else if (db.oldVersion === 3) { + const oldDetails = value as V3TokenDetails; + + tokenDetails = { + token: oldDetails.fcmToken, + createTime: oldDetails.createTime, + subscriptionOptions: { + auth: arrayToBase64(oldDetails.auth), + p256dh: arrayToBase64(oldDetails.p256dh), + endpoint: oldDetails.endpoint, + swScope: oldDetails.swScope, + vapidKey: arrayToBase64(oldDetails.vapidKey) + } + }; + } else if (db.oldVersion === 4) { + const oldDetails = value as V4TokenDetails; + + tokenDetails = { + token: oldDetails.fcmToken, + createTime: oldDetails.createTime, + subscriptionOptions: { + auth: arrayToBase64(oldDetails.auth), + p256dh: arrayToBase64(oldDetails.p256dh), + endpoint: oldDetails.endpoint, + swScope: oldDetails.swScope, + vapidKey: arrayToBase64(oldDetails.vapidKey) + } + }; + } + }); + db.close(); + + // Delete all old databases. + await deleteDb(OLD_DB_NAME); + await deleteDb('fcm_vapid_details_db'); + await deleteDb('undefined'); + + return checkTokenDetails(tokenDetails) ? tokenDetails : null; +} + +function checkTokenDetails( + tokenDetails: TokenDetails | null +): tokenDetails is TokenDetails { + if (!tokenDetails || !tokenDetails.subscriptionOptions) { + return false; + } + const { subscriptionOptions } = tokenDetails; + return ( + typeof tokenDetails.createTime === 'number' && + tokenDetails.createTime > 0 && + typeof tokenDetails.token === 'string' && + tokenDetails.token.length > 0 && + typeof subscriptionOptions.auth === 'string' && + subscriptionOptions.auth.length > 0 && + typeof subscriptionOptions.p256dh === 'string' && + subscriptionOptions.p256dh.length > 0 && + typeof subscriptionOptions.endpoint === 'string' && + subscriptionOptions.endpoint.length > 0 && + typeof subscriptionOptions.swScope === 'string' && + subscriptionOptions.swScope.length > 0 && + typeof subscriptionOptions.vapidKey === 'string' && + subscriptionOptions.vapidKey.length > 0 + ); +} diff --git a/packages-exp/messaging-exp/src/helpers/sleep.test.ts b/packages-exp/messaging-exp/src/helpers/sleep.test.ts new file mode 100644 index 00000000000..b7c4e228f10 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/sleep.test.ts @@ -0,0 +1,39 @@ +/** + * @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 { SinonFakeTimers, useFakeTimers } from 'sinon'; + +import { expect } from 'chai'; +import { sleep } from './sleep'; + +describe('sleep', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers({ shouldAdvanceTime: true }); + }); + + it('returns a promise that resolves after a given amount of time', async () => { + const t0 = clock.now; + await sleep(100); + const t1 = clock.now; + + expect(t1 - t0).to.equal(100); + }); +}); diff --git a/packages-exp/messaging-exp/src/helpers/sleep.ts b/packages-exp/messaging-exp/src/helpers/sleep.ts new file mode 100644 index 00000000000..2bd1eb9283b --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/sleep.ts @@ -0,0 +1,23 @@ +/** + * @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. + */ + +/** Returns a promise that resolves after given time passes. */ +export function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts new file mode 100644 index 00000000000..621d1e126a0 --- /dev/null +++ b/packages-exp/messaging-exp/src/index.ts @@ -0,0 +1,74 @@ +/** + * @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 '@firebase/installations-exp'; + +import { + Component, + ComponentContainer, + ComponentType, + InstanceFactory +} from '@firebase/component'; +import { ERROR_FACTORY, ErrorCode } from './util/errors'; + +import { FirebaseMessaging } from '@firebase/messaging-types'; +import { MessagingService } from './api'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; +import { _registerComponent } from '@firebase/app-exp'; +import { isSupported } from './helpers/isSupported'; + +const NAMESPACE_EXPORTS = { + isSupported +}; + +/** + * Define extension behavior of `registerMessaging` + */ +declare module '@firebase/app-types' { + interface FirebaseNamespace { + messaging: { + (app?: FirebaseApp): FirebaseMessaging; + isSupported(): boolean; + }; + } + interface FirebaseApp { + messaging(): FirebaseMessaging; + } +} + +const messagingFactory: InstanceFactory<'messaging'> = ( + container: ComponentContainer +) => { + if (!isSupported()) { + throw ERROR_FACTORY.create(ErrorCode.UNSUPPORTED_BROWSER); + } + + const messagingService = new MessagingService(container); + if (!!messagingService.windowController) { + return messagingService.windowController!; + } else { + return messagingService.windowController!; + } +}; + +_registerComponent( + new Component( + 'messaging', + messagingFactory, + ComponentType.PUBLIC + ).setServiceProps(NAMESPACE_EXPORTS) +); diff --git a/packages-exp/messaging-exp/src/interfaces/app-config.ts b/packages-exp/messaging-exp/src/interfaces/app-config.ts new file mode 100644 index 00000000000..4a887eeb3cc --- /dev/null +++ b/packages-exp/messaging-exp/src/interfaces/app-config.ts @@ -0,0 +1,25 @@ +/** + * @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. + */ + +export interface AppConfig { + readonly appName: string; + readonly projectId: string; + readonly apiKey: string; + readonly appId: string; + /** Only used for old DB migration. */ + readonly senderId: string; +} diff --git a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts new file mode 100644 index 00000000000..ecc380f2089 --- /dev/null +++ b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts @@ -0,0 +1,29 @@ +/** + * @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 { 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; + appConfig: AppConfig; + installations: FirebaseInstallations; + analyticsProvider: Provider; +} diff --git a/packages-exp/messaging-exp/src/interfaces/internal-message-payload.ts b/packages-exp/messaging-exp/src/interfaces/internal-message-payload.ts new file mode 100644 index 00000000000..92f7b19fa02 --- /dev/null +++ b/packages-exp/messaging-exp/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-exp/messaging-exp/src/interfaces/token-details.ts b/packages-exp/messaging-exp/src/interfaces/token-details.ts new file mode 100644 index 00000000000..791c94d267b --- /dev/null +++ b/packages-exp/messaging-exp/src/interfaces/token-details.ts @@ -0,0 +1,34 @@ +/** + * @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. + */ + +export interface TokenDetails { + token: string; + createTime: number; + /** Does not exist in Safari since it's not using Push API. */ + subscriptionOptions?: SubscriptionOptions; +} + +/** + * Additional options and values required by a Push API subscription. + */ +export interface SubscriptionOptions { + vapidKey: string; + swScope: string; + endpoint: string; + auth: string; + p256dh: string; +} diff --git a/packages-exp/messaging-exp/src/testing/compare-headers.test.ts b/packages-exp/messaging-exp/src/testing/compare-headers.test.ts new file mode 100644 index 00000000000..ad171740ace --- /dev/null +++ b/packages-exp/messaging-exp/src/testing/compare-headers.test.ts @@ -0,0 +1,46 @@ +/** + * @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 './setup'; + +import { AssertionError, expect } from 'chai'; + +import { compareHeaders } from './compare-headers'; + +describe('compareHeaders', () => { + it("doesn't fail if headers contain the same entries", () => { + const headers1 = new Headers({ a: '123', b: '456' }); + const headers2 = new Headers({ a: '123', b: '456' }); + compareHeaders(headers1, headers2); + }); + + it('fails if headers contain different keys', () => { + const headers1 = new Headers({ a: '123', b: '456', extraKey: '789' }); + const headers2 = new Headers({ a: '123', b: '456' }); + expect(() => { + compareHeaders(headers1, headers2); + }).to.throw(AssertionError); + }); + + it('fails if headers contain different values', () => { + const headers1 = new Headers({ a: '123', b: '456' }); + const headers2 = new Headers({ a: '123', b: 'differentValue' }); + expect(() => { + compareHeaders(headers1, headers2); + }).to.throw(AssertionError); + }); +}); diff --git a/packages-exp/messaging-exp/src/testing/compare-headers.ts b/packages-exp/messaging-exp/src/testing/compare-headers.ts new file mode 100644 index 00000000000..6f760caf32d --- /dev/null +++ b/packages-exp/messaging-exp/src/testing/compare-headers.ts @@ -0,0 +1,40 @@ +/** + * @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 './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. +export function compareHeaders( + expectedHeaders: HeadersWithEntries, + actualHeaders: HeadersWithEntries +): void { + const expected = makeMap(expectedHeaders); + const actual = makeMap(actualHeaders); + expect(actual).to.deep.equal(expected); +} + +function makeMap(headers: HeadersWithEntries): Map { + expect(headers.entries).not.to.be.undefined; + return new Map(headers.entries!()); +} diff --git a/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts b/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts new file mode 100644 index 00000000000..1f3da9a76ca --- /dev/null +++ b/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts @@ -0,0 +1,80 @@ +/** + * @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 { + FirebaseAnalyticsInternal, + FirebaseAnalyticsInternalName +} from '@firebase/analytics-interop-types'; +import { FirebaseApp, FirebaseOptions } from '@firebase/app-types-exp'; + +import { FirebaseInstallations } from '@firebase/installations-types-exp'; +import { FirebaseInternalDependencies } from '../../interfaces/internal-dependencies'; +import { Provider } from '@firebase/component'; +import { extractAppConfig } from '../../helpers/extract-app-config'; + +export function getFakeFirebaseDependencies( + options: FirebaseOptions = {} +): FirebaseInternalDependencies { + const app = getFakeApp(options); + return { + app, + appConfig: extractAppConfig(app), + installations: getFakeInstallations(), + analyticsProvider: getFakeAnalyticsProvider() + }; +} + +export function getFakeApp(options: FirebaseOptions = {}): FirebaseApp { + options = { + apiKey: 'apiKey', + projectId: 'projectId', + authDomain: 'authDomain', + messagingSenderId: '1234567890', + databaseURL: 'databaseUrl', + storageBucket: 'storageBucket', + appId: '1:777777777777:web:d93b5ca1475efe57', + ...options + }; + return { + name: 'appName', + options, + automaticDataCollectionEnabled: true, + delete: async () => {}, + messaging: (() => null as unknown) as FirebaseApp['messaging'] + // installations: () => getFakeInstallations() + }; +} + +function getFakeInstallations(): FirebaseInstallations { + return { + getId: async () => 'FID', + getToken: async () => 'authToken', + delete: async () => undefined, + onIdChange: () => () => {} + }; +} + +function getFakeAnalyticsProvider(): Provider { + const analytics: FirebaseAnalyticsInternal = { + logEvent() {} + }; + + return ({ + get: async () => analytics, + getImmediate: () => analytics + } as unknown) as Provider; +} diff --git a/packages-exp/messaging-exp/src/testing/fakes/service-worker.ts b/packages-exp/messaging-exp/src/testing/fakes/service-worker.ts new file mode 100644 index 00000000000..3fe02c10e5d --- /dev/null +++ b/packages-exp/messaging-exp/src/testing/fakes/service-worker.ts @@ -0,0 +1,211 @@ +/** + * @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 { 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. + +// Missing function types are implemented from interfaces, so types are actually defined. +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +// const originalSwRegistration = ServiceWorkerRegistration; +export function mockServiceWorker(): void { + self.clients = new FakeClients(); + self.registration = new FakeServiceWorkerRegistration(); +} + +export function restoreServiceWorker(): void { + self.clients = new FakeClients(); + self.registration = new FakeServiceWorkerRegistration(); +} + +class FakeClients implements Clients { + private readonly clients: Client[] = []; + + async get(id: string) { + return this.clients.find(c => id === c.id) ?? null; + } + + async matchAll({ type = 'all' } = {}) { + if (type === 'all') { + return this.clients; + } + return this.clients.filter(c => c.type === type); + } + + async openWindow(url: string) { + const windowClient = new FakeWindowClient(); + windowClient.url = url; + this.clients.push(windowClient); + return windowClient; + } + + async claim() {} +} + +let currentId = 0; +class FakeWindowClient implements WindowClient { + readonly id: string; + readonly type = 'window'; + focused = false; + visibilityState: VisibilityState = 'hidden'; + url = 'https://example.org'; + + constructor() { + this.id = (currentId++).toString(); + } + + async focus() { + this.focused = true; + return this; + } + + async navigate(url: string) { + this.url = url; + return this; + } + + postMessage() {} +} + +export class FakeServiceWorkerRegistration + implements ServiceWorkerRegistration { + active = null; + installing = null; + waiting = null; + onupdatefound = null; + pushManager = new FakePushManager(); + scope = '/scope-value'; + + // Unused in FCM Web SDK, no need to mock these. + navigationPreload = (null as unknown) as NavigationPreloadManager; + sync = (null as unknown) as SyncManager; + updateViaCache = (null as unknown) as ServiceWorkerUpdateViaCache; + + async getNotifications() { + return []; + } + + async showNotification() {} + + async update() {} + + async unregister() { + return true; + } + + addEventListener() {} + removeEventListener() {} + dispatchEvent() { + return true; + } +} + +class FakePushManager implements PushManager { + private subscription: FakePushSubscription | null = null; + + async permissionState() { + return 'granted' as const; + } + + async getSubscription() { + return this.subscription; + } + + async subscribe() { + if (!this.subscription) { + this.subscription = new FakePushSubscription(); + } + return this.subscription!; + } +} + +export class FakePushSubscription implements PushSubscription { + endpoint = 'https://example.org'; + expirationTime = 1234567890; + auth = 'auth-value'; // Encoded: 'YXV0aC12YWx1ZQ' + p256 = 'p256-value'; // Encoded: 'cDI1Ni12YWx1ZQ' + + getKey(name: PushEncryptionKeyName) { + const encoder = new TextEncoder(); + return encoder.encode(name === 'auth' ? this.auth : this.p256); + } + + async unsubscribe() { + return true; + } + + // Unused in FCM + toJSON = (null as unknown) as () => PushSubscriptionJSON; + options = (null as unknown) as PushSubscriptionOptions; +} + +/** + * 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; + AT_TARGET = Event.AT_TARGET; + BUBBLING_PHASE = Event.BUBBLING_PHASE; + CAPTURING_PHASE = Event.CAPTURING_PHASE; + bubbles: boolean; + cancelable: boolean; + composed: boolean; + timeStamp = 123456; + isTrusted = true; + eventPhase = Event.NONE; + target = null; + currentTarget = null; + srcElement = null; + cancelBubble = false; + defaultPrevented = false; + returnValue = false; + + preventDefault() { + this.defaultPrevented = true; + this.returnValue = true; + } + + stopPropagation() {} + + stopImmediatePropagation() {} + + initEvent() {} + + waitUntil() {} + + composedPath() { + return []; + } + + constructor(public type: string, options: EventInit = {}) { + this.bubbles = options.bubbles ?? false; + this.cancelable = options.cancelable ?? false; + this.composed = options.composed ?? false; + } +} diff --git a/packages-exp/messaging-exp/src/testing/fakes/token-details.ts b/packages-exp/messaging-exp/src/testing/fakes/token-details.ts new file mode 100644 index 00000000000..73eea06b2e5 --- /dev/null +++ b/packages-exp/messaging-exp/src/testing/fakes/token-details.ts @@ -0,0 +1,36 @@ +/** + * @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 { FakePushSubscription } from './service-worker'; +import { TokenDetails } from '../../interfaces/token-details'; +import { arrayToBase64 } from '../../helpers/array-base64-translator'; + +export function getFakeTokenDetails(): TokenDetails { + const subscription = new FakePushSubscription(); + + return { + token: 'token-value', + createTime: 1234567890, + subscriptionOptions: { + swScope: '/scope-value', + vapidKey: arrayToBase64(new TextEncoder().encode('vapid-key-value')), // 'dmFwaWQta2V5LXZhbHVl', + endpoint: subscription.endpoint, + auth: arrayToBase64(subscription.getKey('auth')), // 'YXV0aC12YWx1ZQ' + p256dh: arrayToBase64(subscription.getKey('p256dh')) // 'cDI1Ni12YWx1ZQ' + } + }; +} diff --git a/packages-exp/messaging-exp/src/testing/setup.ts b/packages-exp/messaging-exp/src/testing/setup.ts new file mode 100644 index 00000000000..0d55cf43ef2 --- /dev/null +++ b/packages-exp/messaging-exp/src/testing/setup.ts @@ -0,0 +1,33 @@ +/** + * @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 * as chaiAsPromised from 'chai-as-promised'; +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); + +afterEach(async () => { + restore(); + await dbDelete(); + await deleteDb('fcm_token_details_db'); +}); diff --git a/packages-exp/messaging-exp/src/testing/sinon-types.ts b/packages-exp/messaging-exp/src/testing/sinon-types.ts new file mode 100644 index 00000000000..13eafbac969 --- /dev/null +++ b/packages-exp/messaging-exp/src/testing/sinon-types.ts @@ -0,0 +1,30 @@ +/** + * @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 { SinonSpy, SinonStub } from 'sinon'; + +// Helper types for Sinon stubs and spies. + +export type Stub any> = SinonStub< + Parameters, + ReturnType +>; + +export type Spy any> = SinonSpy< + Parameters, + ReturnType +>; diff --git a/packages-exp/messaging-exp/src/util/constants.ts b/packages-exp/messaging-exp/src/util/constants.ts new file mode 100644 index 00000000000..4bafce33b0a --- /dev/null +++ b/packages-exp/messaging-exp/src/util/constants.ts @@ -0,0 +1,34 @@ +/** + * @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. + */ + +export const DEFAULT_SW_PATH = '/firebase-messaging-sw.js'; +export const DEFAULT_SW_SCOPE = '/firebase-cloud-messaging-push-scope'; + +export const DEFAULT_VAPID_KEY = + 'BDOU99-h67HcA6JeFXHbSNMu7e2yNNu3RzoMj8TM4W88jITfq7ZmPvIM1Iv-4_l2LxQcYwhqby2xGpWwzjfAnG4'; + +export const ENDPOINT = 'https://fcmregistrations.googleapis.com/v1'; + +/** Key of FCM Payload in Notification's data field. */ +export const FCM_MSG = 'FCM_MSG'; + +export const CONSOLE_CAMPAIGN_ID = 'google.c.a.c_id'; +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-exp/messaging-exp/src/util/errors.ts b/packages-exp/messaging-exp/src/util/errors.ts new file mode 100644 index 00000000000..6a8fca2fdef --- /dev/null +++ b/packages-exp/messaging-exp/src/util/errors.ts @@ -0,0 +1,93 @@ +/** + * @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 { ErrorFactory, ErrorMap } from '@firebase/util'; + +export const enum ErrorCode { + MISSING_APP_CONFIG_VALUES = 'missing-app-config-values', + AVAILABLE_IN_WINDOW = 'only-available-in-window', + AVAILABLE_IN_SW = 'only-available-in-sw', + PERMISSION_DEFAULT = 'permission-default', + PERMISSION_BLOCKED = 'permission-blocked', + UNSUPPORTED_BROWSER = 'unsupported-browser', + FAILED_DEFAULT_REGISTRATION = 'failed-service-worker-registration', + TOKEN_SUBSCRIBE_FAILED = 'token-subscribe-failed', + TOKEN_SUBSCRIBE_NO_TOKEN = 'token-subscribe-no-token', + TOKEN_UNSUBSCRIBE_FAILED = 'token-unsubscribe-failed', + TOKEN_UPDATE_FAILED = 'token-update-failed', + TOKEN_UPDATE_NO_TOKEN = 'token-update-no-token', + INVALID_BG_HANDLER = 'invalid-bg-handler', + USE_SW_AFTER_GET_TOKEN = 'use-sw-after-get-token', + INVALID_SW_REGISTRATION = 'invalid-sw-registration', + USE_VAPID_KEY_AFTER_GET_TOKEN = 'use-vapid-key-after-get-token', + INVALID_VAPID_KEY = 'invalid-vapid-key' +} + +export const ERROR_MAP: ErrorMap = { + [ErrorCode.MISSING_APP_CONFIG_VALUES]: + 'Missing App configuration value: "{$valueName}"', + [ErrorCode.AVAILABLE_IN_WINDOW]: + 'This method is available in a Window context.', + [ErrorCode.AVAILABLE_IN_SW]: + 'This method is available in a service worker context.', + [ErrorCode.PERMISSION_DEFAULT]: + 'The notification permission was not granted and dismissed instead.', + [ErrorCode.PERMISSION_BLOCKED]: + 'The notification permission was not granted and blocked instead.', + [ErrorCode.UNSUPPORTED_BROWSER]: + "This browser doesn't support the API's required to use the firebase SDK.", + [ErrorCode.FAILED_DEFAULT_REGISTRATION]: + 'We are unable to register the default service worker. {$browserErrorMessage}', + [ErrorCode.TOKEN_SUBSCRIBE_FAILED]: + 'A problem occurred while subscribing the user to FCM: {$errorInfo}', + [ErrorCode.TOKEN_SUBSCRIBE_NO_TOKEN]: + 'FCM returned no token when subscribing the user to push.', + [ErrorCode.TOKEN_UNSUBSCRIBE_FAILED]: + 'A problem occurred while unsubscribing the ' + + 'user from FCM: {$errorInfo}', + [ErrorCode.TOKEN_UPDATE_FAILED]: + 'A problem occurred while updating the user from FCM: {$errorInfo}', + [ErrorCode.TOKEN_UPDATE_NO_TOKEN]: + 'FCM returned no token when updating the user to push.', + [ErrorCode.USE_SW_AFTER_GET_TOKEN]: + 'The useServiceWorker() method may only be called once and must be ' + + 'called before calling getToken() to ensure your service worker is used.', + [ErrorCode.INVALID_SW_REGISTRATION]: + 'The input to useServiceWorker() must be a ServiceWorkerRegistration.', + [ErrorCode.INVALID_BG_HANDLER]: + 'The input to setBackgroundMessageHandler() must be a function.', + [ErrorCode.INVALID_VAPID_KEY]: 'The public VAPID key must be a string.', + [ErrorCode.USE_VAPID_KEY_AFTER_GET_TOKEN]: + 'The usePublicVapidKey() method may only be called once and must be ' + + 'called before calling getToken() to ensure your VAPID key is used.' +}; + +interface ErrorParams { + [ErrorCode.MISSING_APP_CONFIG_VALUES]: { + valueName: string; + }; + [ErrorCode.FAILED_DEFAULT_REGISTRATION]: { browserErrorMessage: string }; + [ErrorCode.TOKEN_SUBSCRIBE_FAILED]: { errorInfo: string }; + [ErrorCode.TOKEN_UNSUBSCRIBE_FAILED]: { errorInfo: string }; + [ErrorCode.TOKEN_UPDATE_FAILED]: { errorInfo: string }; +} + +export const ERROR_FACTORY = new ErrorFactory( + 'messaging', + 'Messaging', + ERROR_MAP +); diff --git a/packages-exp/messaging-exp/src/util/sw-types.ts b/packages-exp/messaging-exp/src/util/sw-types.ts new file mode 100644 index 00000000000..9c54a5ccc03 --- /dev/null +++ b/packages-exp/messaging-exp/src/util/sw-types.ts @@ -0,0 +1,114 @@ +/** + * @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. + */ + +/** + * 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. + */ + +/* 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. +interface ServiceWorkerGlobalScope { + readonly location: WorkerLocation; + readonly clients: Clients; + readonly registration: ServiceWorkerRegistration; + addEventListener( + type: K, + listener: ( + this: ServiceWorkerGlobalScope, + ev: ServiceWorkerGlobalScopeEventMap[K] + ) => any, + options?: boolean | AddEventListenerOptions + ): void; +} + +// Same as the previous interface +interface ServiceWorkerGlobalScopeEventMap { + notificationclick: NotificationEvent; + push: PushEvent; + pushsubscriptionchange: PushSubscriptionChangeEvent; +} + +interface Client { + readonly id: string; + readonly type: ClientTypes; + readonly url: string; + postMessage(message: any, transfer?: Transferable[]): void; +} + +interface ClientQueryOptions { + includeReserved?: boolean; + includeUncontrolled?: boolean; + type?: ClientTypes; +} + +interface WindowClient extends Client { + readonly focused: boolean; + readonly visibilityState: VisibilityState; + focus(): Promise; + navigate(url: string): Promise; +} + +interface Clients { + claim(): Promise; + get(id: string): Promise; + matchAll(options?: ClientQueryOptions): Promise; + openWindow(url: string): Promise; +} + +interface ExtendableEvent extends Event { + waitUntil(f: Promise): void; +} + +interface NotificationEvent extends ExtendableEvent { + readonly action: string; + readonly notification: Notification; +} + +interface PushMessageData { + arrayBuffer(): ArrayBuffer; + blob(): Blob; + json(): any; + text(): string; +} + +interface PushEvent extends ExtendableEvent { + readonly data: PushMessageData | null; +} + +interface PushSubscriptionChangeEvent extends ExtendableEvent { + readonly newSubscription: PushSubscription | null; + readonly oldSubscription: PushSubscription | null; +} + +interface WorkerLocation { + readonly hash: string; + readonly host: string; + readonly hostname: string; + readonly href: string; + readonly origin: string; + readonly pathname: string; + readonly port: string; + readonly protocol: string; + readonly search: string; + toString(): string; +} diff --git a/packages-exp/messaging-exp/tsconfig.json b/packages-exp/messaging-exp/tsconfig.json new file mode 100644 index 00000000000..4b63b47c5b5 --- /dev/null +++ b/packages-exp/messaging-exp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "noUnusedLocals": true, + "lib": [ + "dom", + "es2017" + ], + "downlevelIteration": true + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages-exp/messaging-types-exp/README.md b/packages-exp/messaging-types-exp/README.md new file mode 100644 index 00000000000..383e4f60710 --- /dev/null +++ b/packages-exp/messaging-types-exp/README.md @@ -0,0 +1,3 @@ +# @firebase/messaging-types + +**This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.** diff --git a/packages-exp/messaging-types-exp/index.d.ts b/packages-exp/messaging-types-exp/index.d.ts new file mode 100644 index 00000000000..0d69cd19bc7 --- /dev/null +++ b/packages-exp/messaging-types-exp/index.d.ts @@ -0,0 +1,94 @@ +/** + * @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 { + Observer, + Unsubscribe, + NextFn, + ErrorFn, + 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 { + /** 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, + completed?: CompleteFn + ): Unsubscribe; + /** + * @deprecated Use Notification.requestPermission() instead. + * https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission + */ + requestPermission(): Promise; + setBackgroundMessageHandler( + callback: (payload: any) => Promise | void + ): void; + useServiceWorker(registration: ServiceWorkerRegistration): void; + usePublicVapidKey(b64PublicKey: string): void; +} + +export type FirebaseMessagingName = 'messaging'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'messaging': FirebaseMessaging; + } +} diff --git a/packages-exp/messaging-types-exp/package.json b/packages-exp/messaging-types-exp/package.json new file mode 100644 index 00000000000..6140123a772 --- /dev/null +++ b/packages-exp/messaging-types-exp/package.json @@ -0,0 +1,29 @@ +{ + "name": "@firebase/messaging-types-exp", + "private": true, + "version": "0.0.800", + "description": "@firebase/messaging Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc", + "test:ci": "node ../../scripts/run_tests_in_ci.js" + }, + "files": [ + "index.d.ts" + ], + "peerDependencies": { + "@firebase/app-types": "0.x" + }, + "repository": { + "directory": "packages/messaging-types", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "devDependencies": { + "typescript": "4.0.2" + } +} diff --git a/packages-exp/messaging-types-exp/tsconfig.json b/packages-exp/messaging-types-exp/tsconfig.json new file mode 100644 index 00000000000..9a785433d90 --- /dev/null +++ b/packages-exp/messaging-types-exp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": [ + "dist/**/*" + ] +} From 31fa5a1541b310317ffb2f7692aee926858a91d8 Mon Sep 17 00:00:00 2001 From: kai Date: Tue, 22 Sep 2020 14:12:38 -0700 Subject: [PATCH 02/17] Update dependecies to exp --- .../messaging-exp/src/helpers/externalizePayload.test.ts | 2 +- packages-exp/messaging-exp/src/helpers/externalizePayload.ts | 2 +- .../messaging-exp/src/helpers/extract-app-config.test.ts | 2 +- packages-exp/messaging-exp/src/helpers/extract-app-config.ts | 2 +- packages-exp/messaging-exp/src/index.ts | 4 ++-- .../messaging-exp/src/interfaces/internal-dependencies.ts | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages-exp/messaging-exp/src/helpers/externalizePayload.test.ts b/packages-exp/messaging-exp/src/helpers/externalizePayload.test.ts index c42fb662cac..fdf782107cc 100644 --- a/packages-exp/messaging-exp/src/helpers/externalizePayload.test.ts +++ b/packages-exp/messaging-exp/src/helpers/externalizePayload.test.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { MessagePayload } from '@firebase/messaging-types'; +import { MessagePayload } from '@firebase/messaging-types-exp'; import { MessagePayloadInternal } from '../interfaces/internal-message-payload'; import { expect } from 'chai'; import { externalizePayload } from './externalizePayload'; diff --git a/packages-exp/messaging-exp/src/helpers/externalizePayload.ts b/packages-exp/messaging-exp/src/helpers/externalizePayload.ts index f86cafd089e..57ca6b26e59 100644 --- a/packages-exp/messaging-exp/src/helpers/externalizePayload.ts +++ b/packages-exp/messaging-exp/src/helpers/externalizePayload.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { MessagePayload } from '@firebase/messaging-types'; +import { MessagePayload } from '@firebase/messaging-types-exp'; import { MessagePayloadInternal } from '../interfaces/internal-message-payload'; export function externalizePayload( diff --git a/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts b/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts index 27d27c18aee..24a727d8f89 100644 --- a/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts +++ b/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts @@ -18,7 +18,7 @@ import '../testing/setup'; import { AppConfig } from '../interfaces/app-config'; -import { FirebaseApp } from '@firebase/app-types'; +import { FirebaseApp } from '@firebase/app-types-exp'; import { expect } from 'chai'; import { extractAppConfig } from './extract-app-config'; import { getFakeApp } from '../testing/fakes/firebase-dependencies'; diff --git a/packages-exp/messaging-exp/src/helpers/extract-app-config.ts b/packages-exp/messaging-exp/src/helpers/extract-app-config.ts index e95a45ced7e..8007f35e2c3 100644 --- a/packages-exp/messaging-exp/src/helpers/extract-app-config.ts +++ b/packages-exp/messaging-exp/src/helpers/extract-app-config.ts @@ -16,7 +16,7 @@ */ import { ERROR_FACTORY, ErrorCode } from '../util/errors'; -import { FirebaseApp, FirebaseOptions } from '@firebase/app-types'; +import { FirebaseApp, FirebaseOptions } from '@firebase/app-types-exp'; import { AppConfig } from '../interfaces/app-config'; import { FirebaseError } from '@firebase/util'; diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index 621d1e126a0..bc848855966 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -25,7 +25,7 @@ import { } from '@firebase/component'; import { ERROR_FACTORY, ErrorCode } from './util/errors'; -import { FirebaseMessaging } from '@firebase/messaging-types'; +import { FirebaseMessaging } from '@firebase/messaging-types-exp'; import { MessagingService } from './api'; import { _FirebaseNamespace } from '@firebase/app-types/private'; import { _registerComponent } from '@firebase/app-exp'; @@ -38,7 +38,7 @@ const NAMESPACE_EXPORTS = { /** * Define extension behavior of `registerMessaging` */ -declare module '@firebase/app-types' { +declare module '@firebase/app-types-exp' { interface FirebaseNamespace { messaging: { (app?: FirebaseApp): FirebaseMessaging; diff --git a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts index ecc380f2089..916420eded2 100644 --- a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts +++ b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts @@ -17,8 +17,8 @@ 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 { FirebaseApp } from '@firebase/app-types-exp'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; import { Provider } from '@firebase/component'; export interface FirebaseInternalDependencies { From 6b0471507c452c4a3feff5b5d1bb3a96a37c85ff Mon Sep 17 00:00:00 2001 From: kai Date: Thu, 24 Sep 2020 16:06:20 -0700 Subject: [PATCH 03/17] update app dependency --- packages-exp/messaging-exp/package.json | 2 +- packages-exp/messaging-exp/src/api.ts | 2 +- .../messaging-exp/src/helpers/extract-app-config.test.ts | 2 -- packages-exp/messaging-exp/src/index.ts | 2 -- .../messaging-exp/src/interfaces/internal-dependencies.ts | 2 +- .../src/testing/fakes/firebase-dependencies.ts | 8 ++++---- 6 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages-exp/messaging-exp/package.json b/packages-exp/messaging-exp/package.json index bf6ead9bc1c..0822fcdebfe 100644 --- a/packages-exp/messaging-exp/package.json +++ b/packages-exp/messaging-exp/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@firebase/component": "0.1.19", - "@firebase/installations-exp": "0.0.800", + "@firebase/installations": "0.4.17", "@firebase/messaging-types-exp": "0.0.800", "@firebase/util": "0.3.2", "eslint": "^7.3.1", diff --git a/packages-exp/messaging-exp/src/api.ts b/packages-exp/messaging-exp/src/api.ts index 9eaeaf3f014..7aff5c458ca 100644 --- a/packages-exp/messaging-exp/src/api.ts +++ b/packages-exp/messaging-exp/src/api.ts @@ -69,7 +69,7 @@ export class MessagingService implements _FirebaseService { readonly swController: SwController | null = null; constructor(container: ComponentContainer) { - const app = container.getProvider('app').getImmediate(); + const app = container.getProvider('app-exp').getImmediate(); const appConfig = extractAppConfig(app); const installations = container.getProvider('installations').getImmediate(); const analyticsProvider = container.getProvider('analytics-internal'); diff --git a/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts b/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts index 24a727d8f89..796717e2b33 100644 --- a/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts +++ b/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts @@ -42,14 +42,12 @@ describe('extractAppConfig', () => { ).to.throw('Missing App configuration value: "App Configuration Object"'); let firebaseApp = getFakeApp(); - // @ts-expect-error delete firebaseApp.options; expect(() => extractAppConfig(firebaseApp)).to.throw( 'Missing App configuration value: "App Configuration Object"' ); firebaseApp = getFakeApp(); - // @ts-expect-error delete firebaseApp.name; expect(() => extractAppConfig(firebaseApp)).to.throw( 'Missing App configuration value: "App Name"' diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index bc848855966..44a75816401 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -15,8 +15,6 @@ * limitations under the License. */ -import '@firebase/installations-exp'; - import { Component, ComponentContainer, diff --git a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts index 916420eded2..22c70e68f77 100644 --- a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts +++ b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts @@ -18,7 +18,7 @@ import { AppConfig } from './app-config'; import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; import { FirebaseApp } from '@firebase/app-types-exp'; -import { FirebaseInstallations } from '@firebase/installations-types-exp'; +import { FirebaseInstallations } from '@firebase/installations-types'; import { Provider } from '@firebase/component'; export interface FirebaseInternalDependencies { diff --git a/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts b/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts index 1f3da9a76ca..7591858bce7 100644 --- a/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts +++ b/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts @@ -21,7 +21,7 @@ import { } from '@firebase/analytics-interop-types'; import { FirebaseApp, FirebaseOptions } from '@firebase/app-types-exp'; -import { FirebaseInstallations } from '@firebase/installations-types-exp'; +import { FirebaseInstallations } from '@firebase/installations-types'; import { FirebaseInternalDependencies } from '../../interfaces/internal-dependencies'; import { Provider } from '@firebase/component'; import { extractAppConfig } from '../../helpers/extract-app-config'; @@ -38,7 +38,7 @@ export function getFakeFirebaseDependencies( }; } -export function getFakeApp(options: FirebaseOptions = {}): FirebaseApp { +export function getFakeApp(options: FirebaseOptions = {}): any { options = { apiKey: 'apiKey', projectId: 'projectId', @@ -54,8 +54,8 @@ export function getFakeApp(options: FirebaseOptions = {}): FirebaseApp { options, automaticDataCollectionEnabled: true, delete: async () => {}, - messaging: (() => null as unknown) as FirebaseApp['messaging'] - // installations: () => getFakeInstallations() + messaging: (() => null as unknown) as FirebaseApp['messaging'], + installations: () => getFakeInstallations() }; } From d777e336166ec512a91c85fec7d9b12af599c7b7 Mon Sep 17 00:00:00 2001 From: Kai Wu Date: Fri, 25 Sep 2020 09:51:40 -0700 Subject: [PATCH 04/17] Update README.md --- packages-exp/messaging-types-exp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-exp/messaging-types-exp/README.md b/packages-exp/messaging-types-exp/README.md index 383e4f60710..c10e1551f13 100644 --- a/packages-exp/messaging-types-exp/README.md +++ b/packages-exp/messaging-types-exp/README.md @@ -1,3 +1,3 @@ -# @firebase/messaging-types +# @firebase/messaging-types-exp **This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.** From de78923ba8f05a92fd33f80a3702048e08098e5f Mon Sep 17 00:00:00 2001 From: kai Date: Mon, 28 Sep 2020 10:46:03 -0700 Subject: [PATCH 05/17] Integrate w/ API checker --- packages-exp/messaging-exp/api-extractor.json | 8 ++++++++ packages-exp/messaging-exp/package.json | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 packages-exp/messaging-exp/api-extractor.json diff --git a/packages-exp/messaging-exp/api-extractor.json b/packages-exp/messaging-exp/api-extractor.json new file mode 100644 index 00000000000..44b475ca490 --- /dev/null +++ b/packages-exp/messaging-exp/api-extractor.json @@ -0,0 +1,8 @@ +{ + "extends": "../../config/api-extractor.json", + // Point it to your entry point d.ts file. + "mainEntryPointFilePath": "/dist/index.d.ts", + "dtsRollup": { + "enabled": true + } +} diff --git a/packages-exp/messaging-exp/package.json b/packages-exp/messaging-exp/package.json index 0822fcdebfe..3c23cb39acd 100644 --- a/packages-exp/messaging-exp/package.json +++ b/packages-exp/messaging-exp/package.json @@ -13,8 +13,8 @@ "scripts": { "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", - "build": "rollup -c", - "build:deps": "lerna run --scope @firebase/'{app,messaging}' --include-dependencies build", + "build": "rollup -c && yarn api-report", + "build:deps": "lerna run --scope @firebase/'{app-exp,messaging-exp}' --include-dependencies build", "dev": "rollup -c -w", "test": "run-p test:karma type-check lint ", "test:integration": "run-p test:karma type-check lint && cd ../../integration/messaging && npm run-script test", @@ -22,6 +22,7 @@ "test:karma": "karma start --single-run", "test:debug": "karma start --browsers=Chrome --auto-watch", "prepare": "yarn build", + "api-report": "api-extractor run --local --verbose", "type-check": "tsc --noEmit" }, "license": "Apache-2.0", From 724bba935897ea5bc2208f3fa65bc572ac14759f Mon Sep 17 00:00:00 2001 From: kai Date: Thu, 29 Oct 2020 10:30:44 -0700 Subject: [PATCH 06/17] Removing deps that are already included in the top level --- packages-exp/messaging-exp/package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages-exp/messaging-exp/package.json b/packages-exp/messaging-exp/package.json index 3c23cb39acd..1e417180663 100644 --- a/packages-exp/messaging-exp/package.json +++ b/packages-exp/messaging-exp/package.json @@ -35,9 +35,7 @@ "@firebase/installations": "0.4.17", "@firebase/messaging-types-exp": "0.0.800", "@firebase/util": "0.3.2", - "eslint": "^7.3.1", "idb": "3.0.2", - "npm-run-all": "^4.1.5", "tslib": "^1.11.1" }, "devDependencies": { From 1c49b446dc1595a4b5e791642f9432e2d2c5fd4f Mon Sep 17 00:00:00 2001 From: kai Date: Mon, 16 Nov 2020 16:33:50 -0800 Subject: [PATCH 07/17] Mod Change --- .vscode/settings.json | 7 +- common/api-review/messaging-exp.api.md | 10 + packages-exp/messaging-exp/package.json | 2 +- packages-exp/messaging-exp/rollup.config.js | 2 +- packages-exp/messaging-exp/src/api.ts | 55 +- .../messaging-exp/src/api/deleteToken.ts | 36 + .../messaging-exp/src/api/getToken.ts | 53 ++ .../src/api/onBackgroundMessage.ts | 41 ++ .../messaging-exp/src/api/onMessage.ts | 42 ++ .../src/controllers/window-controller.test.ts | 639 ------------------ .../src/controllers/window-controller.ts | 301 --------- .../src/core/token-management.test.ts | 296 -------- .../messaging-exp/src/helpers/logToScion.ts | 56 ++ .../src/helpers/registerDefaultSw.ts | 47 ++ .../messaging-exp/src/helpers/updateSwReg.ts | 40 ++ .../src/helpers/updateVapidKey.ts | 30 + packages-exp/messaging-exp/src/index.ts | 17 +- .../src/interfaces/internal-dependencies.ts | 2 +- .../idb-manager.test.ts | 4 +- .../src/{helpers => internals}/idb-manager.ts | 2 +- .../requests.test.ts} | 9 +- .../{core/api.ts => internals/requests.ts} | 3 +- .../src/internals/token-manager.test.ts | 194 ++++++ .../token-manager.ts} | 86 +-- .../src/listeners/messageEventListener.ts | 59 ++ .../sw-controller.test.ts | 195 +----- .../sw-controller.ts | 177 +---- .../messaging-exp/src/messaging-service.ts | 63 ++ .../testing/fakes/firebase-dependencies.ts | 8 +- .../messaging-exp/src/testing/setup.ts | 2 +- packages-exp/messaging-types-exp/index.d.ts | 41 +- 31 files changed, 812 insertions(+), 1707 deletions(-) create mode 100644 common/api-review/messaging-exp.api.md create mode 100644 packages-exp/messaging-exp/src/api/deleteToken.ts create mode 100644 packages-exp/messaging-exp/src/api/getToken.ts create mode 100644 packages-exp/messaging-exp/src/api/onBackgroundMessage.ts create mode 100644 packages-exp/messaging-exp/src/api/onMessage.ts delete mode 100644 packages-exp/messaging-exp/src/controllers/window-controller.test.ts delete mode 100644 packages-exp/messaging-exp/src/controllers/window-controller.ts delete mode 100644 packages-exp/messaging-exp/src/core/token-management.test.ts create mode 100644 packages-exp/messaging-exp/src/helpers/logToScion.ts create mode 100644 packages-exp/messaging-exp/src/helpers/registerDefaultSw.ts create mode 100644 packages-exp/messaging-exp/src/helpers/updateSwReg.ts create mode 100644 packages-exp/messaging-exp/src/helpers/updateVapidKey.ts rename packages-exp/messaging-exp/src/{helpers => internals}/idb-manager.test.ts (96%) rename packages-exp/messaging-exp/src/{helpers => internals}/idb-manager.ts (98%) rename packages-exp/messaging-exp/src/{core/api.test.ts => internals/requests.test.ts} (96%) rename packages-exp/messaging-exp/src/{core/api.ts => internals/requests.ts} (97%) create mode 100644 packages-exp/messaging-exp/src/internals/token-manager.test.ts rename packages-exp/messaging-exp/src/{core/token-management.ts => internals/token-manager.ts} (67%) create mode 100644 packages-exp/messaging-exp/src/listeners/messageEventListener.ts rename packages-exp/messaging-exp/src/{controllers => listeners}/sw-controller.test.ts (72%) rename packages-exp/messaging-exp/src/{controllers => listeners}/sw-controller.ts (57%) create mode 100644 packages-exp/messaging-exp/src/messaging-service.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 1a4875b99aa..6bbd0cb657f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,10 @@ "**/node_modules": true }, "typescript.tsdk": "node_modules/typescript/lib", - "files.associations": { "*.json": "jsonc" } + "files.associations": { + "*.json": "jsonc" + }, + "cSpell.words": [ + "unregisters" + ] } diff --git a/common/api-review/messaging-exp.api.md b/common/api-review/messaging-exp.api.md new file mode 100644 index 00000000000..4c156ec6f1c --- /dev/null +++ b/common/api-review/messaging-exp.api.md @@ -0,0 +1,10 @@ +## API Report File for "@firebase/messaging-exp" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages-exp/messaging-exp/package.json b/packages-exp/messaging-exp/package.json index 1e417180663..c57c2e024c4 100644 --- a/packages-exp/messaging-exp/package.json +++ b/packages-exp/messaging-exp/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@firebase/component": "0.1.19", - "@firebase/installations": "0.4.17", + "@firebase/installations-exp": "0.x", "@firebase/messaging-types-exp": "0.0.800", "@firebase/util": "0.3.2", "idb": "3.0.2", diff --git a/packages-exp/messaging-exp/rollup.config.js b/packages-exp/messaging-exp/rollup.config.js index 693cea56ae1..3d8120f271e 100644 --- a/packages-exp/messaging-exp/rollup.config.js +++ b/packages-exp/messaging-exp/rollup.config.js @@ -1,6 +1,6 @@ /** * @license - * Copyright 2018 Google Inc. + * 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. diff --git a/packages-exp/messaging-exp/src/api.ts b/packages-exp/messaging-exp/src/api.ts index 7aff5c458ca..3c509629768 100644 --- a/packages-exp/messaging-exp/src/api.ts +++ b/packages-exp/messaging-exp/src/api.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { ComponentContainer, Provider } from '@firebase/component'; import { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; import { FirebaseMessaging, @@ -23,72 +22,44 @@ import { } from '@firebase/messaging-types-exp'; import { NextFn, Observer, Unsubscribe } from '@firebase/util'; -import { FirebaseInternalDependencies } from './interfaces/internal-dependencies'; -import { SwController } from './controllers/sw-controller'; -import { WindowController } from './controllers/window-controller'; +import { MessagingService } from './messaging-service'; +import { Provider } from '@firebase/component'; +import { deleteToken as _deleteToken } from './api/deleteToken'; import { _getProvider } from '@firebase/app-exp'; -import { extractAppConfig } from './helpers/extract-app-config'; +import { getToken as _getToken } from './api/getToken'; +import { onBackgroundMessage as _onBackgroundMessage } from './api/onBackgroundMessage'; +import { onMessage as _onMessage } from './api/onMessage'; export function getMessaging(app: FirebaseApp): FirebaseMessaging { - const messagingProvider: Provider<'messaging'> = _getProvider( + const messagingProvider: Provider<'messaging-exp'> = _getProvider( app, - 'messaging' + 'messaging-exp' ); return messagingProvider.getImmediate(); } -export function getToken( +export async function getToken( messaging: FirebaseMessaging, options?: { vapidKey?: string; swReg?: ServiceWorkerRegistration } ): Promise { - return messaging.getToken(options); + return _getToken(messaging as MessagingService, options); } export function deleteToken(messaging: FirebaseMessaging): Promise { - return messaging.deleteToken(); + return _deleteToken(messaging as MessagingService); } export function onMessage( messaging: FirebaseMessaging, nextOrObserver: NextFn | Observer ): Unsubscribe { - return messaging.onMessage(nextOrObserver); + return _onMessage(messaging as MessagingService, nextOrObserver); } export function onBackgroundMessage( messaging: FirebaseMessaging, nextOrObserver: NextFn | Observer ): Unsubscribe { - return messaging.onBackgroundMessage(nextOrObserver); -} - -export class MessagingService implements _FirebaseService { - app!: FirebaseApp; - readonly windowController: WindowController | null = null; - readonly swController: SwController | null = null; - - constructor(container: ComponentContainer) { - const app = container.getProvider('app-exp').getImmediate(); - const appConfig = extractAppConfig(app); - const installations = container.getProvider('installations').getImmediate(); - const analyticsProvider = container.getProvider('analytics-internal'); - - const firebaseDependencies: FirebaseInternalDependencies = { - app, - appConfig, - installations, - analyticsProvider - }; - - if (self && 'ServiceWorkerGlobalScope' in self) { - this.swController = new SwController(firebaseDependencies); - } else { - this.windowController = new WindowController(firebaseDependencies); - } - } - - _delete(): Promise { - throw new Error('Method not implemented.'); - } + return _onBackgroundMessage(messaging as MessagingService, nextOrObserver); } diff --git a/packages-exp/messaging-exp/src/api/deleteToken.ts b/packages-exp/messaging-exp/src/api/deleteToken.ts new file mode 100644 index 00000000000..5a1725f2f0d --- /dev/null +++ b/packages-exp/messaging-exp/src/api/deleteToken.ts @@ -0,0 +1,36 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +import { MessagingService } from '../messaging-service'; +import { deleteTokenInternal } from '../internals/token-manager'; +import { registerDefaultSw } from '../helpers/registerDefaultSw'; + +export async function deleteToken( + messaging: MessagingService +): Promise { + if (!navigator) { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); + } + + if (!messaging.swRegistration) { + await registerDefaultSw(messaging); + } + + return deleteTokenInternal(messaging); +} diff --git a/packages-exp/messaging-exp/src/api/getToken.ts b/packages-exp/messaging-exp/src/api/getToken.ts new file mode 100644 index 00000000000..46e85af7983 --- /dev/null +++ b/packages-exp/messaging-exp/src/api/getToken.ts @@ -0,0 +1,53 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +import { MessagingService } from '../messaging-service'; +import { getTokenInternal } from '../internals/token-manager'; +import { messageEventListener } from '../listeners/messageEventListener'; +import { updateSwReg } from '../helpers/updateSwReg'; +import { updateVapidKey } from '../helpers/updateVapidKey'; + +export async function getToken( + messaging: MessagingService, + options?: { + vapidKey?: string; + serviceWorkerRegistration?: ServiceWorkerRegistration; + } +): Promise { + if (!navigator) { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); + } + + navigator.serviceWorker.addEventListener('message', e => + messageEventListener(messaging, e) + ); + + if (Notification.permission === 'default') { + await Notification.requestPermission(); + } + + if (Notification.permission !== 'granted') { + throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); + } + + await updateVapidKey(messaging, options?.vapidKey); + await updateSwReg(messaging, options?.serviceWorkerRegistration); + + return getTokenInternal(messaging); +} diff --git a/packages-exp/messaging-exp/src/api/onBackgroundMessage.ts b/packages-exp/messaging-exp/src/api/onBackgroundMessage.ts new file mode 100644 index 00000000000..3bfa5c1d4ff --- /dev/null +++ b/packages-exp/messaging-exp/src/api/onBackgroundMessage.ts @@ -0,0 +1,41 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { NextFn, Observer, Unsubscribe } from '@firebase/util'; + +import { MessagePayload } from '@firebase/messaging-types-exp'; +import { MessagingService } from '../messaging-service'; +import { SwController } from '../listeners/sw-controller'; + +export function onBackgroundMessage( + messaging: MessagingService, + nextOrObserver: NextFn | Observer +): Unsubscribe { + if (!!navigator) { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_SW); + } + + // Initialize swController which resister listeners for onPush, onSubChange, onNotificationClick. + new SwController(messaging); + + messaging.onBackgroundMessageHandler = nextOrObserver; + + return () => { + messaging.onBackgroundMessageHandler = null; + }; +} diff --git a/packages-exp/messaging-exp/src/api/onMessage.ts b/packages-exp/messaging-exp/src/api/onMessage.ts new file mode 100644 index 00000000000..2b65ff9d6b8 --- /dev/null +++ b/packages-exp/messaging-exp/src/api/onMessage.ts @@ -0,0 +1,42 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { NextFn, Observer, Unsubscribe } from '@firebase/util'; + +import { MessagePayload } from '@firebase/messaging-types-exp'; +import { MessagingService } from '../messaging-service'; +import { messageEventListener } from '../listeners/messageEventListener'; + +export function onMessage( + messaging: MessagingService, + nextOrObserver: NextFn | Observer +): Unsubscribe { + if (!navigator) { + throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); + } + + navigator.serviceWorker.addEventListener('message', e => + messageEventListener(messaging, e) + ); + + messaging.onMessageHandler = nextOrObserver; + + return () => { + messaging.onMessageHandler = null; + }; +} diff --git a/packages-exp/messaging-exp/src/controllers/window-controller.test.ts b/packages-exp/messaging-exp/src/controllers/window-controller.test.ts deleted file mode 100644 index 373b4eb42f7..00000000000 --- a/packages-exp/messaging-exp/src/controllers/window-controller.test.ts +++ /dev/null @@ -1,639 +0,0 @@ -/** - * @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'; - -import { - CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, - CONSOLE_CAMPAIGN_ID, - CONSOLE_CAMPAIGN_NAME, - CONSOLE_CAMPAIGN_TIME, - DEFAULT_SW_PATH, - DEFAULT_SW_SCOPE, - DEFAULT_VAPID_KEY -} from '../util/constants'; -import { - MessagePayloadInternal, - MessageType -} from '../interfaces/internal-message-payload'; -import { SinonFakeTimers, SinonSpy, spy, stub, useFakeTimers } from 'sinon'; -import { Spy, Stub } from '../testing/sinon-types'; - -import { ErrorCode } from '../util/errors'; -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'; -import { expect } from 'chai'; -import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; - -type MessageEventListener = (event: Event) => Promise; - -const ORIGINAL_SW_REGISTRATION = FakeServiceWorkerRegistration; - -describe('WindowController', () => { - let firebaseDependencies: FirebaseInternalDependencies; - let swRegistration: ServiceWorkerRegistration; - let windowController: WindowController; - - let getTokenStub: Stub; - let deleteTokenStub: Stub; - let registerStub: Stub; - let addEventListenerStub: Stub; - - /** The event listener that WindowController adds to the message event. */ - let messageEventListener: MessageEventListener; - - beforeEach(() => { - // To trick the instanceof check in useServiceWorker. - self.ServiceWorkerRegistration = FakeServiceWorkerRegistration; - - firebaseDependencies = getFakeFirebaseDependencies(); - swRegistration = new FakeServiceWorkerRegistration(); - - stub(Notification, 'permission').value('granted'); - registerStub = stub(navigator.serviceWorker, 'register').resolves( - swRegistration - ); - addEventListenerStub = stub( - navigator.serviceWorker, - 'addEventListener' - ).callsFake((type, listener) => { - expect(type).to.equal('message'); - - if ('handleEvent' in listener) { - messageEventListener = listener.handleEvent as MessageEventListener; - } else { - messageEventListener = listener as MessageEventListener; - } - }); - getTokenStub = stub(tokenManagementModule, 'getToken').resolves('fcmToken'); - deleteTokenStub = stub(tokenManagementModule, 'deleteToken').resolves(true); - - windowController = new WindowController(firebaseDependencies); - }); - - afterEach(() => { - self.ServiceWorkerRegistration = ORIGINAL_SW_REGISTRATION; - }); - - it('has app', () => { - expect(windowController.app).to.equal(firebaseDependencies.app); - }); - - it('adds the message event listener on creation', () => { - expect(addEventListenerStub).to.have.been.called; - }); - - it('throws when service-worker-only methods are called', () => { - expect(() => windowController.setBackgroundMessageHandler()).to.throw( - 'messaging/only-available-in-sw' - ); - }); - - 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'); - - try { - await windowController.getToken(); - throw new Error('should have thrown'); - } catch (err) { - expect(err.code).to.equal(`messaging/${ErrorCode.PERMISSION_BLOCKED}`); - } - }); - - it('asks for permission if permission is default', async () => { - stub(Notification, 'permission').value('default'); - const requestPermissionStub = stub( - Notification, - 'requestPermission' - ).resolves('denied'); - - try { - await windowController.getToken(); - throw new Error('should have thrown'); - } catch (err) { - expect(err.code).to.equal(`messaging/${ErrorCode.PERMISSION_BLOCKED}`); - } - - expect(requestPermissionStub).to.have.been.calledOnce; - }); - - it('registers the default SW', async () => { - await windowController.getToken(); - - expect(registerStub).to.have.been.calledOnceWith(DEFAULT_SW_PATH, { - scope: DEFAULT_SW_SCOPE - }); - }); - - it('throws if there is a failure to get SW registration', async () => { - registerStub.rejects(); - - try { - await windowController.getToken(); - throw new Error('should have thrown'); - } catch (err) { - expect(err.code).to.equal( - `messaging/${ErrorCode.FAILED_DEFAULT_REGISTRATION}` - ); - } - }); - - it('calls tokenManagement.getToken with the default SW and VAPID key', async () => { - await windowController.getToken(); - - expect(getTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - swRegistration, - DEFAULT_VAPID_KEY - ); - }); - - it('calls tokenManagement.getToken with the specified SW and VAPID key', async () => { - const differentSwRegistration = new FakeServiceWorkerRegistration(); - differentSwRegistration.scope = '/different-scope'; - - windowController.usePublicVapidKey('newVapidKey'); - windowController.useServiceWorker(differentSwRegistration); - await windowController.getToken(); - - expect(getTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - differentSwRegistration, - 'newVapidKey' - ); - }); - }); - - describe('deleteToken', () => { - it('calls tokenManagement.deleteToken with the default SW', async () => { - await windowController.deleteToken(); - - expect(deleteTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - swRegistration - ); - }); - - it('calls tokenManagement.deleteToken with the specified SW', async () => { - const differentSwRegistration = new FakeServiceWorkerRegistration(); - differentSwRegistration.scope = '/different-scope'; - - windowController.useServiceWorker(differentSwRegistration); - await windowController.deleteToken(); - - expect(deleteTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - differentSwRegistration - ); - }); - }); - - describe('requestPermission', () => { - it('should resolve if the permission is already granted', async () => { - stub(Notification, 'permission').value('granted'); - - return windowController.requestPermission(); - }); - - it('should reject if requestPermission resolves with "denied"', async () => { - stub(Notification, 'permission').value('default'); - stub(Notification, 'requestPermission').resolves('denied'); - - try { - await windowController.requestPermission(); - throw new Error('Expected an error.'); - } catch (err) { - expect(err.code).to.equal('messaging/permission-blocked'); - } - }); - - it('should reject if requestPermission resolves with "default"', async () => { - stub(Notification, 'permission').value('default'); - stub(Notification, 'requestPermission').resolves('default'); - - try { - await windowController.requestPermission(); - throw new Error('Expected an error.'); - } catch (err) { - expect(err.code).to.equal('messaging/permission-default'); - } - }); - - it('should resolve if requestPermission resolves with "granted"', async () => { - stub(Notification, 'permission').value('default'); - stub(Notification, 'requestPermission').resolves('granted'); - - return windowController.requestPermission(); - }); - }); - - describe('onMessage', () => { - it('sets the onMessage callback', async () => { - const onMessageCallback = spy(); - windowController.onMessage(onMessageCallback); - - 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: internalPayload }) - ); - - expect(onMessageCallback).to.have.been.called; - }); - - it('works with an observer', async () => { - const onMessageCallback = spy(); - windowController.onMessage({ - next: onMessageCallback, - error: () => {}, - complete: () => {} - }); - - 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: internalPayload }) - ); - - expect(onMessageCallback).to.have.been.called; - }); - - it('returns a function that clears the onMessage callback', async () => { - const onMessageCallback = spy(); - const unsubscribe = windowController.onMessage(onMessageCallback); - unsubscribe(); - - 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: internalPayload }) - ); - - expect(onMessageCallback).not.to.have.been.called; - }); - }); - - describe('usePublicVapidKey', () => { - it('throws on invalid input', () => { - expect(() => - windowController.usePublicVapidKey((null as unknown) as string) - ).to.throw('messaging/invalid-vapid-key'); - - expect(() => windowController.usePublicVapidKey('')).to.throw( - 'messaging/invalid-vapid-key' - ); - }); - - it('throws if called twice', () => { - windowController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl'); - expect(() => - windowController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl') - ).to.throw('messaging/use-vapid-key-after-get-token'); - }); - - it('throws if called after getToken', async () => { - await windowController.getToken(); - - expect(() => - windowController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl') - ).to.throw('messaging/use-vapid-key-after-get-token'); - }); - }); - - describe('useServiceWorker', () => { - it('throws on invalid input', () => { - expect(() => - windowController.useServiceWorker( - ({} as unknown) as ServiceWorkerRegistration - ) - ).to.throw('messaging/invalid-sw-registration'); - }); - - it('throws if called twice', () => { - windowController.useServiceWorker(swRegistration); - expect(() => windowController.useServiceWorker(swRegistration)).to.throw( - 'messaging/use-sw-after-get-token' - ); - }); - - it('throws if called after getToken', async () => { - await windowController.getToken(); - - expect(() => windowController.useServiceWorker(swRegistration)).to.throw( - 'messaging/use-sw-after-get-token' - ); - }); - }); - - describe('SW message event handler', () => { - let clock: SinonFakeTimers; - let onMessageSpy: SinonSpy; - let logEventSpy: Spy; - - beforeEach(() => { - clock = useFakeTimers(); - - const analytics = firebaseDependencies.analyticsProvider.getImmediate(); - logEventSpy = spy(analytics, 'logEvent'); - - onMessageSpy = spy(); - windowController.onMessage(onMessageSpy); - }); - - it('does nothing when non-fcm message is passed in', async () => { - await messageEventListener( - new MessageEvent('message', { data: 'non-fcm-message' }) - ); - - expect(onMessageSpy).not.to.have.been.called; - expect(logEventSpy).not.to.have.been.called; - }); - - it('calls onMessage callback when it receives a PUSH_RECEIVED message', async () => { - 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: internalPayload }) - ); - - 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 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: internalPayload }) - ); - - expect(onMessageSpy).not.to.have.been.called; - expect(logEventSpy).not.to.have.been.called; - }); - - it('calls analytics.logEvent if the message has analytics enabled for PUSH_RECEIVED', async () => { - 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: internalPayload }) - ); - - 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: '123456', - message_name: 'Campaign Name', - message_time: '1234567890', - message_device_time: clock.now - /* eslint-enable camelcase */ - } - ); - }); - - it('calls analytics.logEvent if the message has analytics enabled for NOTIFICATION_CLICKED', async () => { - 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: internalPayload }) - ); - - expect(onMessageSpy).not.to.have.been.called; - expect(logEventSpy).to.have.been.calledOnceWith('notification_open', { - /* eslint-disable camelcase */ - message_id: '123456', - message_name: 'Campaign Name', - message_time: '1234567890', - message_device_time: clock.now - /* eslint-enable camelcase */ - }); - }); - }); - - describe('onTokenRefresh', () => { - it('returns an unsubscribe function that does nothing', () => { - const unsubscribe = windowController.onTokenRefresh(); - expect(unsubscribe).not.to.throw; - }); - }); -}); diff --git a/packages-exp/messaging-exp/src/controllers/window-controller.ts b/packages-exp/messaging-exp/src/controllers/window-controller.ts deleted file mode 100644 index 8ae001b1b9f..00000000000 --- a/packages-exp/messaging-exp/src/controllers/window-controller.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * @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 { - CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, - CONSOLE_CAMPAIGN_ID, - CONSOLE_CAMPAIGN_NAME, - CONSOLE_CAMPAIGN_TIME, - DEFAULT_SW_PATH, - 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 { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; -import { NextFn, Observer, Unsubscribe } from '@firebase/util'; -import { deleteToken, getToken } from '../core/token-management'; - -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; -import { FirebaseMessaging } from '@firebase/messaging-types-exp'; -import { isConsoleMessage } from '../helpers/is-console-message'; - -export class WindowController implements FirebaseMessaging, _FirebaseService { - private vapidKey: string | null = null; - private swRegistration?: ServiceWorkerRegistration; - private onMessageCallback: NextFn | Observer | null = null; - - constructor( - private readonly firebaseDependencies: FirebaseInternalDependencies - ) { - navigator.serviceWorker.addEventListener('message', e => - this.messageEventListener(e) - ); - } - - _delete(): Promise { - throw new Error('Method not implemented.'); - } - - get app(): FirebaseApp { - return this.firebaseDependencies.app; - } - - private async messageEventListener(event: MessageEvent): Promise { - const internalPayload = event.data as MessagePayloadInternal; - - if (!internalPayload.isFirebaseMessaging) { - return; - } - - // 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; - } - - async getToken(options?: { - vapidKey?: string; - serviceWorkerRegistration?: ServiceWorkerRegistration; - }): Promise { - if (Notification.permission === 'default') { - await Notification.requestPermission(); - } - - if (Notification.permission !== 'granted') { - throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); - } - - 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 { - if (!this.swRegistration) { - await this.registerDefaultSw(); - } - - return deleteToken(this.firebaseDependencies, this.swRegistration!); - } - - /** - * Request permission if it is not currently granted. - * - * @return Resolves if the permission was granted, rejects otherwise. - * - * @deprecated Use Notification.requestPermission() instead. - * https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission - */ - async requestPermission(): Promise { - if (Notification.permission === 'granted') { - return; - } - - const permissionResult = await Notification.requestPermission(); - if (permissionResult === 'granted') { - return; - } else if (permissionResult === 'denied') { - throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); - } else { - throw ERROR_FACTORY.create(ErrorCode.PERMISSION_DEFAULT); - } - } - - /** - * @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); - } - - if (typeof vapidKey !== 'string' || vapidKey.length === 0) { - throw ERROR_FACTORY.create(ErrorCode.INVALID_VAPID_KEY); - } - - 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); - } - - if (this.swRegistration) { - throw ERROR_FACTORY.create(ErrorCode.USE_SW_AFTER_GET_TOKEN); - } - - this.swRegistration = swRegistration; - } - - /** - * @param nextOrObserver An observer object or a function triggered on message. - * - * @return The unsubscribe function for the observer. - */ - onMessage(nextOrObserver: NextFn | Observer): Unsubscribe { - this.onMessageCallback = nextOrObserver; - - return () => { - this.onMessageCallback = null; - }; - } - - setBackgroundMessageHandler(): void { - throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_SW); - } - - onBackgroundMessage(): Unsubscribe { - throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_SW); - } - - /** - * @deprecated No-op. It was initially designed with token rotation requests from server in mind. - * However, the plan to implement such feature was abandoned. - */ - onTokenRefresh(): Unsubscribe { - return () => {}; - } - - private async logEvent( - messageType: MessageType, - data: ConsoleMessageData - ): Promise { - const eventType = getEventType(messageType); - const analytics = await this.firebaseDependencies.analyticsProvider.get(); - analytics.logEvent(eventType, { - /* eslint-disable camelcase */ - message_id: data[CONSOLE_CAMPAIGN_ID], - message_name: data[CONSOLE_CAMPAIGN_NAME], - message_time: data[CONSOLE_CAMPAIGN_TIME], - message_device_time: Math.floor(Date.now() / 1000) - /* eslint-enable camelcase */ - }); - } -} - -function getEventType(messageType: MessageType): string { - switch (messageType) { - case MessageType.NOTIFICATION_CLICKED: - return 'notification_open'; - case MessageType.PUSH_RECEIVED: - return 'notification_foreground'; - default: - throw new Error(); - } -} - -function stripInternalFields( - internalPayload: MessagePayloadInternal -): MessagePayloadInternal { - delete internalPayload.messageType; - delete internalPayload.isFirebaseMessaging; - return internalPayload; -} diff --git a/packages-exp/messaging-exp/src/core/token-management.test.ts b/packages-exp/messaging-exp/src/core/token-management.test.ts deleted file mode 100644 index db9f3e39268..00000000000 --- a/packages-exp/messaging-exp/src/core/token-management.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * @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 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 { DEFAULT_VAPID_KEY } from '../util/constants'; -import { ErrorCode } from '../util/errors'; -import { FakeServiceWorkerRegistration } from '../testing/fakes/service-worker'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; -import { Stub } from '../testing/sinon-types'; -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; - let firebaseDependencies: FirebaseInternalDependencies; - let swRegistration: FakeServiceWorkerRegistration; - let requestGetTokenStub: Stub; - let requestUpdateTokenStub: Stub; - let requestDeleteTokenStub: Stub; - - beforeEach(() => { - useFakeTimers({ now: 1234567890 }); - - tokenDetails = getFakeTokenDetails(); - firebaseDependencies = getFakeFirebaseDependencies(); - swRegistration = new FakeServiceWorkerRegistration(); - - requestGetTokenStub = stub(apiModule, 'requestGetToken').resolves( - 'token-from-server' // new token. - ); - requestUpdateTokenStub = stub(apiModule, 'requestUpdateToken').resolves( - tokenDetails.token // same as current token. - ); - requestDeleteTokenStub = stub(apiModule, 'requestDeleteToken').resolves(); - }); - - describe('getToken', () => { - it("throws if notification permission isn't granted", async () => { - stub(Notification, 'permission').value('denied'); - - try { - await getToken(firebaseDependencies, swRegistration, DEFAULT_VAPID_KEY); - throw new Error('should have thrown'); - } catch (err) { - expect(err.code).to.equal(`messaging/${ErrorCode.PERMISSION_BLOCKED}`); - } - - expect(requestGetTokenStub).not.to.have.been.called; - expect(requestUpdateTokenStub).not.to.have.been.called; - expect(requestDeleteTokenStub).not.to.have.been.called; - }); - - it('gets a new token if there is none', async () => { - stub(Notification, 'permission').value('granted'); - - const token = await getToken( - firebaseDependencies, - swRegistration, - tokenDetails.subscriptionOptions!.vapidKey - ); - - expect(token).to.equal('token-from-server'); - expect(requestGetTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - tokenDetails.subscriptionOptions - ); - expect(requestUpdateTokenStub).not.to.have.been.called; - expect(requestDeleteTokenStub).not.to.have.been.called; - - const tokenFromDb = await dbGet(firebaseDependencies); - expect(token).to.equal(tokenFromDb!.token); - expect(tokenFromDb).to.deep.equal({ - ...tokenDetails, - token: 'token-from-server' - }); - }); - - it('deletes the token and requests a new one if the token is invalid', async () => { - stub(Notification, 'permission').value('granted'); - - await dbSet(firebaseDependencies, tokenDetails); - - // Change the auth in the Push subscription, invalidating the token. - const subscription = await swRegistration.pushManager.subscribe(); - subscription.auth = 'different-auth'; - const newAuth = arrayToBase64(subscription.getKey('auth')); - - const token = await getToken( - firebaseDependencies, - swRegistration, - tokenDetails.subscriptionOptions!.vapidKey - ); - - const expectedSubscriptionOptions: SubscriptionOptions = { - ...tokenDetails.subscriptionOptions!, - auth: newAuth - }; - - expect(token).to.equal('token-from-server'); - expect(requestGetTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - expectedSubscriptionOptions - ); - expect(requestUpdateTokenStub).not.to.have.been.called; - expect(requestDeleteTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - tokenDetails.token - ); - - const tokenFromDb = await dbGet(firebaseDependencies); - expect(token).to.equal(tokenFromDb!.token); - expect(tokenFromDb).to.deep.equal({ - ...tokenDetails, - token, - subscriptionOptions: expectedSubscriptionOptions - }); - }); - - it('deletes the token and requests a new one if the VAPID key changes', async () => { - stub(Notification, 'permission').value('granted'); - - await dbSet(firebaseDependencies, tokenDetails); - - const token = await getToken( - firebaseDependencies, - swRegistration, - 'some-other-vapid-key' - ); - - const expectedSubscriptionOptions: SubscriptionOptions = { - ...tokenDetails.subscriptionOptions!, - vapidKey: 'some-other-vapid-key' - }; - - expect(token).to.equal('token-from-server'); - expect(requestGetTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - expectedSubscriptionOptions - ); - expect(requestUpdateTokenStub).not.to.have.been.called; - expect(requestDeleteTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - tokenDetails.token - ); - - const tokenFromDb = await dbGet(firebaseDependencies); - expect(token).to.equal(tokenFromDb!.token); - expect(tokenFromDb).to.deep.equal({ - ...tokenDetails, - token, - subscriptionOptions: expectedSubscriptionOptions - }); - }); - - it('updates the token if it was last updated more than a week ago', async () => { - stub(Notification, 'permission').value('granted'); - - // Change create time to be older than a week. - tokenDetails.createTime = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days - - await dbSet(firebaseDependencies, tokenDetails); - - const token = await getToken( - firebaseDependencies, - swRegistration, - tokenDetails.subscriptionOptions!.vapidKey - ); - const expectedTokenDetails: TokenDetails = { - ...tokenDetails, - createTime: Date.now() - }; - - expect(token).to.equal(tokenDetails.token); // Same token. - expect(requestGetTokenStub).not.to.have.been.called; - expect(requestUpdateTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - expectedTokenDetails - ); - expect(requestDeleteTokenStub).not.to.have.been.called; - - const tokenFromDb = await dbGet(firebaseDependencies); - expect(token).to.equal(tokenFromDb!.token); - expect(tokenFromDb).to.deep.equal(expectedTokenDetails); - }); - - it('deletes the token if the update fails', async () => { - stub(Notification, 'permission').value('granted'); - - // Change create time to be older than a week. - tokenDetails.createTime = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days - - await dbSet(firebaseDependencies, tokenDetails); - - requestUpdateTokenStub.rejects(new Error('Update failed.')); - - await expect( - getToken( - firebaseDependencies, - swRegistration, - tokenDetails.subscriptionOptions!.vapidKey - ) - ).to.be.rejectedWith('Update failed.'); - - const expectedTokenDetails: TokenDetails = { - ...tokenDetails, - createTime: Date.now() - }; - - expect(requestGetTokenStub).not.to.have.been.called; - expect(requestUpdateTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - expectedTokenDetails - ); - expect(requestDeleteTokenStub).to.have.been.calledOnceWith( - firebaseDependencies, - tokenDetails.token - ); - - const tokenFromDb = await dbGet(firebaseDependencies); - expect(tokenFromDb).to.be.undefined; - }); - - it('returns the token if it is valid', async () => { - stub(Notification, 'permission').value('granted'); - - await dbSet(firebaseDependencies, tokenDetails); - - const token = await getToken( - firebaseDependencies, - swRegistration, - tokenDetails.subscriptionOptions!.vapidKey - ); - - expect(token).to.equal(tokenDetails.token); - expect(requestGetTokenStub).not.to.have.been.called; - expect(requestUpdateTokenStub).not.to.have.been.called; - expect(requestDeleteTokenStub).not.to.have.been.called; - - const tokenFromDb = await dbGet(firebaseDependencies); - expect(tokenFromDb).to.deep.equal(tokenDetails); - }); - }); - - describe('deleteToken', () => { - it('returns if there is no token in the db', async () => { - await deleteToken(firebaseDependencies, swRegistration); - - expect(requestGetTokenStub).not.to.have.been.called; - expect(requestUpdateTokenStub).not.to.have.been.called; - expect(requestDeleteTokenStub).not.to.have.been.called; - }); - - it('removes token from the db, calls requestDeleteToken and unsubscribes the push subscription', async () => { - const unsubscribeSpy = spy( - await swRegistration.pushManager.subscribe(), - 'unsubscribe' - ); - await dbSet(firebaseDependencies, tokenDetails); - - await deleteToken(firebaseDependencies, swRegistration); - - expect(await dbGet(firebaseDependencies)).to.be.undefined; - expect(requestGetTokenStub).not.to.have.been.called; - expect(requestUpdateTokenStub).not.to.have.been.called; - expect(requestDeleteTokenStub).not.to.have.been.calledOnceWith( - firebaseDependencies, - tokenDetails - ); - expect(unsubscribeSpy).to.have.been.called; - }); - }); -}); diff --git a/packages-exp/messaging-exp/src/helpers/logToScion.ts b/packages-exp/messaging-exp/src/helpers/logToScion.ts new file mode 100644 index 00000000000..75966ae5dd0 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/logToScion.ts @@ -0,0 +1,56 @@ +/** + * @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 { + CONSOLE_CAMPAIGN_ID, + CONSOLE_CAMPAIGN_NAME, + CONSOLE_CAMPAIGN_TIME +} from '../util/constants'; +import { + ConsoleMessageData, + MessageType +} from '../interfaces/internal-message-payload'; + +import { MessagingService } from '../messaging-service'; + +export async function logToScion( + messaging: MessagingService, + messageType: MessageType, + data: ConsoleMessageData +): Promise { + const eventType = getEventType(messageType); + const analytics = await messaging.firebaseDependencies.analyticsProvider.get(); + analytics.logEvent(eventType, { + /* eslint-disable camelcase */ + message_id: data[CONSOLE_CAMPAIGN_ID], + message_name: data[CONSOLE_CAMPAIGN_NAME], + message_time: data[CONSOLE_CAMPAIGN_TIME], + message_device_time: Math.floor(Date.now() / 1000) + /* eslint-enable camelcase */ + }); +} + +function getEventType(messageType: MessageType): string { + switch (messageType) { + case MessageType.NOTIFICATION_CLICKED: + return 'notification_open'; + case MessageType.PUSH_RECEIVED: + return 'notification_foreground'; + default: + throw new Error(); + } +} diff --git a/packages-exp/messaging-exp/src/helpers/registerDefaultSw.ts b/packages-exp/messaging-exp/src/helpers/registerDefaultSw.ts new file mode 100644 index 00000000000..2d929596c68 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/registerDefaultSw.ts @@ -0,0 +1,47 @@ +/** + * @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 { DEFAULT_SW_PATH, DEFAULT_SW_SCOPE } from '../util/constants'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +import { MessagingService } from '../messaging-service'; + +export async function registerDefaultSw( + messaging: MessagingService +): Promise { + try { + messaging.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 from 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. + messaging.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 + }); + } +} diff --git a/packages-exp/messaging-exp/src/helpers/updateSwReg.ts b/packages-exp/messaging-exp/src/helpers/updateSwReg.ts new file mode 100644 index 00000000000..927599aaa73 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/updateSwReg.ts @@ -0,0 +1,40 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +import { MessagingService } from '../messaging-service'; +import { registerDefaultSw } from './registerDefaultSw'; + +export async function updateSwReg( + messaging: MessagingService, + swRegistration?: ServiceWorkerRegistration | undefined +): Promise { + if (!swRegistration && !messaging.swRegistration) { + await registerDefaultSw(messaging); + } + + if (!swRegistration && !!messaging.swRegistration) { + return; + } + + if (!(swRegistration instanceof ServiceWorkerRegistration)) { + throw ERROR_FACTORY.create(ErrorCode.INVALID_SW_REGISTRATION); + } + + messaging.swRegistration = swRegistration; +} diff --git a/packages-exp/messaging-exp/src/helpers/updateVapidKey.ts b/packages-exp/messaging-exp/src/helpers/updateVapidKey.ts new file mode 100644 index 00000000000..9ad3b4663b4 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/updateVapidKey.ts @@ -0,0 +1,30 @@ +/** + * @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 { DEFAULT_VAPID_KEY } from '../util/constants'; +import { MessagingService } from '../messaging-service'; + +export async function updateVapidKey( + messaging: MessagingService, + vapidKey?: string | undefined +): Promise { + if (!!vapidKey) { + messaging.vapidKey = vapidKey; + } else if (!messaging.vapidKey) { + messaging.vapidKey = DEFAULT_VAPID_KEY; + } +} diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index 44a75816401..5087663d620 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -24,7 +24,7 @@ import { import { ERROR_FACTORY, ErrorCode } from './util/errors'; import { FirebaseMessaging } from '@firebase/messaging-types-exp'; -import { MessagingService } from './api'; +import { MessagingService } from './messaging-service'; import { _FirebaseNamespace } from '@firebase/app-types/private'; import { _registerComponent } from '@firebase/app-exp'; import { isSupported } from './helpers/isSupported'; @@ -48,24 +48,23 @@ declare module '@firebase/app-types-exp' { } } -const messagingFactory: InstanceFactory<'messaging'> = ( +const messagingFactory: InstanceFactory<'messaging-exp'> = ( container: ComponentContainer ) => { if (!isSupported()) { throw ERROR_FACTORY.create(ErrorCode.UNSUPPORTED_BROWSER); } - const messagingService = new MessagingService(container); - if (!!messagingService.windowController) { - return messagingService.windowController!; - } else { - return messagingService.windowController!; - } + return new MessagingService( + container.getProvider('app-exp').getImmediate(), + container.getProvider('installations-exp').getImmediate(), + container.getProvider('analytics-internal') + ); }; _registerComponent( new Component( - 'messaging', + 'messaging-exp', messagingFactory, ComponentType.PUBLIC ).setServiceProps(NAMESPACE_EXPORTS) diff --git a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts index 22c70e68f77..916420eded2 100644 --- a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts +++ b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts @@ -18,7 +18,7 @@ import { AppConfig } from './app-config'; import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; import { FirebaseApp } from '@firebase/app-types-exp'; -import { FirebaseInstallations } from '@firebase/installations-types'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; import { Provider } from '@firebase/component'; export interface FirebaseInternalDependencies { diff --git a/packages-exp/messaging-exp/src/helpers/idb-manager.test.ts b/packages-exp/messaging-exp/src/internals/idb-manager.test.ts similarity index 96% rename from packages-exp/messaging-exp/src/helpers/idb-manager.test.ts rename to packages-exp/messaging-exp/src/internals/idb-manager.test.ts index b98ad933378..c66f11ad440 100644 --- a/packages-exp/messaging-exp/src/helpers/idb-manager.test.ts +++ b/packages-exp/messaging-exp/src/internals/idb-manager.test.ts @@ -17,9 +17,9 @@ import '../testing/setup'; -import * as migrateOldDatabaseModule from './migrate-old-database'; +import * as migrateOldDatabaseModule from '../helpers/migrate-old-database'; -import { dbGet, dbRemove, dbSet } from './idb-manager'; +import { dbGet, dbRemove, dbSet } from '../internals/idb-manager'; import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; import { Stub } from '../testing/sinon-types'; diff --git a/packages-exp/messaging-exp/src/helpers/idb-manager.ts b/packages-exp/messaging-exp/src/internals/idb-manager.ts similarity index 98% rename from packages-exp/messaging-exp/src/helpers/idb-manager.ts rename to packages-exp/messaging-exp/src/internals/idb-manager.ts index dbe957de244..4ddebf5ae96 100644 --- a/packages-exp/messaging-exp/src/helpers/idb-manager.ts +++ b/packages-exp/messaging-exp/src/internals/idb-manager.ts @@ -19,7 +19,7 @@ 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 { migrateOldDatabase } from '../helpers/migrate-old-database'; // Exported for tests. export const DATABASE_NAME = 'firebase-messaging-database'; diff --git a/packages-exp/messaging-exp/src/core/api.test.ts b/packages-exp/messaging-exp/src/internals/requests.test.ts similarity index 96% rename from packages-exp/messaging-exp/src/core/api.test.ts rename to packages-exp/messaging-exp/src/internals/requests.test.ts index da3368191c8..37aebb6d7ec 100644 --- a/packages-exp/messaging-exp/src/core/api.test.ts +++ b/packages-exp/messaging-exp/src/internals/requests.test.ts @@ -17,12 +17,14 @@ import '../testing/setup'; +import * as installationsModule from '@firebase/installations-exp'; + import { ApiRequestBody, requestDeleteToken, requestGetToken, requestUpdateToken -} from './api'; +} from './requests'; import { ENDPOINT } from '../util/constants'; import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; @@ -38,11 +40,15 @@ describe('API', () => { let tokenDetails: TokenDetails; let firebaseDependencies: FirebaseInternalDependencies; let fetchStub: Stub; + let getAuthTokenStub: Stub; beforeEach(() => { tokenDetails = getFakeTokenDetails(); firebaseDependencies = getFakeFirebaseDependencies(); fetchStub = stub(self, 'fetch'); + getAuthTokenStub = stub(installationsModule, 'getToken').resolves( + 'authToken' + ); }); describe('getToken', () => { @@ -79,6 +85,7 @@ describe('API', () => { expect(response).to.equal('fcm-token-from-server'); expect(fetchStub).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + expect(getAuthTokenStub).to.be.called; const actualHeaders = fetchStub.lastCall.lastArg.headers; compareHeaders(expectedHeaders, actualHeaders); }); diff --git a/packages-exp/messaging-exp/src/core/api.ts b/packages-exp/messaging-exp/src/internals/requests.ts similarity index 97% rename from packages-exp/messaging-exp/src/core/api.ts rename to packages-exp/messaging-exp/src/internals/requests.ts index b93f8623601..21fc6f2acf1 100644 --- a/packages-exp/messaging-exp/src/core/api.ts +++ b/packages-exp/messaging-exp/src/internals/requests.ts @@ -21,6 +21,7 @@ import { SubscriptionOptions, TokenDetails } from '../interfaces/token-details'; import { AppConfig } from '../interfaces/app-config'; import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { getToken } from '@firebase/installations-exp'; export interface ApiResponse { token?: string; @@ -154,7 +155,7 @@ async function getHeaders({ appConfig, installations }: FirebaseInternalDependencies): Promise { - const authToken = await installations.getToken(); + const authToken = await getToken(installations); return new Headers({ 'Content-Type': 'application/json', diff --git a/packages-exp/messaging-exp/src/internals/token-manager.test.ts b/packages-exp/messaging-exp/src/internals/token-manager.test.ts new file mode 100644 index 00000000000..2129d82a34a --- /dev/null +++ b/packages-exp/messaging-exp/src/internals/token-manager.test.ts @@ -0,0 +1,194 @@ +/** + * @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 apiModule from './requests'; + +import { dbGet, dbSet } from './idb-manager'; +import { deleteTokenInternal, getTokenInternal } from './token-manager'; +import { + getFakeAnalyticsProvider, + getFakeApp, + getFakeInstallations +} from '../testing/fakes/firebase-dependencies'; +import { spy, stub, useFakeTimers } from 'sinon'; + +import { FakeServiceWorkerRegistration } from '../testing/fakes/service-worker'; +import { MessagingService } from '../messaging-service'; +import { Stub } from '../testing/sinon-types'; +import { TokenDetails } from '../interfaces/token-details'; +// import { arrayToBase64 } from '../helpers/array-base64-translator'; +import { expect } from 'chai'; +import { getFakeTokenDetails } from '../testing/fakes/token-details'; + +describe('Token Manager', () => { + let tokenDetails: TokenDetails; + let messaging: MessagingService; + let requestGetTokenStub: Stub; + let requestUpdateTokenStub: Stub; + let requestDeleteTokenStub: Stub; + + beforeEach(() => { + tokenDetails = getFakeTokenDetails(); + messaging = new MessagingService( + getFakeApp(), + getFakeInstallations(), + getFakeAnalyticsProvider() + ); + // base64 value of 'vapid-key-value' set in fakeTokenDetails + messaging.vapidKey = 'dmFwaWQta2V5LXZhbHVl'; + messaging.swRegistration = new FakeServiceWorkerRegistration(); + + requestGetTokenStub = stub(apiModule, 'requestGetToken').resolves( + 'token-value' // new token. + ); + requestUpdateTokenStub = stub(apiModule, 'requestUpdateToken').resolves( + tokenDetails.token // same as current token. + ); + requestDeleteTokenStub = stub(apiModule, 'requestDeleteToken').resolves(); + useFakeTimers({ now: 1234567890 }); + }); + + describe('getTokenInternal', () => { + it('gets a new token if there is none', async () => { + // Act + const token = await getTokenInternal(messaging); + + // Assert + expect(token).to.equal('token-value'); + expect(requestGetTokenStub).to.have.been.calledOnceWith( + messaging.firebaseDependencies, + tokenDetails.subscriptionOptions + ); + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.called; + + const tokenFromDb = await dbGet(messaging.firebaseDependencies); + expect(token).to.equal(tokenFromDb!.token); + expect(tokenFromDb).to.deep.equal({ + ...tokenDetails, + token: 'token-value' + }); + }); + + it('returns the token if it is valid', async () => { + // Arrange + await dbSet(messaging.firebaseDependencies, tokenDetails); + + // Act + const token = await getTokenInternal(messaging); + + // Assert + expect(token).to.equal(tokenDetails.token); + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.called; + + const tokenFromDb = await dbGet(messaging.firebaseDependencies); + expect(tokenFromDb).to.deep.equal(tokenDetails); + }); + + it('update the token if it was last updated more than a week ago', async () => { + // Change create time to be older than a week. + tokenDetails.createTime = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days + + await dbSet(messaging.firebaseDependencies, tokenDetails); + + const token = await getTokenInternal(messaging); + const expectedTokenDetails: TokenDetails = { + ...tokenDetails, + createTime: Date.now() + }; + + expect(token).to.equal(tokenDetails.token); // Same token. + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).to.have.been.calledOnceWith( + messaging.firebaseDependencies, + expectedTokenDetails + ); + expect(requestDeleteTokenStub).not.to.have.been.called; + + const tokenFromDb = await dbGet(messaging.firebaseDependencies); + expect(token).to.equal(tokenFromDb!.token); + expect(tokenFromDb).to.deep.equal(expectedTokenDetails); + }); + + it('deletes the token if the update fails', async () => { + // Arrange + // Change create time to be older than a week. + tokenDetails.createTime = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days + + await dbSet(messaging.firebaseDependencies, tokenDetails); + + requestUpdateTokenStub.rejects(new Error('Update failed.')); + + // Act + await expect(getTokenInternal(messaging)).to.be.rejectedWith( + 'Update failed.' + ); + + // Assert + const expectedTokenDetails: TokenDetails = { + ...tokenDetails, + createTime: Date.now() + }; + + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).to.have.been.calledOnceWith( + messaging.firebaseDependencies, + expectedTokenDetails + ); + expect(requestDeleteTokenStub).to.have.been.calledOnceWith( + messaging.firebaseDependencies, + tokenDetails.token + ); + + const tokenFromDb = await dbGet(messaging.firebaseDependencies); + expect(tokenFromDb).to.be.undefined; + }); + }); + + describe('deleteToken', () => { + it('returns if there is no token in the db', async () => { + await deleteTokenInternal(messaging); + + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.called; + }); + + it('removes token from the db, calls requestDeleteToken and unsubscribes the push subscription', async () => { + const unsubscribeSpy = spy( + await messaging.swRegistration!.pushManager.subscribe(), + 'unsubscribe' + ); + await dbSet(messaging.firebaseDependencies, tokenDetails); + + await deleteTokenInternal(messaging); + + expect(await dbGet(messaging.firebaseDependencies)).to.be.undefined; + expect(requestGetTokenStub).not.to.have.been.called; + expect(requestUpdateTokenStub).not.to.have.been.called; + expect(requestDeleteTokenStub).not.to.have.been.calledOnceWith( + messaging.firebaseDependencies, + tokenDetails + ); + expect(unsubscribeSpy).to.have.been.called; + }); + }); +}); diff --git a/packages-exp/messaging-exp/src/core/token-management.ts b/packages-exp/messaging-exp/src/internals/token-manager.ts similarity index 67% rename from packages-exp/messaging-exp/src/core/token-management.ts rename to packages-exp/messaging-exp/src/internals/token-manager.ts index b314c89fc77..1f14a9e055c 100644 --- a/packages-exp/messaging-exp/src/core/token-management.ts +++ b/packages-exp/messaging-exp/src/internals/token-manager.ts @@ -15,68 +15,66 @@ * limitations under the License. */ -import { ERROR_FACTORY, ErrorCode } from '../util/errors'; import { SubscriptionOptions, TokenDetails } from '../interfaces/token-details'; import { arrayToBase64, base64ToArray } from '../helpers/array-base64-translator'; -import { dbGet, dbRemove, dbSet } from '../helpers/idb-manager'; -import { requestDeleteToken, requestGetToken, requestUpdateToken } from './api'; +import { dbGet, dbRemove, dbSet } from './idb-manager'; +import { + requestDeleteToken, + requestGetToken, + requestUpdateToken +} from './requests'; import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { MessagingService } from '../messaging-service'; -/** UpdateRegistration will be called once every week. */ +// UpdateRegistration will be called once every week. const TOKEN_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days -export async function getToken( - firebaseDependencies: FirebaseInternalDependencies, - swRegistration: ServiceWorkerRegistration, - vapidKey: string +export async function getTokenInternal( + messaging: MessagingService ): Promise { - if (Notification.permission !== 'granted') { - throw ERROR_FACTORY.create(ErrorCode.PERMISSION_BLOCKED); - } - - // 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); + const pushSubscription = await getPushSubscription( + messaging.swRegistration!, + messaging.vapidKey! + ); const subscriptionOptions: SubscriptionOptions = { - vapidKey, - swScope: swRegistration.scope, + vapidKey: messaging.vapidKey!, + swScope: messaging.swRegistration!.scope, endpoint: pushSubscription.endpoint, auth: arrayToBase64(pushSubscription.getKey('auth')!), p256dh: arrayToBase64(pushSubscription.getKey('p256dh')!) }; + const tokenDetails = await dbGet(messaging.firebaseDependencies); if (!tokenDetails) { // No token, get a new one. - return getNewToken(firebaseDependencies, subscriptionOptions); + return getNewToken(messaging.firebaseDependencies, subscriptionOptions); } else if ( !isTokenValid(tokenDetails.subscriptionOptions!, subscriptionOptions) ) { // Invalid token, get a new one. try { - await requestDeleteToken(firebaseDependencies, tokenDetails.token); + await requestDeleteToken( + messaging.firebaseDependencies!, + tokenDetails.token + ); } catch (e) { // Suppress errors because of #2364 console.warn(e); } - return getNewToken(firebaseDependencies, subscriptionOptions); + return getNewToken(messaging.firebaseDependencies!, subscriptionOptions); } else if (Date.now() >= tokenDetails.createTime + TOKEN_EXPIRATION_MS) { // Weekly token refresh - return updateToken( - { - token: tokenDetails.token, - createTime: Date.now(), - subscriptionOptions - }, - firebaseDependencies, - swRegistration - ); + return updateToken(messaging, { + token: tokenDetails.token, + createTime: Date.now(), + subscriptionOptions + }); } else { // Valid token, nothing to do. return tokenDetails.token; @@ -87,18 +85,20 @@ 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. */ -export async function deleteToken( - firebaseDependencies: FirebaseInternalDependencies, - swRegistration: ServiceWorkerRegistration +export async function deleteTokenInternal( + messaging: MessagingService ): Promise { - const tokenDetails = await dbGet(firebaseDependencies); + const tokenDetails = await dbGet(messaging.firebaseDependencies); if (tokenDetails) { - await requestDeleteToken(firebaseDependencies, tokenDetails.token); - await dbRemove(firebaseDependencies); + await requestDeleteToken( + messaging.firebaseDependencies, + tokenDetails.token + ); + await dbRemove(messaging.firebaseDependencies); } // Unsubscribe from the push subscription. - const pushSubscription = await swRegistration.pushManager.getSubscription(); + const pushSubscription = await messaging.swRegistration!.pushManager.getSubscription(); if (pushSubscription) { return pushSubscription.unsubscribe(); } @@ -108,13 +108,12 @@ export async function deleteToken( } async function updateToken( - tokenDetails: TokenDetails, - firebaseDependencies: FirebaseInternalDependencies, - swRegistration: ServiceWorkerRegistration + messaging: MessagingService, + tokenDetails: TokenDetails ): Promise { try { const updatedToken = await requestUpdateToken( - firebaseDependencies, + messaging.firebaseDependencies, tokenDetails ); @@ -124,10 +123,10 @@ async function updateToken( createTime: Date.now() }; - await dbSet(firebaseDependencies, updatedTokenDetails); + await dbSet(messaging.firebaseDependencies, updatedTokenDetails); return updatedToken; } catch (e) { - await deleteToken(firebaseDependencies, swRegistration); + await deleteTokenInternal(messaging); throw e; } } @@ -160,6 +159,7 @@ async function getPushSubscription( if (subscription) { return subscription; } + return swRegistration.pushManager.subscribe({ userVisibleOnly: true, // Chrome <= 75 doesn't support base64-encoded VAPID key. For backward compatibility, VAPID key diff --git a/packages-exp/messaging-exp/src/listeners/messageEventListener.ts b/packages-exp/messaging-exp/src/listeners/messageEventListener.ts new file mode 100644 index 00000000000..495a2c12c39 --- /dev/null +++ b/packages-exp/messaging-exp/src/listeners/messageEventListener.ts @@ -0,0 +1,59 @@ +/** + * @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 { + MessagePayloadInternal, + MessageType +} from '../interfaces/internal-message-payload'; + +import { CONSOLE_CAMPAIGN_ANALYTICS_ENABLED } from '../util/constants'; +import { MessagingService } from '../messaging-service'; +import { _FirebaseService } from '@firebase/app-types-exp'; +import { externalizePayload } from '../helpers/externalizePayload'; +import { isConsoleMessage } from '../helpers/is-console-message'; +import { logToScion } from '../helpers/logToScion'; + +export async function messageEventListener( + messaging: MessagingService, + event: MessageEvent +): Promise { + const internalPayload = event.data as MessagePayloadInternal; + + if (!internalPayload.isFirebaseMessaging) { + return; + } + + if ( + messaging.onMessageHandler && + internalPayload.messageType === MessageType.PUSH_RECEIVED + ) { + if (typeof messaging.onMessageHandler === 'function') { + messaging.onMessageHandler(externalizePayload(internalPayload)); + } else { + messaging.onMessageHandler.next(externalizePayload(internalPayload)); + } + } + + // Log to Scion if applicable + const dataPayload = internalPayload.data; + if ( + isConsoleMessage(dataPayload) && + dataPayload[CONSOLE_CAMPAIGN_ANALYTICS_ENABLED] === '1' + ) { + await logToScion(messaging, internalPayload.messageType!, dataPayload); + } +} diff --git a/packages-exp/messaging-exp/src/controllers/sw-controller.test.ts b/packages-exp/messaging-exp/src/listeners/sw-controller.test.ts similarity index 72% rename from packages-exp/messaging-exp/src/controllers/sw-controller.test.ts rename to packages-exp/messaging-exp/src/listeners/sw-controller.test.ts index 27e2643c353..dede38f1b58 100644 --- a/packages-exp/messaging-exp/src/controllers/sw-controller.test.ts +++ b/packages-exp/messaging-exp/src/listeners/sw-controller.test.ts @@ -17,15 +17,13 @@ import '../testing/setup'; -import * as tokenManagementModule from '../core/token-management'; +import * as tokenManagementModule from '../internals/token-manager'; -import { BgMessageHandler, SwController } from './sw-controller'; import { CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, CONSOLE_CAMPAIGN_ID, CONSOLE_CAMPAIGN_NAME, CONSOLE_CAMPAIGN_TIME, - DEFAULT_VAPID_KEY, FCM_MSG } from '../util/constants'; import { DeepPartial, ValueOf, Writable } from 'ts-essentials'; @@ -39,14 +37,17 @@ import { MessagePayloadInternal, MessageType } from '../interfaces/internal-message-payload'; +import { + getFakeAnalyticsProvider, + getFakeApp, + getFakeInstallations +} from '../testing/fakes/firebase-dependencies'; import { spy, stub } from 'sinon'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; +import { MessagingService } from '../messaging-service'; import { Stub } from '../testing/sinon-types'; -import { dbSet } from '../helpers/idb-manager'; +import { SwController } from './sw-controller'; import { expect } from 'chai'; -import { getFakeFirebaseDependencies } from '../testing/fakes/firebase-dependencies'; -import { getFakeTokenDetails } from '../testing/fakes/token-details'; const LOCAL_HOST = self.location.host; const TEST_LINK = 'https://' + LOCAL_HOST + '/test-link.org'; @@ -70,25 +71,15 @@ const DISPLAY_MESSAGE: MessagePayloadInternal = { collapse_key: 'collapse' }; -// 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', () => { let addEventListenerStub: Stub; // eslint-disable-next-line @typescript-eslint/ban-types let eventListenerMap: Map; - let swController: SwController; - let firebaseDependencies: FirebaseInternalDependencies; - let getTokenStub: Stub; - let deleteTokenStub: Stub; + let messaging: MessagingService; + let getTokenStub: Stub; + let deleteTokenStub: Stub< + typeof tokenManagementModule['deleteTokenInternal'] + >; beforeEach(() => { mockServiceWorker(); @@ -105,23 +96,26 @@ describe('SwController', () => { ); eventListenerMap = new Map(); - getTokenStub = stub(tokenManagementModule, 'getToken').resolves( + getTokenStub = stub(tokenManagementModule, 'getTokenInternal').resolves( 'token-value' ); - deleteTokenStub = stub(tokenManagementModule, 'deleteToken').resolves(true); - - firebaseDependencies = getFakeFirebaseDependencies(); - swController = new SwController(firebaseDependencies); + deleteTokenStub = stub( + tokenManagementModule, + 'deleteTokenInternal' + ).resolves(true); + + messaging = new MessagingService( + getFakeApp(), + getFakeInstallations(), + getFakeAnalyticsProvider() + ); + new SwController(messaging); }); afterEach(() => { restoreServiceWorker(); }); - it('has app', () => { - expect(swController.app).to.equal(firebaseDependencies.app); - }); - it('sets event listeners on initialization', () => { expect(addEventListenerStub).to.have.been.calledThrice; expect(addEventListenerStub).to.have.been.calledWith('push'); @@ -131,68 +125,6 @@ describe('SwController', () => { expect(addEventListenerStub).to.have.been.calledWith('notificationclick'); }); - it('throws when window-only methods are called', () => { - expect(() => swController.requestPermission()).to.throw( - 'messaging/only-available-in-window' - ); - expect(() => swController.useServiceWorker()).to.throw( - 'messaging/only-available-in-window' - ); - expect(() => swController.onMessage()).to.throw( - 'messaging/only-available-in-window' - ); - expect(() => swController.onTokenRefresh()).to.throw( - 'messaging/only-available-in-window' - ); - }); - - describe('getToken', () => { - it('calls getToken with the set VAPID key', async () => { - swController.usePublicVapidKey('use-vapid-key'); - await swController.getToken(); - - expect(getTokenStub).to.have.been.calledWith( - firebaseDependencies, - self.registration, - 'use-vapid-key' - ); - }); - - it('calls getToken with the current VAPID key if it is not set', async () => { - const tokenDetails = getFakeTokenDetails(); - await dbSet(firebaseDependencies, tokenDetails); - - await swController.getToken(); - - expect(getTokenStub).to.have.been.calledWith( - firebaseDependencies, - self.registration, - 'dmFwaWQta2V5LXZhbHVl' - ); - }); - - it('calls getToken with the default VAPID key if there is no token in db', async () => { - await swController.getToken(); - - expect(getTokenStub).to.have.been.calledWith( - firebaseDependencies, - self.registration, - DEFAULT_VAPID_KEY - ); - }); - }); - - describe('deleteToken', () => { - it('calls deleteToken', async () => { - await swController.deleteToken(); - - expect(deleteTokenStub).to.have.been.calledWith( - firebaseDependencies, - self.registration - ); - }); - }); - describe('onPush', () => { it('does nothing if push is not from FCM', async () => { const showNotificationSpy = spy(self.registration, 'showNotification'); @@ -274,46 +206,6 @@ describe('SwController', () => { }); }); - it('calls bgMessageHandler if message is not a notification', async () => { - const bgMessageHandlerSpy = spy(); - swController.setBackgroundMessageHandler(bgMessageHandlerSpy); - - await callEventListener( - makeEvent('push', { - data: { - json: () => DATA_MESSAGE - } - }) - ); - - 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 @@ -346,43 +238,6 @@ describe('SwController', () => { }); }); - describe('setBackgroundMessageHandler', () => { - it('throws on invalid input', () => { - expect(() => - swController.setBackgroundMessageHandler( - (null as unknown) as BgMessageHandler - ) - ).to.throw('messaging/invalid-bg-handler'); - }); - }); - - describe('usePublicVapidKey', () => { - it('throws on invalid input', () => { - expect(() => - swController.usePublicVapidKey((null as unknown) as string) - ).to.throw('messaging/invalid-vapid-key'); - - expect(() => swController.usePublicVapidKey('')).to.throw( - 'messaging/invalid-vapid-key' - ); - }); - - it('throws if called twice', () => { - swController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl'); - expect(() => - swController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl') - ).to.throw('messaging/use-vapid-key-after-get-token'); - }); - - it('throws if called after getToken', async () => { - await swController.getToken(); - - expect(() => - swController.usePublicVapidKey('dmFwaWQta2V5LXZhbHVl') - ).to.throw('messaging/use-vapid-key-after-get-token'); - }); - }); - describe('onNotificationClick', () => { let NOTIFICATION_CLICK_PAYLOAD: DeepPartial; diff --git a/packages-exp/messaging-exp/src/controllers/sw-controller.ts b/packages-exp/messaging-exp/src/listeners/sw-controller.ts similarity index 57% rename from packages-exp/messaging-exp/src/controllers/sw-controller.ts rename to packages-exp/messaging-exp/src/listeners/sw-controller.ts index 426d631cb37..c07179e1ea4 100644 --- a/packages-exp/messaging-exp/src/controllers/sw-controller.ts +++ b/packages-exp/messaging-exp/src/listeners/sw-controller.ts @@ -15,23 +15,19 @@ * limitations under the License. */ -import { DEFAULT_VAPID_KEY, FCM_MSG, TAG } from '../util/constants'; -import { ERROR_FACTORY, ErrorCode } from '../util/errors'; -import { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; -import { - FirebaseMessaging, - MessagePayload -} from '@firebase/messaging-types-exp'; +import { DEFAULT_VAPID_KEY, FCM_MSG } from '../util/constants'; import { MessagePayloadInternal, MessageType, NotificationPayloadInternal } from '../interfaces/internal-message-payload'; -import { NextFn, Observer, Unsubscribe } from '@firebase/util'; -import { deleteToken, getToken } from '../core/token-management'; +import { + deleteTokenInternal, + getTokenInternal +} from '../internals/token-manager'; -import { FirebaseInternalDependencies } from '../interfaces/internal-dependencies'; -import { dbGet } from '../helpers/idb-manager'; +import { MessagingService } from '../messaging-service'; +import { dbGet } from '../internals/idb-manager'; import { externalizePayload } from '../helpers/externalizePayload'; import { isConsoleMessage } from '../helpers/is-console-message'; import { sleep } from '../helpers/sleep'; @@ -39,24 +35,8 @@ import { sleep } from '../helpers/sleep'; // Let TS know that this is a service worker 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 - | NextFn - | Observer = null; - - constructor( - private readonly firebaseDependencies: FirebaseInternalDependencies - ) { +export class SwController { + constructor(private readonly messaging: MessagingService) { self.addEventListener('push', e => { e.waitUntil(this.onPush(e)); }); @@ -68,104 +48,6 @@ export class SwController implements FirebaseMessaging, _FirebaseService { }); } - _delete(): Promise { - throw new Error('Method not implemented.'); - } - - get app(): FirebaseApp { - return this.firebaseDependencies.app; - } - - /** - * @deprecated. Use onBackgroundMessage(nextOrObserver: NextFn | Observer): - * Unsubscribe instead. - * - * Calling setBackgroundMessageHandler will opt in to some specific behaviors. - * - * 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); - } - - this.bgMessageHandler = callback; - } - - 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. - const tokenDetails = await dbGet(this.firebaseDependencies); - this.vapidKey = - tokenDetails?.subscriptionOptions?.vapidKey ?? DEFAULT_VAPID_KEY; - } - - return getToken( - this.firebaseDependencies, - self.registration, - this.vapidKey - ); - } - - // 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); - } - - requestPermission(): Promise { - throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); - } - - // TODO: Remove this together with getToken from SW Controller. - usePublicVapidKey(vapidKey: string): void { - if (this.vapidKey !== null) { - throw ERROR_FACTORY.create(ErrorCode.USE_VAPID_KEY_AFTER_GET_TOKEN); - } - - if (typeof vapidKey !== 'string' || vapidKey.length === 0) { - throw ERROR_FACTORY.create(ErrorCode.INVALID_VAPID_KEY); - } - - this.vapidKey = vapidKey; - } - - useServiceWorker(): void { - throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); - } - - onMessage(): Unsubscribe { - throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); - } - - onTokenRefresh(): Unsubscribe { - throw ERROR_FACTORY.create(ErrorCode.AVAILABLE_IN_WINDOW); - } - /** * A handler for push events that shows notifications based on the content of the payload. * @@ -179,10 +61,7 @@ export class SwController implements FirebaseMessaging, _FirebaseService { async onPush(event: PushEvent): Promise { const internalPayload = getMessagePayloadInternal(event); if (!internalPayload) { - console.debug( - TAG + - 'failed to get parsed MessagePayload from the PushEvent. Skip handling the push.' - ); + // Failed to get parsed MessagePayload from the PushEvent. Skip handling the push. return; } @@ -192,29 +71,18 @@ export class SwController implements FirebaseMessaging, _FirebaseService { return sendMessagePayloadInternalToWindows(clientList, internalPayload); } - // background handling: display and pass to onBackgroundMessage hook - let isNotificationShown = false; + // background handling: display if possible and pass to onBackgroundMessage hook 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; - } - - if (!!this.bgMessageHandler) { + if (!!this.messaging.onBackgroundMessageHandler) { const payload = externalizePayload(internalPayload); - if (typeof this.bgMessageHandler === 'function') { - this.bgMessageHandler(payload); + if (typeof this.messaging.onBackgroundMessageHandler === 'function') { + this.messaging.onBackgroundMessageHandler(payload); } else { - this.bgMessageHandler.next(payload); + this.messaging.onBackgroundMessageHandler.next(payload); } } } @@ -223,17 +91,16 @@ export class SwController implements FirebaseMessaging, _FirebaseService { const { newSubscription } = event; if (!newSubscription) { // Subscription revoked, delete token - await deleteToken(this.firebaseDependencies, self.registration); + await deleteTokenInternal(this.messaging); return; } - const tokenDetails = await dbGet(this.firebaseDependencies); - await deleteToken(this.firebaseDependencies, self.registration); - await getToken( - this.firebaseDependencies, - self.registration, - tokenDetails?.subscriptionOptions?.vapidKey ?? DEFAULT_VAPID_KEY - ); + const tokenDetails = await dbGet(this.messaging.firebaseDependencies); + await deleteTokenInternal(this.messaging); + + this.messaging.vapidKey = + tokenDetails?.subscriptionOptions?.vapidKey ?? DEFAULT_VAPID_KEY; + await getTokenInternal(this.messaging); } async onNotificationClick(event: NotificationEvent): Promise { diff --git a/packages-exp/messaging-exp/src/messaging-service.ts b/packages-exp/messaging-exp/src/messaging-service.ts new file mode 100644 index 00000000000..ce3da8d0ad5 --- /dev/null +++ b/packages-exp/messaging-exp/src/messaging-service.ts @@ -0,0 +1,63 @@ +/** + * @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 { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; +import { NextFn, Observer } from '@firebase/util'; + +import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; +import { FirebaseInternalDependencies } from './interfaces/internal-dependencies'; +import { MessagePayload } from '@firebase/messaging-types-exp'; +import { Provider } from '@firebase/component'; +import { extractAppConfig } from './helpers/extract-app-config'; + +export class MessagingService implements _FirebaseService { + readonly app!: FirebaseApp; + readonly firebaseDependencies!: FirebaseInternalDependencies; + + swRegistration?: ServiceWorkerRegistration; + vapidKey?: string; + + onBackgroundMessageHandler: + | NextFn + | Observer + | null = null; + + onMessageHandler: + | NextFn + | Observer + | null = null; + + constructor( + app: FirebaseApp, + installations: FirebaseInstallations, + analyticsProvider: Provider + ) { + const appConfig = extractAppConfig(app); + + this.firebaseDependencies = { + app, + appConfig, + installations, + analyticsProvider + }; + } + + _delete(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts b/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts index 7591858bce7..198910b2126 100644 --- a/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts +++ b/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts @@ -21,7 +21,7 @@ import { } from '@firebase/analytics-interop-types'; import { FirebaseApp, FirebaseOptions } from '@firebase/app-types-exp'; -import { FirebaseInstallations } from '@firebase/installations-types'; +import { FirebaseInstallations } from '@firebase/installations-types-exp'; import { FirebaseInternalDependencies } from '../../interfaces/internal-dependencies'; import { Provider } from '@firebase/component'; import { extractAppConfig } from '../../helpers/extract-app-config'; @@ -59,7 +59,7 @@ export function getFakeApp(options: FirebaseOptions = {}): any { }; } -function getFakeInstallations(): FirebaseInstallations { +export function getFakeInstallations(): FirebaseInstallations { return { getId: async () => 'FID', getToken: async () => 'authToken', @@ -68,7 +68,9 @@ function getFakeInstallations(): FirebaseInstallations { }; } -function getFakeAnalyticsProvider(): Provider { +export function getFakeAnalyticsProvider(): Provider< + FirebaseAnalyticsInternalName +> { const analytics: FirebaseAnalyticsInternal = { logEvent() {} }; diff --git a/packages-exp/messaging-exp/src/testing/setup.ts b/packages-exp/messaging-exp/src/testing/setup.ts index 0d55cf43ef2..61b3524ca74 100644 --- a/packages-exp/messaging-exp/src/testing/setup.ts +++ b/packages-exp/messaging-exp/src/testing/setup.ts @@ -18,7 +18,7 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as sinonChai from 'sinon-chai'; -import { dbDelete } from '../helpers/idb-manager'; +import { dbDelete } from '../internals/idb-manager'; import { deleteDb } from 'idb'; import { restore } from 'sinon'; import { use } from 'chai'; diff --git a/packages-exp/messaging-types-exp/index.d.ts b/packages-exp/messaging-types-exp/index.d.ts index 0d69cd19bc7..f1fc1714d76 100644 --- a/packages-exp/messaging-types-exp/index.d.ts +++ b/packages-exp/messaging-types-exp/index.d.ts @@ -46,49 +46,12 @@ export interface MessagePayload { collapseKey: string; } -export interface FirebaseMessaging { - /** 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, - completed?: CompleteFn - ): Unsubscribe; - /** - * @deprecated Use Notification.requestPermission() instead. - * https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission - */ - requestPermission(): Promise; - setBackgroundMessageHandler( - callback: (payload: any) => Promise | void - ): void; - useServiceWorker(registration: ServiceWorkerRegistration): void; - usePublicVapidKey(b64PublicKey: string): void; -} +export interface FirebaseMessaging {} export type FirebaseMessagingName = 'messaging'; declare module '@firebase/component' { interface NameServiceMapping { - 'messaging': FirebaseMessaging; + 'messaging-exp': FirebaseMessaging; } } From 3d8a351c5cb1c67dc1abc25e60b911704ef66952 Mon Sep 17 00:00:00 2001 From: kai Date: Tue, 15 Dec 2020 11:23:39 -0800 Subject: [PATCH 08/17] Update --- .vscode/settings.json | 9 +-- common/api-review/messaging-exp.api.md | 22 ++++++++ packages-exp/messaging-exp/.npmignore | 9 --- packages-exp/messaging-exp/package.json | 7 ++- packages-exp/messaging-exp/rollup.config.js | 13 ++++- packages-exp/messaging-exp/src/api.ts | 2 +- .../messaging-exp/src/helpers/register.ts | 48 ++++++++++++++++ packages-exp/messaging-exp/src/index.sw.ts | 22 ++++++++ packages-exp/messaging-exp/src/index.ts | 55 +------------------ .../src/interfaces/internal-dependencies.ts | 4 +- .../src/listeners/messageEventListener.ts | 1 - .../src/listeners/sw-controller.test.ts | 6 ++ .../src/listeners/sw-controller.ts | 7 +++ .../messaging-exp/src/messaging-service.ts | 4 +- .../testing/fakes/firebase-dependencies.ts | 12 ++-- .../src/testing/fakes/service-worker.ts | 8 +++ .../messaging-exp/src/util/sw-types.ts | 20 +++---- packages-exp/messaging-types-exp/package.json | 2 +- 18 files changed, 154 insertions(+), 97 deletions(-) delete mode 100644 packages-exp/messaging-exp/.npmignore create mode 100644 packages-exp/messaging-exp/src/helpers/register.ts create mode 100644 packages-exp/messaging-exp/src/index.sw.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 6bbd0cb657f..338dd7801dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,10 +10,5 @@ "**/node_modules": true }, "typescript.tsdk": "node_modules/typescript/lib", - "files.associations": { - "*.json": "jsonc" - }, - "cSpell.words": [ - "unregisters" - ] -} + "files.associations": { "*.json": "jsonc" } +} \ No newline at end of file diff --git a/common/api-review/messaging-exp.api.md b/common/api-review/messaging-exp.api.md index 4c156ec6f1c..2af9365cc1e 100644 --- a/common/api-review/messaging-exp.api.md +++ b/common/api-review/messaging-exp.api.md @@ -4,6 +4,28 @@ ```ts +import { FirebaseApp } from '@firebase/app-types-exp'; +import { FirebaseMessaging } from '@firebase/messaging-types-exp'; +import { MessagePayload } from '@firebase/messaging-types-exp'; +import { NextFn } from '@firebase/util'; +import { Observer } from '@firebase/util'; +import { Unsubscribe } from '@firebase/util'; + +// @public (undocumented) +export function deleteToken(messaging: FirebaseMessaging): Promise; + +// @public (undocumented) +export function getMessaging(app: FirebaseApp): FirebaseMessaging; + +// @public (undocumented) +export function getToken(messaging: FirebaseMessaging, options?: { + vapidKey?: string; + swReg?: ServiceWorkerRegistration; +}): Promise; + +// @public (undocumented) +export function onMessage(messaging: FirebaseMessaging, nextOrObserver: NextFn | Observer): Unsubscribe; + // (No @packageDocumentation comment for this package) diff --git a/packages-exp/messaging-exp/.npmignore b/packages-exp/messaging-exp/.npmignore deleted file mode 100644 index 682c8f74a52..00000000000 --- a/packages-exp/messaging-exp/.npmignore +++ /dev/null @@ -1,9 +0,0 @@ -# Directories not needed by end users -/src -test - -# Files not needed by end users -gulpfile.js -index.ts -karma.conf.js -tsconfig.json \ No newline at end of file diff --git a/packages-exp/messaging-exp/package.json b/packages-exp/messaging-exp/package.json index c57c2e024c4..67fa75a6067 100644 --- a/packages-exp/messaging-exp/package.json +++ b/packages-exp/messaging-exp/package.json @@ -1,12 +1,13 @@ { "name": "@firebase/messaging-exp", "private": true, - "version": "0.0.800", + "version": "0.0.900", "description": "", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", "esm2017": "dist/index.esm2017.js", + "sw": "dist/index.sw.esm5.js", "files": [ "dist" ], @@ -33,13 +34,13 @@ "dependencies": { "@firebase/component": "0.1.19", "@firebase/installations-exp": "0.x", - "@firebase/messaging-types-exp": "0.0.800", + "@firebase/messaging-types-exp": "0.0.900", "@firebase/util": "0.3.2", "idb": "3.0.2", "tslib": "^1.11.1" }, "devDependencies": { - "rollup": "2.7.6", + "rollup-plugin-json": "^4.0.0", "rollup-plugin-typescript2": "0.27.0", "ts-essentials": "^6.0.7", "typescript": "3.8.3" diff --git a/packages-exp/messaging-exp/rollup.config.js b/packages-exp/messaging-exp/rollup.config.js index 3d8120f271e..92be88f8786 100644 --- a/packages-exp/messaging-exp/rollup.config.js +++ b/packages-exp/messaging-exp/rollup.config.js @@ -16,9 +16,9 @@ */ import json from 'rollup-plugin-json'; -import typescriptPlugin from 'rollup-plugin-typescript2'; -import typescript from 'typescript'; import pkg from './package.json'; +import typescript from 'typescript'; +import typescriptPlugin from 'rollup-plugin-typescript2'; const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) @@ -35,6 +35,7 @@ const es5BuildPlugins = [ ]; const es5Builds = [ + // window builds { input: 'src/index.ts', output: [ @@ -43,6 +44,14 @@ const es5Builds = [ ], plugins: es5BuildPlugins, external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + }, + + // sw builds + { + input: 'src/index.sw.ts', + output: [{ file: pkg.sw, format: 'es', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) } ]; diff --git a/packages-exp/messaging-exp/src/api.ts b/packages-exp/messaging-exp/src/api.ts index 3c509629768..cb32ff09b21 100644 --- a/packages-exp/messaging-exp/src/api.ts +++ b/packages-exp/messaging-exp/src/api.ts @@ -15,13 +15,13 @@ * limitations under the License. */ -import { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; import { FirebaseMessaging, MessagePayload } from '@firebase/messaging-types-exp'; import { NextFn, Observer, Unsubscribe } from '@firebase/util'; +import { FirebaseApp } from '@firebase/app-types-exp'; import { MessagingService } from './messaging-service'; import { Provider } from '@firebase/component'; import { deleteToken as _deleteToken } from './api/deleteToken'; diff --git a/packages-exp/messaging-exp/src/helpers/register.ts b/packages-exp/messaging-exp/src/helpers/register.ts new file mode 100644 index 00000000000..cb6b99259bb --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/register.ts @@ -0,0 +1,48 @@ +/** + * @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 { + Component, + ComponentContainer, + ComponentType, + InstanceFactory +} from '@firebase/component'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +import { MessagingService } from '../messaging-service'; +import { _registerComponent } from '@firebase/app-exp'; +import { isSupported } from './isSupported'; + +const messagingFactory: InstanceFactory<'messaging-exp'> = ( + container: ComponentContainer +) => { + if (!isSupported()) { + throw ERROR_FACTORY.create(ErrorCode.UNSUPPORTED_BROWSER); + } + + return new MessagingService( + container.getProvider('app-exp').getImmediate(), + container.getProvider('installations-exp-internal').getImmediate(), + container.getProvider('analytics-internal') + ); +}; + +export function registerMessaging(): void { + _registerComponent( + new Component('messaging-exp', messagingFactory, ComponentType.PUBLIC) + ); +} diff --git a/packages-exp/messaging-exp/src/index.sw.ts b/packages-exp/messaging-exp/src/index.sw.ts new file mode 100644 index 00000000000..6aae953d4f1 --- /dev/null +++ b/packages-exp/messaging-exp/src/index.sw.ts @@ -0,0 +1,22 @@ +/** + * @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 { registerMessaging } from './helpers/register'; + +export { onBackgroundMessage, getMessaging } from './api'; + +registerMessaging(); diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index 5087663d620..7447c3ca982 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -15,57 +15,8 @@ * limitations under the License. */ -import { - Component, - ComponentContainer, - ComponentType, - InstanceFactory -} from '@firebase/component'; -import { ERROR_FACTORY, ErrorCode } from './util/errors'; +import { registerMessaging } from './helpers/register'; -import { FirebaseMessaging } from '@firebase/messaging-types-exp'; -import { MessagingService } from './messaging-service'; -import { _FirebaseNamespace } from '@firebase/app-types/private'; -import { _registerComponent } from '@firebase/app-exp'; -import { isSupported } from './helpers/isSupported'; +export { getToken, deleteToken, onMessage, getMessaging } from './api'; -const NAMESPACE_EXPORTS = { - isSupported -}; - -/** - * Define extension behavior of `registerMessaging` - */ -declare module '@firebase/app-types-exp' { - interface FirebaseNamespace { - messaging: { - (app?: FirebaseApp): FirebaseMessaging; - isSupported(): boolean; - }; - } - interface FirebaseApp { - messaging(): FirebaseMessaging; - } -} - -const messagingFactory: InstanceFactory<'messaging-exp'> = ( - container: ComponentContainer -) => { - if (!isSupported()) { - throw ERROR_FACTORY.create(ErrorCode.UNSUPPORTED_BROWSER); - } - - return new MessagingService( - container.getProvider('app-exp').getImmediate(), - container.getProvider('installations-exp').getImmediate(), - container.getProvider('analytics-internal') - ); -}; - -_registerComponent( - new Component( - 'messaging-exp', - messagingFactory, - ComponentType.PUBLIC - ).setServiceProps(NAMESPACE_EXPORTS) -); +registerMessaging(); diff --git a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts index 916420eded2..c8adfabf225 100644 --- a/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts +++ b/packages-exp/messaging-exp/src/interfaces/internal-dependencies.ts @@ -18,12 +18,12 @@ import { AppConfig } from './app-config'; import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; import { FirebaseApp } from '@firebase/app-types-exp'; -import { FirebaseInstallations } from '@firebase/installations-types-exp'; import { Provider } from '@firebase/component'; +import { _FirebaseInstallationsInternal } from '@firebase/installations-types-exp'; export interface FirebaseInternalDependencies { app: FirebaseApp; appConfig: AppConfig; - installations: FirebaseInstallations; + installations: _FirebaseInstallationsInternal; analyticsProvider: Provider; } diff --git a/packages-exp/messaging-exp/src/listeners/messageEventListener.ts b/packages-exp/messaging-exp/src/listeners/messageEventListener.ts index 495a2c12c39..e40113376af 100644 --- a/packages-exp/messaging-exp/src/listeners/messageEventListener.ts +++ b/packages-exp/messaging-exp/src/listeners/messageEventListener.ts @@ -22,7 +22,6 @@ import { import { CONSOLE_CAMPAIGN_ANALYTICS_ENABLED } from '../util/constants'; import { MessagingService } from '../messaging-service'; -import { _FirebaseService } from '@firebase/app-types-exp'; import { externalizePayload } from '../helpers/externalizePayload'; import { isConsoleMessage } from '../helpers/is-console-message'; import { logToScion } from '../helpers/logToScion'; diff --git a/packages-exp/messaging-exp/src/listeners/sw-controller.test.ts b/packages-exp/messaging-exp/src/listeners/sw-controller.test.ts index dede38f1b58..8a7a901dafb 100644 --- a/packages-exp/messaging-exp/src/listeners/sw-controller.test.ts +++ b/packages-exp/messaging-exp/src/listeners/sw-controller.test.ts @@ -37,6 +37,12 @@ import { MessagePayloadInternal, MessageType } from '../interfaces/internal-message-payload'; +import { + NotificationEvent, + ServiceWorkerGlobalScope, + ServiceWorkerGlobalScopeEventMap, + WindowClient +} from '../util/sw-types'; import { getFakeAnalyticsProvider, getFakeApp, diff --git a/packages-exp/messaging-exp/src/listeners/sw-controller.ts b/packages-exp/messaging-exp/src/listeners/sw-controller.ts index c07179e1ea4..2b6209c292d 100644 --- a/packages-exp/messaging-exp/src/listeners/sw-controller.ts +++ b/packages-exp/messaging-exp/src/listeners/sw-controller.ts @@ -21,6 +21,13 @@ import { MessageType, NotificationPayloadInternal } from '../interfaces/internal-message-payload'; +import { + NotificationEvent, + PushEvent, + PushSubscriptionChangeEvent, + ServiceWorkerGlobalScope, + WindowClient +} from '../util/sw-types'; import { deleteTokenInternal, getTokenInternal diff --git a/packages-exp/messaging-exp/src/messaging-service.ts b/packages-exp/messaging-exp/src/messaging-service.ts index ce3da8d0ad5..ed98946e5de 100644 --- a/packages-exp/messaging-exp/src/messaging-service.ts +++ b/packages-exp/messaging-exp/src/messaging-service.ts @@ -19,10 +19,10 @@ import { FirebaseApp, _FirebaseService } from '@firebase/app-types-exp'; import { NextFn, Observer } from '@firebase/util'; import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; -import { FirebaseInstallations } from '@firebase/installations-types-exp'; import { FirebaseInternalDependencies } from './interfaces/internal-dependencies'; import { MessagePayload } from '@firebase/messaging-types-exp'; import { Provider } from '@firebase/component'; +import { _FirebaseInstallationsInternal } from '@firebase/installations-types-exp'; import { extractAppConfig } from './helpers/extract-app-config'; export class MessagingService implements _FirebaseService { @@ -44,7 +44,7 @@ export class MessagingService implements _FirebaseService { constructor( app: FirebaseApp, - installations: FirebaseInstallations, + installations: _FirebaseInstallationsInternal, analyticsProvider: Provider ) { const appConfig = extractAppConfig(app); diff --git a/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts b/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts index 198910b2126..b72ec417e56 100644 --- a/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts +++ b/packages-exp/messaging-exp/src/testing/fakes/firebase-dependencies.ts @@ -19,11 +19,11 @@ import { FirebaseAnalyticsInternal, FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; -import { FirebaseApp, FirebaseOptions } from '@firebase/app-types-exp'; -import { FirebaseInstallations } from '@firebase/installations-types-exp'; import { FirebaseInternalDependencies } from '../../interfaces/internal-dependencies'; +import { FirebaseOptions } from '@firebase/app-types-exp'; import { Provider } from '@firebase/component'; +import { _FirebaseInstallationsInternal } from '@firebase/installations-types-exp'; import { extractAppConfig } from '../../helpers/extract-app-config'; export function getFakeFirebaseDependencies( @@ -54,17 +54,15 @@ export function getFakeApp(options: FirebaseOptions = {}): any { options, automaticDataCollectionEnabled: true, delete: async () => {}, - messaging: (() => null as unknown) as FirebaseApp['messaging'], + messaging: (() => null as unknown) as any, installations: () => getFakeInstallations() }; } -export function getFakeInstallations(): FirebaseInstallations { +export function getFakeInstallations(): _FirebaseInstallationsInternal { return { getId: async () => 'FID', - getToken: async () => 'authToken', - delete: async () => undefined, - onIdChange: () => () => {} + getToken: async () => 'authToken' }; } diff --git a/packages-exp/messaging-exp/src/testing/fakes/service-worker.ts b/packages-exp/messaging-exp/src/testing/fakes/service-worker.ts index 3fe02c10e5d..8cc09811e0e 100644 --- a/packages-exp/messaging-exp/src/testing/fakes/service-worker.ts +++ b/packages-exp/messaging-exp/src/testing/fakes/service-worker.ts @@ -15,6 +15,14 @@ * limitations under the License. */ +import { + Client, + Clients, + ExtendableEvent, + ServiceWorkerGlobalScope, + WindowClient +} from '../../util/sw-types'; + import { Writable } from 'ts-essentials'; // Add fake SW types. diff --git a/packages-exp/messaging-exp/src/util/sw-types.ts b/packages-exp/messaging-exp/src/util/sw-types.ts index 9c54a5ccc03..587cc248f50 100644 --- a/packages-exp/messaging-exp/src/util/sw-types.ts +++ b/packages-exp/messaging-exp/src/util/sw-types.ts @@ -27,7 +27,7 @@ // 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 { +export interface ServiceWorkerGlobalScope { readonly location: WorkerLocation; readonly clients: Clients; readonly registration: ServiceWorkerRegistration; @@ -42,44 +42,44 @@ interface ServiceWorkerGlobalScope { } // Same as the previous interface -interface ServiceWorkerGlobalScopeEventMap { +export interface ServiceWorkerGlobalScopeEventMap { notificationclick: NotificationEvent; push: PushEvent; pushsubscriptionchange: PushSubscriptionChangeEvent; } -interface Client { +export interface Client { readonly id: string; readonly type: ClientTypes; readonly url: string; postMessage(message: any, transfer?: Transferable[]): void; } -interface ClientQueryOptions { +export interface ClientQueryOptions { includeReserved?: boolean; includeUncontrolled?: boolean; type?: ClientTypes; } -interface WindowClient extends Client { +export interface WindowClient extends Client { readonly focused: boolean; readonly visibilityState: VisibilityState; focus(): Promise; navigate(url: string): Promise; } -interface Clients { +export interface Clients { claim(): Promise; get(id: string): Promise; matchAll(options?: ClientQueryOptions): Promise; openWindow(url: string): Promise; } -interface ExtendableEvent extends Event { +export interface ExtendableEvent extends Event { waitUntil(f: Promise): void; } -interface NotificationEvent extends ExtendableEvent { +export interface NotificationEvent extends ExtendableEvent { readonly action: string; readonly notification: Notification; } @@ -91,11 +91,11 @@ interface PushMessageData { text(): string; } -interface PushEvent extends ExtendableEvent { +export interface PushEvent extends ExtendableEvent { readonly data: PushMessageData | null; } -interface PushSubscriptionChangeEvent extends ExtendableEvent { +export interface PushSubscriptionChangeEvent extends ExtendableEvent { readonly newSubscription: PushSubscription | null; readonly oldSubscription: PushSubscription | null; } diff --git a/packages-exp/messaging-types-exp/package.json b/packages-exp/messaging-types-exp/package.json index 6140123a772..15ba8bcdd01 100644 --- a/packages-exp/messaging-types-exp/package.json +++ b/packages-exp/messaging-types-exp/package.json @@ -1,7 +1,7 @@ { "name": "@firebase/messaging-types-exp", "private": true, - "version": "0.0.800", + "version": "0.0.900", "description": "@firebase/messaging Types", "author": "Firebase (https://firebase.google.com/)", "license": "Apache-2.0", From e4b967fdce77fae0de96569ebc00fb549760013c Mon Sep 17 00:00:00 2001 From: kai Date: Tue, 15 Dec 2020 16:03:12 -0800 Subject: [PATCH 09/17] Add js doc --- packages-exp/messaging-exp/src/api.ts | 63 +++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages-exp/messaging-exp/src/api.ts b/packages-exp/messaging-exp/src/api.ts index cb32ff09b21..5f23959802d 100644 --- a/packages-exp/messaging-exp/src/api.ts +++ b/packages-exp/messaging-exp/src/api.ts @@ -30,6 +30,11 @@ import { getToken as _getToken } from './api/getToken'; import { onBackgroundMessage as _onBackgroundMessage } from './api/onBackgroundMessage'; import { onMessage as _onMessage } from './api/onMessage'; +/** + * Retrieves a firebase messaging instance. + * + * @return the firebase messaging instance associated with the provided firebase app. + */ export function getMessaging(app: FirebaseApp): FirebaseMessaging { const messagingProvider: Provider<'messaging-exp'> = _getProvider( app, @@ -39,6 +44,33 @@ export function getMessaging(app: FirebaseApp): FirebaseMessaging { return messagingProvider.getImmediate(); } +/** + * 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 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 messaging: the messaging instance. + * @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. + * + */ export async function getToken( messaging: FirebaseMessaging, options?: { vapidKey?: string; swReg?: ServiceWorkerRegistration } @@ -46,10 +78,30 @@ export async function getToken( return _getToken(messaging as MessagingService, options); } +/** + * Deletes the registration token associated with this messaging instance and unsubscribes the + * messaging instance from the push subscription. + * + * @param messaging: the messaging instance. + * + * @return The promise resolves when the token has been successfully deleted. + */ export function deleteToken(messaging: FirebaseMessaging): Promise { return _deleteToken(messaging as MessagingService); } +/** + * 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 messaging: the messaging instance. + * @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. + */ export function onMessage( messaging: FirebaseMessaging, nextOrObserver: NextFn | Observer @@ -57,6 +109,17 @@ export function onMessage( return _onMessage(messaging as MessagingService, nextOrObserver); } +/** + * 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 messaging: the messaging instance. + * @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 + */ export function onBackgroundMessage( messaging: FirebaseMessaging, nextOrObserver: NextFn | Observer From 4308cd3f04bf1d58227bda20f2b029e133664534 Mon Sep 17 00:00:00 2001 From: kai Date: Wed, 16 Dec 2020 17:02:27 -0800 Subject: [PATCH 10/17] Create release roll up - --- common/api-review/messaging-exp.api.md | 8 +- packages-exp/messaging-exp/rollup.config.js | 1 + .../messaging-exp/rollup.config.release.js | 99 +++++++++++++++++++ packages-exp/messaging-exp/src/index.sw.ts | 7 ++ packages-exp/messaging-exp/src/index.ts | 7 ++ .../messaging-exp/src/messaging-service.ts | 3 +- 6 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages-exp/messaging-exp/rollup.config.release.js diff --git a/common/api-review/messaging-exp.api.md b/common/api-review/messaging-exp.api.md index 2af9365cc1e..1053bdf9ae4 100644 --- a/common/api-review/messaging-exp.api.md +++ b/common/api-review/messaging-exp.api.md @@ -11,19 +11,19 @@ import { NextFn } from '@firebase/util'; import { Observer } from '@firebase/util'; import { Unsubscribe } from '@firebase/util'; -// @public (undocumented) +// @public export function deleteToken(messaging: FirebaseMessaging): Promise; -// @public (undocumented) +// @public export function getMessaging(app: FirebaseApp): FirebaseMessaging; -// @public (undocumented) +// @public export function getToken(messaging: FirebaseMessaging, options?: { vapidKey?: string; swReg?: ServiceWorkerRegistration; }): Promise; -// @public (undocumented) +// @public export function onMessage(messaging: FirebaseMessaging, nextOrObserver: NextFn | Observer): Unsubscribe; diff --git a/packages-exp/messaging-exp/rollup.config.js b/packages-exp/messaging-exp/rollup.config.js index 92be88f8786..dcd93d51934 100644 --- a/packages-exp/messaging-exp/rollup.config.js +++ b/packages-exp/messaging-exp/rollup.config.js @@ -82,4 +82,5 @@ const es2017Builds = [ external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) } ]; + export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/messaging-exp/rollup.config.release.js b/packages-exp/messaging-exp/rollup.config.release.js new file mode 100644 index 00000000000..dba034ec5cd --- /dev/null +++ b/packages-exp/messaging-exp/rollup.config.release.js @@ -0,0 +1,99 @@ +/** + * @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 { importPathTransformer } from '../../scripts/exp/ts-transform-import-path'; +import json from 'rollup-plugin-json'; +import pkg from './package.json'; +import typescript from 'typescript'; +import typescriptPlugin from 'rollup-plugin-typescript2'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript, + clean: true, + abortOnError: false, + transformers: [importPathTransformer] + }), + json() +]; + +const es5Builds = [ + // window builds + { + input: 'src/index.ts', + output: [ + { file: pkg.main, format: 'cjs', sourcemap: true }, + { file: pkg.module, format: 'es', sourcemap: true } + ], + plugins: es5BuildPlugins, + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + }, + + // sw builds + { + input: 'src/index.sw.ts', + output: [{ file: pkg.sw, format: 'es', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + abortOnError: false, + clean: true, + transformers: [importPathTransformer], + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }), + json({ preferConst: true }) +]; + +const es2017Builds = [ + { + input: 'src/index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + treeshake: { + moduleSideEffects: false + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/messaging-exp/src/index.sw.ts b/packages-exp/messaging-exp/src/index.sw.ts index 6aae953d4f1..0436690f0f7 100644 --- a/packages-exp/messaging-exp/src/index.sw.ts +++ b/packages-exp/messaging-exp/src/index.sw.ts @@ -15,8 +15,15 @@ * limitations under the License. */ +import { FirebaseMessaging } from '@firebase/messaging-types-exp'; import { registerMessaging } from './helpers/register'; export { onBackgroundMessage, getMessaging } from './api'; registerMessaging(); + +declare module '@firebase/component' { + interface NameServiceMapping { + 'messaging-exp': FirebaseMessaging; + } +} diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index 7447c3ca982..aba7147e8f1 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -15,8 +15,15 @@ * limitations under the License. */ +import { FirebaseMessaging } from '@firebase/messaging-types-exp'; import { registerMessaging } from './helpers/register'; export { getToken, deleteToken, onMessage, getMessaging } from './api'; registerMessaging(); + +declare module '@firebase/component' { + interface NameServiceMapping { + 'messaging-exp': FirebaseMessaging; + } +} diff --git a/packages-exp/messaging-exp/src/messaging-service.ts b/packages-exp/messaging-exp/src/messaging-service.ts index ed98946e5de..96cac006c40 100644 --- a/packages-exp/messaging-exp/src/messaging-service.ts +++ b/packages-exp/messaging-exp/src/messaging-service.ts @@ -28,6 +28,7 @@ import { extractAppConfig } from './helpers/extract-app-config'; export class MessagingService implements _FirebaseService { readonly app!: FirebaseApp; readonly firebaseDependencies!: FirebaseInternalDependencies; + deleteService!: () => Promise; swRegistration?: ServiceWorkerRegistration; vapidKey?: string; @@ -58,6 +59,6 @@ export class MessagingService implements _FirebaseService { } _delete(): Promise { - throw new Error('Method not implemented.'); + return this.deleteService(); } } From 69d9d1bc0c5852aba9ede185928615ed6bdf3db6 Mon Sep 17 00:00:00 2001 From: kai Date: Thu, 17 Dec 2020 10:49:19 -0800 Subject: [PATCH 11/17] Add messaging to firebase-exp/ --- packages-exp/firebase-exp/messaging/index.ts | 17 +++++++++++++++++ .../firebase-exp/messaging/package.json | 7 +++++++ packages-exp/firebase-exp/package.json | 4 +++- 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 packages-exp/firebase-exp/messaging/index.ts create mode 100644 packages-exp/firebase-exp/messaging/package.json diff --git a/packages-exp/firebase-exp/messaging/index.ts b/packages-exp/firebase-exp/messaging/index.ts new file mode 100644 index 00000000000..94c90207d03 --- /dev/null +++ b/packages-exp/firebase-exp/messaging/index.ts @@ -0,0 +1,17 @@ +/** + * @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. + */ +export * from '@firebase/messaging-exp'; diff --git a/packages-exp/firebase-exp/messaging/package.json b/packages-exp/firebase-exp/messaging/package.json new file mode 100644 index 00000000000..c2e46721890 --- /dev/null +++ b/packages-exp/firebase-exp/messaging/package.json @@ -0,0 +1,7 @@ +{ + "name": "firebase-exp/messaging", + "main": "dist/index.cjs.js", + "browser": "dist/index.esm.js", + "module": "dist/index.esm.js", + "typings": "dist/messaging/index.d.ts" +} diff --git a/packages-exp/firebase-exp/package.json b/packages-exp/firebase-exp/package.json index ad8f4592f75..c72bda4c9d2 100644 --- a/packages-exp/firebase-exp/package.json +++ b/packages-exp/firebase-exp/package.json @@ -43,7 +43,9 @@ "@firebase/functions-exp": "0.0.900", "@firebase/firestore": "2.0.5", "@firebase/performance-exp": "0.0.900", - "@firebase/remote-config-exp": "0.0.900" + "@firebase/remote-config-exp": "0.0.900", + "@firebase/messaging-exp": "0.0.900" + }, "devDependencies": { "rollup": "2.33.2", From 3fdb6d98e7199cde90b2bd4f40d5497161bf9db9 Mon Sep 17 00:00:00 2001 From: kai Date: Thu, 17 Dec 2020 11:25:42 -0800 Subject: [PATCH 12/17] Fix typing error --- packages-exp/messaging-exp/src/index.sw.ts | 4 ++-- packages-exp/messaging-exp/src/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages-exp/messaging-exp/src/index.sw.ts b/packages-exp/messaging-exp/src/index.sw.ts index 0436690f0f7..363bcc1301b 100644 --- a/packages-exp/messaging-exp/src/index.sw.ts +++ b/packages-exp/messaging-exp/src/index.sw.ts @@ -20,10 +20,10 @@ import { registerMessaging } from './helpers/register'; export { onBackgroundMessage, getMessaging } from './api'; -registerMessaging(); - declare module '@firebase/component' { interface NameServiceMapping { 'messaging-exp': FirebaseMessaging; } } + +registerMessaging(); diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index aba7147e8f1..9fba2c0b93b 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -20,10 +20,10 @@ import { registerMessaging } from './helpers/register'; export { getToken, deleteToken, onMessage, getMessaging } from './api'; -registerMessaging(); - declare module '@firebase/component' { interface NameServiceMapping { 'messaging-exp': FirebaseMessaging; } } + +registerMessaging(); From 370575ca84f5d4bb215ea975f2085b03215acc7e Mon Sep 17 00:00:00 2001 From: kai Date: Thu, 17 Dec 2020 11:39:14 -0800 Subject: [PATCH 13/17] Try fix type error previously working. start showing errror after the register.ts refactor. reverting back to see --- packages-exp/messaging-exp/src/index.sw.ts | 31 ++++++++++++++++++++-- packages-exp/messaging-exp/src/index.ts | 31 ++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages-exp/messaging-exp/src/index.sw.ts b/packages-exp/messaging-exp/src/index.sw.ts index 363bcc1301b..38718997cbd 100644 --- a/packages-exp/messaging-exp/src/index.sw.ts +++ b/packages-exp/messaging-exp/src/index.sw.ts @@ -15,8 +15,18 @@ * limitations under the License. */ +import { + Component, + ComponentContainer, + ComponentType, + InstanceFactory +} from '@firebase/component'; +import { ERROR_FACTORY, ErrorCode } from './util/errors'; + import { FirebaseMessaging } from '@firebase/messaging-types-exp'; -import { registerMessaging } from './helpers/register'; +import { MessagingService } from './messaging-service'; +import { _registerComponent } from '@firebase/app-exp'; +import { isSupported } from './helpers/isSupported'; export { onBackgroundMessage, getMessaging } from './api'; @@ -26,4 +36,21 @@ declare module '@firebase/component' { } } -registerMessaging(); +// registerMessaging(); +const messagingFactory: InstanceFactory<'messaging-exp'> = ( + container: ComponentContainer +) => { + if (!isSupported()) { + throw ERROR_FACTORY.create(ErrorCode.UNSUPPORTED_BROWSER); + } + + return new MessagingService( + container.getProvider('app-exp').getImmediate(), + container.getProvider('installations-exp-internal').getImmediate(), + container.getProvider('analytics-internal') + ); +}; + +_registerComponent( + new Component('messaging-exp', messagingFactory, ComponentType.PUBLIC) +); diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index 9fba2c0b93b..700fc81db64 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -15,8 +15,18 @@ * limitations under the License. */ +import { + Component, + ComponentContainer, + ComponentType, + InstanceFactory +} from '@firebase/component'; +import { ERROR_FACTORY, ErrorCode } from './util/errors'; + import { FirebaseMessaging } from '@firebase/messaging-types-exp'; -import { registerMessaging } from './helpers/register'; +import { MessagingService } from './messaging-service'; +import { _registerComponent } from '@firebase/app-exp'; +import { isSupported } from './helpers/isSupported'; export { getToken, deleteToken, onMessage, getMessaging } from './api'; @@ -26,4 +36,21 @@ declare module '@firebase/component' { } } -registerMessaging(); +// registerMessaging(); +const messagingFactory: InstanceFactory<'messaging-exp'> = ( + container: ComponentContainer +) => { + if (!isSupported()) { + throw ERROR_FACTORY.create(ErrorCode.UNSUPPORTED_BROWSER); + } + + return new MessagingService( + container.getProvider('app-exp').getImmediate(), + container.getProvider('installations-exp-internal').getImmediate(), + container.getProvider('analytics-internal') + ); +}; + +_registerComponent( + new Component('messaging-exp', messagingFactory, ComponentType.PUBLIC) +); From 00282460cbcf9373c73526162610513e2306941a Mon Sep 17 00:00:00 2001 From: kai Date: Thu, 17 Dec 2020 11:55:27 -0800 Subject: [PATCH 14/17] Rmove moduale declartion --- packages-exp/messaging-exp/src/index.sw.ts | 41 ++++------------------ packages-exp/messaging-exp/src/index.ts | 41 ++++------------------ 2 files changed, 14 insertions(+), 68 deletions(-) diff --git a/packages-exp/messaging-exp/src/index.sw.ts b/packages-exp/messaging-exp/src/index.sw.ts index 38718997cbd..e700eb3c1f7 100644 --- a/packages-exp/messaging-exp/src/index.sw.ts +++ b/packages-exp/messaging-exp/src/index.sw.ts @@ -15,42 +15,15 @@ * limitations under the License. */ -import { - Component, - ComponentContainer, - ComponentType, - InstanceFactory -} from '@firebase/component'; -import { ERROR_FACTORY, ErrorCode } from './util/errors'; - import { FirebaseMessaging } from '@firebase/messaging-types-exp'; -import { MessagingService } from './messaging-service'; -import { _registerComponent } from '@firebase/app-exp'; -import { isSupported } from './helpers/isSupported'; +import { registerMessaging } from './helpers/register'; export { onBackgroundMessage, getMessaging } from './api'; -declare module '@firebase/component' { - interface NameServiceMapping { - 'messaging-exp': FirebaseMessaging; - } -} - -// registerMessaging(); -const messagingFactory: InstanceFactory<'messaging-exp'> = ( - container: ComponentContainer -) => { - if (!isSupported()) { - throw ERROR_FACTORY.create(ErrorCode.UNSUPPORTED_BROWSER); - } - - return new MessagingService( - container.getProvider('app-exp').getImmediate(), - container.getProvider('installations-exp-internal').getImmediate(), - container.getProvider('analytics-internal') - ); -}; +// declare module '@firebase/component' { +// interface NameServiceMapping { +// 'messaging-exp': FirebaseMessaging; +// } +// } -_registerComponent( - new Component('messaging-exp', messagingFactory, ComponentType.PUBLIC) -); +registerMessaging(); diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index 700fc81db64..c951496c88c 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -15,42 +15,15 @@ * limitations under the License. */ -import { - Component, - ComponentContainer, - ComponentType, - InstanceFactory -} from '@firebase/component'; -import { ERROR_FACTORY, ErrorCode } from './util/errors'; - import { FirebaseMessaging } from '@firebase/messaging-types-exp'; -import { MessagingService } from './messaging-service'; -import { _registerComponent } from '@firebase/app-exp'; -import { isSupported } from './helpers/isSupported'; +import { registerMessaging } from './helpers/register'; export { getToken, deleteToken, onMessage, getMessaging } from './api'; -declare module '@firebase/component' { - interface NameServiceMapping { - 'messaging-exp': FirebaseMessaging; - } -} - -// registerMessaging(); -const messagingFactory: InstanceFactory<'messaging-exp'> = ( - container: ComponentContainer -) => { - if (!isSupported()) { - throw ERROR_FACTORY.create(ErrorCode.UNSUPPORTED_BROWSER); - } - - return new MessagingService( - container.getProvider('app-exp').getImmediate(), - container.getProvider('installations-exp-internal').getImmediate(), - container.getProvider('analytics-internal') - ); -}; +// declare module '@firebase/component' { +// interface NameServiceMapping { +// 'messaging-exp': FirebaseMessaging; +// } +// } -_registerComponent( - new Component('messaging-exp', messagingFactory, ComponentType.PUBLIC) -); +registerMessaging(); From f41f23aa5ccf255ef0921e9bcb72713adce8be00 Mon Sep 17 00:00:00 2001 From: kai Date: Thu, 17 Dec 2020 12:00:50 -0800 Subject: [PATCH 15/17] fix --- packages-exp/messaging-exp/src/index.sw.ts | 2 +- packages-exp/messaging-exp/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages-exp/messaging-exp/src/index.sw.ts b/packages-exp/messaging-exp/src/index.sw.ts index e700eb3c1f7..8ecb6e86011 100644 --- a/packages-exp/messaging-exp/src/index.sw.ts +++ b/packages-exp/messaging-exp/src/index.sw.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FirebaseMessaging } from '@firebase/messaging-types-exp'; +// import { FirebaseMessaging } from '@firebase/messaging-types-exp'; import { registerMessaging } from './helpers/register'; export { onBackgroundMessage, getMessaging } from './api'; diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index c951496c88c..4bf39fccaef 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FirebaseMessaging } from '@firebase/messaging-types-exp'; +// import { FirebaseMessaging } from '@firebase/messaging-types-exp'; import { registerMessaging } from './helpers/register'; export { getToken, deleteToken, onMessage, getMessaging } from './api'; From 96ceea691f96d677cdefece439dbb28e72d95ebb Mon Sep 17 00:00:00 2001 From: kai Date: Thu, 17 Dec 2020 13:38:48 -0800 Subject: [PATCH 16/17] Remove unused deps --- packages-exp/messaging-exp/src/index.sw.ts | 12 ++++++------ packages-exp/messaging-exp/src/index.ts | 12 ++++++------ packages-exp/messaging-types-exp/index.d.ts | 8 -------- packages-exp/messaging-types-exp/package.json | 3 ++- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages-exp/messaging-exp/src/index.sw.ts b/packages-exp/messaging-exp/src/index.sw.ts index 8ecb6e86011..363bcc1301b 100644 --- a/packages-exp/messaging-exp/src/index.sw.ts +++ b/packages-exp/messaging-exp/src/index.sw.ts @@ -15,15 +15,15 @@ * limitations under the License. */ -// import { FirebaseMessaging } from '@firebase/messaging-types-exp'; +import { FirebaseMessaging } from '@firebase/messaging-types-exp'; import { registerMessaging } from './helpers/register'; export { onBackgroundMessage, getMessaging } from './api'; -// declare module '@firebase/component' { -// interface NameServiceMapping { -// 'messaging-exp': FirebaseMessaging; -// } -// } +declare module '@firebase/component' { + interface NameServiceMapping { + 'messaging-exp': FirebaseMessaging; + } +} registerMessaging(); diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts index 4bf39fccaef..9fba2c0b93b 100644 --- a/packages-exp/messaging-exp/src/index.ts +++ b/packages-exp/messaging-exp/src/index.ts @@ -15,15 +15,15 @@ * limitations under the License. */ -// import { FirebaseMessaging } from '@firebase/messaging-types-exp'; +import { FirebaseMessaging } from '@firebase/messaging-types-exp'; import { registerMessaging } from './helpers/register'; export { getToken, deleteToken, onMessage, getMessaging } from './api'; -// declare module '@firebase/component' { -// interface NameServiceMapping { -// 'messaging-exp': FirebaseMessaging; -// } -// } +declare module '@firebase/component' { + interface NameServiceMapping { + 'messaging-exp': FirebaseMessaging; + } +} registerMessaging(); diff --git a/packages-exp/messaging-types-exp/index.d.ts b/packages-exp/messaging-types-exp/index.d.ts index f1fc1714d76..7bd08407fd4 100644 --- a/packages-exp/messaging-types-exp/index.d.ts +++ b/packages-exp/messaging-types-exp/index.d.ts @@ -15,14 +15,6 @@ * limitations under the License. */ -import { - Observer, - Unsubscribe, - NextFn, - ErrorFn, - 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 diff --git a/packages-exp/messaging-types-exp/package.json b/packages-exp/messaging-types-exp/package.json index 15ba8bcdd01..4dac4749dab 100644 --- a/packages-exp/messaging-types-exp/package.json +++ b/packages-exp/messaging-types-exp/package.json @@ -10,7 +10,8 @@ "test:ci": "node ../../scripts/run_tests_in_ci.js" }, "files": [ - "index.d.ts" + "index.d.ts", + "index.sw.d.ts" ], "peerDependencies": { "@firebase/app-types": "0.x" From 9847c41fc7cf700a00ddbc6556f7147e7898d369 Mon Sep 17 00:00:00 2001 From: kai Date: Mon, 21 Dec 2020 09:47:53 -0800 Subject: [PATCH 17/17] updates dep versions --- packages-exp/firebase-exp/package.json | 3 ++- packages-exp/messaging-exp/package.json | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages-exp/firebase-exp/package.json b/packages-exp/firebase-exp/package.json index c72bda4c9d2..a2b1ee50562 100644 --- a/packages-exp/firebase-exp/package.json +++ b/packages-exp/firebase-exp/package.json @@ -68,6 +68,7 @@ "firestore", "firestore/lite", "performance", - "remote-config" + "remote-config", + "messaging" ] } diff --git a/packages-exp/messaging-exp/package.json b/packages-exp/messaging-exp/package.json index 67fa75a6067..6a85ab9edef 100644 --- a/packages-exp/messaging-exp/package.json +++ b/packages-exp/messaging-exp/package.json @@ -32,7 +32,7 @@ "@firebase/app-types-exp": "0.x" }, "dependencies": { - "@firebase/component": "0.1.19", + "@firebase/component": "0.1.21", "@firebase/installations-exp": "0.x", "@firebase/messaging-types-exp": "0.0.900", "@firebase/util": "0.3.2", @@ -40,9 +40,9 @@ "tslib": "^1.11.1" }, "devDependencies": { - "rollup-plugin-json": "^4.0.0", + "rollup-plugin-json": "4.0.0", "rollup-plugin-typescript2": "0.27.0", - "ts-essentials": "^6.0.7", + "ts-essentials": "7.0.1", "typescript": "3.8.3" }, "repository": {