diff --git a/.vscode/settings.json b/.vscode/settings.json index 1a4875b99aa..338dd7801dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,4 +11,4 @@ }, "typescript.tsdk": "node_modules/typescript/lib", "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 new file mode 100644 index 00000000000..1053bdf9ae4 --- /dev/null +++ b/common/api-review/messaging-exp.api.md @@ -0,0 +1,32 @@ +## 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 + +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 +export function deleteToken(messaging: FirebaseMessaging): Promise; + +// @public +export function getMessaging(app: FirebaseApp): FirebaseMessaging; + +// @public +export function getToken(messaging: FirebaseMessaging, options?: { + vapidKey?: string; + swReg?: ServiceWorkerRegistration; +}): Promise; + +// @public +export function onMessage(messaging: FirebaseMessaging, nextOrObserver: NextFn | Observer): Unsubscribe; + + +// (No @packageDocumentation comment for this package) + +``` 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..a2b1ee50562 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", @@ -66,6 +68,7 @@ "firestore", "firestore/lite", "performance", - "remote-config" + "remote-config", + "messaging" ] } 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/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/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/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..6a85ab9edef --- /dev/null +++ b/packages-exp/messaging-exp/package.json @@ -0,0 +1,57 @@ +{ + "name": "@firebase/messaging-exp", + "private": true, + "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" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "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", + "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", + "api-report": "api-extractor run --local --verbose", + "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.21", + "@firebase/installations-exp": "0.x", + "@firebase/messaging-types-exp": "0.0.900", + "@firebase/util": "0.3.2", + "idb": "3.0.2", + "tslib": "^1.11.1" + }, + "devDependencies": { + "rollup-plugin-json": "4.0.0", + "rollup-plugin-typescript2": "0.27.0", + "ts-essentials": "7.0.1", + "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..dcd93d51934 --- /dev/null +++ b/packages-exp/messaging-exp/rollup.config.js @@ -0,0 +1,86 @@ +/** + * @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 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 + }), + 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, + 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, + 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/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/api.ts b/packages-exp/messaging-exp/src/api.ts new file mode 100644 index 00000000000..5f23959802d --- /dev/null +++ b/packages-exp/messaging-exp/src/api.ts @@ -0,0 +1,128 @@ +/** + * @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 { + 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'; +import { _getProvider } from '@firebase/app-exp'; +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, + 'messaging-exp' + ); + + 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 } +): Promise { + 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 +): Unsubscribe { + 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 +): Unsubscribe { + 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/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..fdf782107cc --- /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-exp'; +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..57ca6b26e59 --- /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-exp'; +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..796717e2b33 --- /dev/null +++ b/packages-exp/messaging-exp/src/helpers/extract-app-config.test.ts @@ -0,0 +1,80 @@ +/** + * @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-exp'; +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(); + delete firebaseApp.options; + expect(() => extractAppConfig(firebaseApp)).to.throw( + 'Missing App configuration value: "App Configuration Object"' + ); + + firebaseApp = getFakeApp(); + 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..8007f35e2c3 --- /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-exp'; + +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/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/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/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/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/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/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/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.sw.ts b/packages-exp/messaging-exp/src/index.sw.ts new file mode 100644 index 00000000000..363bcc1301b --- /dev/null +++ b/packages-exp/messaging-exp/src/index.sw.ts @@ -0,0 +1,29 @@ +/** + * @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 { 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; + } +} + +registerMessaging(); diff --git a/packages-exp/messaging-exp/src/index.ts b/packages-exp/messaging-exp/src/index.ts new file mode 100644 index 00000000000..9fba2c0b93b --- /dev/null +++ b/packages-exp/messaging-exp/src/index.ts @@ -0,0 +1,29 @@ +/** + * @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 { 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; + } +} + +registerMessaging(); 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..c8adfabf225 --- /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-exp'; +import { Provider } from '@firebase/component'; +import { _FirebaseInstallationsInternal } from '@firebase/installations-types-exp'; + +export interface FirebaseInternalDependencies { + app: FirebaseApp; + appConfig: AppConfig; + installations: _FirebaseInstallationsInternal; + 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/internals/idb-manager.test.ts b/packages-exp/messaging-exp/src/internals/idb-manager.test.ts new file mode 100644 index 00000000000..c66f11ad440 --- /dev/null +++ b/packages-exp/messaging-exp/src/internals/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 '../helpers/migrate-old-database'; + +import { dbGet, dbRemove, dbSet } from '../internals/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/internals/idb-manager.ts b/packages-exp/messaging-exp/src/internals/idb-manager.ts new file mode 100644 index 00000000000..4ddebf5ae96 --- /dev/null +++ b/packages-exp/messaging-exp/src/internals/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 '../helpers/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/internals/requests.test.ts b/packages-exp/messaging-exp/src/internals/requests.test.ts new file mode 100644 index 00000000000..37aebb6d7ec --- /dev/null +++ b/packages-exp/messaging-exp/src/internals/requests.test.ts @@ -0,0 +1,224 @@ +/** + * @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 installationsModule from '@firebase/installations-exp'; + +import { + ApiRequestBody, + requestDeleteToken, + requestGetToken, + requestUpdateToken +} from './requests'; + +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; + let getAuthTokenStub: Stub; + + beforeEach(() => { + tokenDetails = getFakeTokenDetails(); + firebaseDependencies = getFakeFirebaseDependencies(); + fetchStub = stub(self, 'fetch'); + getAuthTokenStub = stub(installationsModule, 'getToken').resolves( + 'authToken' + ); + }); + + 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); + expect(getAuthTokenStub).to.be.called; + 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/internals/requests.ts b/packages-exp/messaging-exp/src/internals/requests.ts new file mode 100644 index 00000000000..21fc6f2acf1 --- /dev/null +++ b/packages-exp/messaging-exp/src/internals/requests.ts @@ -0,0 +1,187 @@ +/** + * @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'; +import { getToken } from '@firebase/installations-exp'; + +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 getToken(installations); + + 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/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/internals/token-manager.ts b/packages-exp/messaging-exp/src/internals/token-manager.ts new file mode 100644 index 00000000000..1f14a9e055c --- /dev/null +++ b/packages-exp/messaging-exp/src/internals/token-manager.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 { SubscriptionOptions, TokenDetails } from '../interfaces/token-details'; +import { + arrayToBase64, + base64ToArray +} from '../helpers/array-base64-translator'; +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. +const TOKEN_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +export async function getTokenInternal( + messaging: MessagingService +): Promise { + const pushSubscription = await getPushSubscription( + messaging.swRegistration!, + messaging.vapidKey! + ); + + const subscriptionOptions: SubscriptionOptions = { + 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(messaging.firebaseDependencies, subscriptionOptions); + } else if ( + !isTokenValid(tokenDetails.subscriptionOptions!, subscriptionOptions) + ) { + // Invalid token, get a new one. + try { + await requestDeleteToken( + messaging.firebaseDependencies!, + tokenDetails.token + ); + } catch (e) { + // Suppress errors because of #2364 + console.warn(e); + } + + return getNewToken(messaging.firebaseDependencies!, subscriptionOptions); + } else if (Date.now() >= tokenDetails.createTime + TOKEN_EXPIRATION_MS) { + // Weekly token refresh + return updateToken(messaging, { + token: tokenDetails.token, + createTime: Date.now(), + subscriptionOptions + }); + } 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 deleteTokenInternal( + messaging: MessagingService +): Promise { + const tokenDetails = await dbGet(messaging.firebaseDependencies); + if (tokenDetails) { + await requestDeleteToken( + messaging.firebaseDependencies, + tokenDetails.token + ); + await dbRemove(messaging.firebaseDependencies); + } + + // Unsubscribe from the push subscription. + const pushSubscription = await messaging.swRegistration!.pushManager.getSubscription(); + if (pushSubscription) { + return pushSubscription.unsubscribe(); + } + + // If there's no SW, consider it a success. + return true; +} + +async function updateToken( + messaging: MessagingService, + tokenDetails: TokenDetails +): Promise { + try { + const updatedToken = await requestUpdateToken( + messaging.firebaseDependencies, + tokenDetails + ); + + const updatedTokenDetails: TokenDetails = { + ...tokenDetails, + token: updatedToken, + createTime: Date.now() + }; + + await dbSet(messaging.firebaseDependencies, updatedTokenDetails); + return updatedToken; + } catch (e) { + await deleteTokenInternal(messaging); + 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/listeners/messageEventListener.ts b/packages-exp/messaging-exp/src/listeners/messageEventListener.ts new file mode 100644 index 00000000000..e40113376af --- /dev/null +++ b/packages-exp/messaging-exp/src/listeners/messageEventListener.ts @@ -0,0 +1,58 @@ +/** + * @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 { 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/listeners/sw-controller.test.ts b/packages-exp/messaging-exp/src/listeners/sw-controller.test.ts new file mode 100644 index 00000000000..8a7a901dafb --- /dev/null +++ b/packages-exp/messaging-exp/src/listeners/sw-controller.test.ts @@ -0,0 +1,465 @@ +/** + * @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 '../internals/token-manager'; + +import { + CONSOLE_CAMPAIGN_ANALYTICS_ENABLED, + CONSOLE_CAMPAIGN_ID, + CONSOLE_CAMPAIGN_NAME, + CONSOLE_CAMPAIGN_TIME, + 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 { + NotificationEvent, + ServiceWorkerGlobalScope, + ServiceWorkerGlobalScopeEventMap, + WindowClient +} from '../util/sw-types'; +import { + getFakeAnalyticsProvider, + getFakeApp, + getFakeInstallations +} from '../testing/fakes/firebase-dependencies'; +import { spy, stub } from 'sinon'; + +import { MessagingService } from '../messaging-service'; +import { Stub } from '../testing/sinon-types'; +import { SwController } from './sw-controller'; +import { expect } from 'chai'; + +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' +}; + +describe('SwController', () => { + let addEventListenerStub: Stub; + // eslint-disable-next-line @typescript-eslint/ban-types + let eventListenerMap: Map; + let messaging: MessagingService; + let getTokenStub: Stub; + let deleteTokenStub: Stub< + typeof tokenManagementModule['deleteTokenInternal'] + >; + + 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, 'getTokenInternal').resolves( + 'token-value' + ); + deleteTokenStub = stub( + tokenManagementModule, + 'deleteTokenInternal' + ).resolves(true); + + messaging = new MessagingService( + getFakeApp(), + getFakeInstallations(), + getFakeAnalyticsProvider() + ); + new SwController(messaging); + }); + + afterEach(() => { + restoreServiceWorker(); + }); + + 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'); + }); + + 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('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('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/listeners/sw-controller.ts b/packages-exp/messaging-exp/src/listeners/sw-controller.ts new file mode 100644 index 00000000000..2b6209c292d --- /dev/null +++ b/packages-exp/messaging-exp/src/listeners/sw-controller.ts @@ -0,0 +1,282 @@ +/** + * @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 } from '../util/constants'; +import { + MessagePayloadInternal, + MessageType, + NotificationPayloadInternal +} from '../interfaces/internal-message-payload'; +import { + NotificationEvent, + PushEvent, + PushSubscriptionChangeEvent, + ServiceWorkerGlobalScope, + WindowClient +} from '../util/sw-types'; +import { + deleteTokenInternal, + getTokenInternal +} from '../internals/token-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'; + +// Let TS know that this is a service worker +declare const self: ServiceWorkerGlobalScope; + +export class SwController { + constructor(private readonly messaging: MessagingService) { + 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)); + }); + } + + /** + * 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) { + // 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 if possible and pass to onBackgroundMessage hook + if (!!internalPayload.notification) { + await showNotification(wrapInternalPayload(internalPayload)); + } + + if (!!this.messaging.onBackgroundMessageHandler) { + const payload = externalizePayload(internalPayload); + + if (typeof this.messaging.onBackgroundMessageHandler === 'function') { + this.messaging.onBackgroundMessageHandler(payload); + } else { + this.messaging.onBackgroundMessageHandler.next(payload); + } + } + } + + async onSubChange(event: PushSubscriptionChangeEvent): Promise { + const { newSubscription } = event; + if (!newSubscription) { + // Subscription revoked, delete token + await deleteTokenInternal(this.messaging); + return; + } + + 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 { + 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/messaging-service.ts b/packages-exp/messaging-exp/src/messaging-service.ts new file mode 100644 index 00000000000..96cac006c40 --- /dev/null +++ b/packages-exp/messaging-exp/src/messaging-service.ts @@ -0,0 +1,64 @@ +/** + * @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 { 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 { + readonly app!: FirebaseApp; + readonly firebaseDependencies!: FirebaseInternalDependencies; + deleteService!: () => Promise; + + swRegistration?: ServiceWorkerRegistration; + vapidKey?: string; + + onBackgroundMessageHandler: + | NextFn + | Observer + | null = null; + + onMessageHandler: + | NextFn + | Observer + | null = null; + + constructor( + app: FirebaseApp, + installations: _FirebaseInstallationsInternal, + analyticsProvider: Provider + ) { + const appConfig = extractAppConfig(app); + + this.firebaseDependencies = { + app, + appConfig, + installations, + analyticsProvider + }; + } + + _delete(): Promise { + return this.deleteService(); + } +} 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..b72ec417e56 --- /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 { 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( + options: FirebaseOptions = {} +): FirebaseInternalDependencies { + const app = getFakeApp(options); + return { + app, + appConfig: extractAppConfig(app), + installations: getFakeInstallations(), + analyticsProvider: getFakeAnalyticsProvider() + }; +} + +export function getFakeApp(options: FirebaseOptions = {}): any { + 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 any, + installations: () => getFakeInstallations() + }; +} + +export function getFakeInstallations(): _FirebaseInstallationsInternal { + return { + getId: async () => 'FID', + getToken: async () => 'authToken' + }; +} + +export function getFakeAnalyticsProvider(): Provider< + FirebaseAnalyticsInternalName +> { + 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..8cc09811e0e --- /dev/null +++ b/packages-exp/messaging-exp/src/testing/fakes/service-worker.ts @@ -0,0 +1,219 @@ +/** + * @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 { + Client, + Clients, + ExtendableEvent, + ServiceWorkerGlobalScope, + WindowClient +} from '../../util/sw-types'; + +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..61b3524ca74 --- /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 '../internals/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..587cc248f50 --- /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. +export 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 +export interface ServiceWorkerGlobalScopeEventMap { + notificationclick: NotificationEvent; + push: PushEvent; + pushsubscriptionchange: PushSubscriptionChangeEvent; +} + +export interface Client { + readonly id: string; + readonly type: ClientTypes; + readonly url: string; + postMessage(message: any, transfer?: Transferable[]): void; +} + +export interface ClientQueryOptions { + includeReserved?: boolean; + includeUncontrolled?: boolean; + type?: ClientTypes; +} + +export interface WindowClient extends Client { + readonly focused: boolean; + readonly visibilityState: VisibilityState; + focus(): Promise; + navigate(url: string): Promise; +} + +export interface Clients { + claim(): Promise; + get(id: string): Promise; + matchAll(options?: ClientQueryOptions): Promise; + openWindow(url: string): Promise; +} + +export interface ExtendableEvent extends Event { + waitUntil(f: Promise): void; +} + +export interface NotificationEvent extends ExtendableEvent { + readonly action: string; + readonly notification: Notification; +} + +interface PushMessageData { + arrayBuffer(): ArrayBuffer; + blob(): Blob; + json(): any; + text(): string; +} + +export interface PushEvent extends ExtendableEvent { + readonly data: PushMessageData | null; +} + +export 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..c10e1551f13 --- /dev/null +++ b/packages-exp/messaging-types-exp/README.md @@ -0,0 +1,3 @@ +# @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.** 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..7bd08407fd4 --- /dev/null +++ b/packages-exp/messaging-types-exp/index.d.ts @@ -0,0 +1,49 @@ +/** + * @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. + */ + +// 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 {} + +export type FirebaseMessagingName = 'messaging'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'messaging-exp': 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..4dac4749dab --- /dev/null +++ b/packages-exp/messaging-types-exp/package.json @@ -0,0 +1,30 @@ +{ + "name": "@firebase/messaging-types-exp", + "private": true, + "version": "0.0.900", + "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", + "index.sw.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/**/*" + ] +}