diff --git a/.android.env.example b/.android.env.example index 5c769312cca..c1780f0c6e2 100644 --- a/.android.env.example +++ b/.android.env.example @@ -1,16 +1,4 @@ export MM_FOX_CODE="EXAMPLE_FOX_CODE" export MM_BRANCH_KEY_TEST= export MM_BRANCH_KEY_LIVE= -export METAMASK_BUILD_TYPE= -# Firebase -export FCM_CONFIG_API_KEY= -export FCM_CONFIG_AUTH_DOMAIN= -export FCM_CONFIG_PROJECT_ID= -export FCM_CONFIG_STORAGE_BUCKET= -export FCM_CONFIG_MESSAGING_SENDER_ID= -export FCM_CONFIG_APP_ID= -export GOOGLE_SERVICES_B64_ANDROID= -#Notifications Feature Announcements -export FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN= -export FEATURES_ANNOUNCEMENTS_SPACE_ID= - +export METAMASK_BUILD_TYPE= \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 58ceabd2486..0c886aafe63 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -56,12 +56,8 @@ app/components/UI/Swaps @MetaMask/swaps-engineers # Notifications Team app/components/Views/Notifications @MetaMask/notifications app/components/Views/Settings/NotificationsSettings @MetaMask/notifications -app/components/UI/Notifications @MetaMask/notifications -app/reducers/notification @MetaMask/notifications -app/actions/notification @MetaMask/notifications -app/selectors/notification @MetaMask/notifications -app/util/notifications @MetaMask/notifications -app/store/util/notifications @MetaMask/notifications +**/notifications/** @MetaMask/notifications +**/notification/** @MetaMask/notifications # Identity Team app/actions/identity @MetaMask/identity diff --git a/.ios.env.example b/.ios.env.example index cc449b8b6e1..bd49b067660 100644 --- a/.ios.env.example +++ b/.ios.env.example @@ -1,14 +1,3 @@ MM_FOX_CODE = EXAMPLE_FOX_CODE MM_BRANCH_KEY_TEST = MM_BRANCH_KEY_LIVE = -# Firebase -FCM_CONFIG_API_KEY= -FCM_CONFIG_AUTH_DOMAIN= -FCM_CONFIG_PROJECT_ID= -FCM_CONFIG_STORAGE_BUCKET= -FCM_CONFIG_MESSAGING_SENDER_ID= -FCM_CONFIG_APP_ID= -GOOGLE_SERVICES_B64_IOS= -#Notifications Feature Announcements -FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN= -FEATURES_ANNOUNCEMENTS_SPACE_ID= diff --git a/.js.env.example b/.js.env.example index 0d1d2764861..d85e2321e97 100644 --- a/.js.env.example +++ b/.js.env.example @@ -79,12 +79,6 @@ export PORTFOLIO_VIEW="true" # Temporary mechanism to enable security alerts API prior to release. export MM_SECURITY_ALERTS_API_ENABLED="true" # Firebase -export FCM_CONFIG_API_KEY="" -export FCM_CONFIG_AUTH_DOMAIN="" -export FCM_CONFIG_PROJECT_ID="" -export FCM_CONFIG_STORAGE_BUCKET="" -export FCM_CONFIG_MESSAGING_SENDER_ID="" -export FCM_CONFIG_APP_ID="" export GOOGLE_SERVICES_B64_ANDROID="" export GOOGLE_SERVICES_B64_IOS="" # Notifications Feature Announcements diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2770038a620..46e4371b20b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -133,7 +133,12 @@ + android:resource="@color/notificationColor"/> + + #000000 #EBEBED #000000 - #FFFFFF + #FFFFFF + #2A4174 diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index ac52d4d960b..187da3782d1 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -5,5 +5,6 @@ #000000 #EBEBED #000000 - #FFFFFF + #FFFFFF + #2A4174 diff --git a/android/app/src/qa/res/values-night/colors.xml b/android/app/src/qa/res/values-night/colors.xml index 5219ed36746..cc8e5b12813 100644 --- a/android/app/src/qa/res/values-night/colors.xml +++ b/android/app/src/qa/res/values-night/colors.xml @@ -5,5 +5,6 @@ #000000 #EBEBED #000000 - #FFFFFF + #FFFFFF + #2A4174 diff --git a/android/app/src/qa/res/values/colors.xml b/android/app/src/qa/res/values/colors.xml index ac52d4d960b..187da3782d1 100644 --- a/android/app/src/qa/res/values/colors.xml +++ b/android/app/src/qa/res/values/colors.xml @@ -5,5 +5,6 @@ #000000 #EBEBED #000000 - #FFFFFF + #FFFFFF + #2A4174 diff --git a/app.config.js b/app.config.js index f1f87b695a4..a9703a6c013 100644 --- a/app.config.js +++ b/app.config.js @@ -20,4 +20,4 @@ module.exports = { } ] ] -}; +}; \ No newline at end of file diff --git a/app/actions/notification/helpers/index.ts b/app/actions/notification/helpers/index.ts index 34b6b6ff064..368eb10415f 100644 --- a/app/actions/notification/helpers/index.ts +++ b/app/actions/notification/helpers/index.ts @@ -2,12 +2,7 @@ import { getErrorMessage } from '@metamask/utils'; import { notificationsErrors } from '../constants'; import Engine from '../../../core/Engine'; -import { - Notification, - mmStorage, - getAllUUIDs, -} from '../../../util/notifications'; -import type { UserStorage } from '@metamask/notification-services-controller/notification-services'; +import { Notification, mmStorage } from '../../../util/notifications'; export type MarkAsReadNotificationsParam = Pick< Notification, @@ -155,42 +150,3 @@ export const performDeleteStorage = async (): Promise => { return getErrorMessage(error); } }; - -export const enablePushNotifications = async ( - userStorage: UserStorage, - fcmToken?: string, -) => { - try { - const uuids = getAllUUIDs(userStorage); - await Engine.context.NotificationServicesPushController.enablePushNotifications( - uuids, - fcmToken, - ); - } catch (error) { - return getErrorMessage(error); - } -}; - -export const disablePushNotifications = async (userStorage: UserStorage) => { - try { - const uuids = getAllUUIDs(userStorage); - await Engine.context.NotificationServicesPushController.disablePushNotifications( - uuids, - ); - } catch (error) { - return getErrorMessage(error); - } -}; - -export const updateTriggerPushNotifications = async ( - userStorage: UserStorage, -) => { - try { - const uuids = getAllUUIDs(userStorage); - await Engine.context.NotificationServicesPushController.updateTriggerPushNotifications( - uuids, - ); - } catch (error) { - return getErrorMessage(error); - } -}; diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index 5d0f6369b0d..669b311c1cd 100644 --- a/app/components/Nav/Main/index.js +++ b/app/components/Nav/Main/index.js @@ -69,7 +69,6 @@ import { } from '../../../selectors/networkInfos'; import { selectShowIncomingTransactionNetworks } from '../../../selectors/preferencesController'; -import useNotificationHandler from '../../../util/notifications/hooks'; import { DEPRECATED_NETWORKS, NETWORKS_CHAIN_ID, @@ -85,6 +84,7 @@ import isNetworkUiRedesignEnabled from '../../../util/networks/isNetworkUiRedesi import { useConnectionHandler } from '../../../util/navigation/useConnectionHandler'; import { AssetPollingProvider } from '../../hooks/AssetPolling/AssetPollingProvider'; import { getGlobalEthQuery } from '../../../util/networks/global-network'; +import { useRegisterPushNotificationsEffect } from '../../../util/notifications/hooks/useRegisterPushNotificationsEffect'; const Stack = createStackNavigator(); @@ -114,8 +114,9 @@ const Main = (props) => { const { connectionChangeHandler } = useConnectionHandler(props.navigation); + useRegisterPushNotificationsEffect(); + const removeNotVisibleNotifications = props.removeNotVisibleNotifications; - useNotificationHandler(props.navigation); useEnableAutomaticSecurityChecks(); useMinimumVersions(); diff --git a/app/components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.tsx b/app/components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.tsx index b488c94a26b..301f3c50f08 100644 --- a/app/components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.tsx +++ b/app/components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.tsx @@ -56,6 +56,7 @@ const BasicFunctionalityModal = ({ route }: Props) => { selectIsMetamaskNotificationsEnabled, ); + // TODO - should we add notification logic when basic functionality is toggled? const { enableNotifications } = useEnableNotifications(); const enableNotificationsFromModal = useCallback(async () => { diff --git a/app/components/UI/Notification/BaseNotification/index.js b/app/components/UI/Notification/BaseNotification/index.js index 8c137a39168..b41f01b541b 100644 --- a/app/components/UI/Notification/BaseNotification/index.js +++ b/app/components/UI/Notification/BaseNotification/index.js @@ -148,8 +148,6 @@ const getTitle = (status, { nonce, amount, assetType }) => { return strings('notifications.cancelled_title'); case 'error': return strings('notifications.error_title'); - case 'eth_received': - return strings('notifications.eth_received_title'); } }; diff --git a/app/components/Views/Notifications/OptIn/index.tsx b/app/components/Views/Notifications/OptIn/index.tsx index f3c1a380bb5..8ad31b21dfe 100644 --- a/app/components/Views/Notifications/OptIn/index.tsx +++ b/app/components/Views/Notifications/OptIn/index.tsx @@ -75,7 +75,6 @@ const OptIn = () => { }); } else { const { permission } = await NotificationsService.getAllPermissions(); - if (permission !== 'authorized') { return; } diff --git a/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.ts b/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.ts index d60127f96c7..dd63a31a1f6 100644 --- a/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.ts +++ b/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.ts @@ -25,6 +25,7 @@ export function useToggleNotifications({ setUiNotificationStatus, }: Props) { const { trackEvent, createEventBuilder } = useMetrics(); + // Check logic here const toggleNotificationsEnabled = useCallback(async () => { if (!basicFunctionalityEnabled) { navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index a8303c37787..3fc5ddd4f3c 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -384,6 +384,8 @@ enum EVENT_NAME { NOTIFICATIONS_MARKED_ALL_AS_READ = 'Notifications Marked All as Read', NOTIFICATION_DETAIL_CLICKED = 'Notification Detail Clicked', NOTIFICATION_STORAGE_KEY_DELETED = 'Notification Storage Key Deleted', + PUSH_NOTIFICATIONS_RECEIVED = 'Push Notification Received', + PUSH_NOTIFICATIONS_CLICKED = 'Push Notification Clicked', // Smart transactions SMART_TRANSACTION_OPT_IN = 'Smart Transaction Opt In', @@ -880,6 +882,12 @@ const events = { NOTIFICATION_STORAGE_KEY_DELETED: generateOpt( EVENT_NAME.NOTIFICATION_STORAGE_KEY_DELETED, ), + PUSH_NOTIFICATIONS_RECEIVED: generateOpt( + EVENT_NAME.PUSH_NOTIFICATIONS_RECEIVED, + ), + PUSH_NOTIFICATIONS_CLICKED: generateOpt( + EVENT_NAME.PUSH_NOTIFICATIONS_CLICKED, + ), // Simulations INCOMPLETE_ASSET_DISPLAYED: generateOpt( EVENT_NAME.INCOMPLETE_ASSET_DISPLAYED, diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 9e7b2fa7da4..0523501d847 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -120,10 +120,18 @@ import { AuthenticationController, UserStorageController, } from '@metamask/profile-sync-controller'; +import NotificationServicesController, { + type NotificationServicesControllerMessenger, +} from '@metamask/notification-services-controller/notification-services'; +import NotificationServicesPushController, { + type NotificationServicesPushControllerMessenger, +} from '@metamask/notification-services-controller/push-services'; import { - NotificationServicesController, - NotificationServicesPushController, -} from '@metamask/notification-services-controller'; + createRegToken, + deleteRegToken, + createSubscribeToPushNotifications, + isPushNotificationsEnabled, +} from './controllers/PushNotificationController/utils'; ///: END:ONLY_INCLUDE_IF import { getCaveatSpecifications, @@ -1061,54 +1069,17 @@ export class Engine { nativeScryptCrypto: scrypt, }); - const notificationServicesController = - new NotificationServicesController.Controller({ - messenger: this.controllerMessenger.getRestricted({ - name: 'NotificationServicesController', - allowedActions: [ - 'KeyringController:getState', - 'KeyringController:getAccounts', - 'AuthenticationController:getBearerToken', - 'AuthenticationController:isSignedIn', - 'UserStorageController:enableProfileSyncing', - 'UserStorageController:getStorageKey', - 'UserStorageController:performGetStorage', - 'UserStorageController:performSetStorage', - 'NotificationServicesPushController:enablePushNotifications', - 'NotificationServicesPushController:disablePushNotifications', - 'NotificationServicesPushController:updateTriggerPushNotifications', - ], - allowedEvents: [ - 'KeyringController:unlock', - 'KeyringController:lock', - 'KeyringController:stateChange', - ], - }), - state: initialState.NotificationServicesController, - env: { - isPushIntegrated: false, - featureAnnouncements: { - platform: 'mobile', - accessToken: process.env - .FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN as string, - spaceId: process.env.FEATURES_ANNOUNCEMENTS_SPACE_ID as string, - }, - }, - }); - const notificationServicesPushControllerMessenger = this.controllerMessenger.getRestricted({ name: 'NotificationServicesPushController', allowedActions: ['AuthenticationController:getBearerToken'], allowedEvents: [], - }); + }) as unknown as NotificationServicesPushControllerMessenger; const notificationServicesPushController = - new NotificationServicesPushController.Controller({ + new NotificationServicesPushController({ messenger: notificationServicesPushControllerMessenger, - state: initialState.NotificationServicesPushController || { - fcmToken: '', - }, + state: initialState.NotificationServicesPushController, env: { apiKey: process.env.FIREBASE_API_KEY ?? '', authDomain: process.env.FIREBASE_AUTH_DOMAIN ?? '', @@ -1120,13 +1091,97 @@ export class Engine { vapidKey: process.env.VAPID_KEY ?? '', }, config: { - isPushEnabled: true, + isPushFeatureEnabled: true, platform: 'mobile', - // TODO: Implement optionability for push notification handlers (depending of the platform) on the NotificationServicesPushController. - onPushNotificationReceived: () => Promise.resolve(undefined), - onPushNotificationClicked: () => Promise.resolve(undefined), + pushService: { + createRegToken, + deleteRegToken, + subscribeToPushNotifications: createSubscribeToPushNotifications(), + }, }, }); + + notificationServicesPushControllerMessenger.subscribe( + 'NotificationServicesPushController:onNewNotifications', + (notification) => { + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PUSH_NOTIFICATIONS_RECEIVED, + ) + .addProperties({ + notification_id: notification.id, + notification_type: notification.type, + chain_id: + 'chain_id' in notification ? notification.chain_id : undefined, + }) + .build(), + ); + }, + ); + notificationServicesPushControllerMessenger.subscribe( + 'NotificationServicesPushController:pushNotificationClicked', + (notification) => { + MetaMetrics.getInstance().trackEvent( + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PUSH_NOTIFICATIONS_CLICKED, + ) + .addProperties({ + notification_id: notification.id, + notification_type: notification.type, + chain_id: + 'chain_id' in notification ? notification.chain_id : undefined, + }) + .build(), + ); + }, + ); + + // Push Notification Side Effect - ensure permissions have been set + // We only need to switch push notifications off if it is enabled, but the system/device has it off + if (notificationServicesPushController.state.isPushEnabled) { + isPushNotificationsEnabled().then((isEnabled) => { + if (isEnabled === false) + notificationServicesPushController.setIsPushNotificationsEnabled( + false, + ); + }); + } + + const notificationServicesController = new NotificationServicesController({ + messenger: this.controllerMessenger.getRestricted({ + name: 'NotificationServicesController', + allowedActions: [ + 'KeyringController:getState', + 'KeyringController:getAccounts', + 'AuthenticationController:getBearerToken', + 'AuthenticationController:isSignedIn', + 'UserStorageController:enableProfileSyncing', + 'UserStorageController:getStorageKey', + 'UserStorageController:performGetStorage', + 'UserStorageController:performSetStorage', + 'NotificationServicesPushController:enablePushNotifications', + 'NotificationServicesPushController:disablePushNotifications', + 'NotificationServicesPushController:updateTriggerPushNotifications', + 'NotificationServicesPushController:getState', + ], + allowedEvents: [ + 'KeyringController:unlock', + 'KeyringController:lock', + 'KeyringController:stateChange', + 'NotificationServicesPushController:stateChange', + 'NotificationServicesPushController:onNewNotifications', + ], + }) as unknown as NotificationServicesControllerMessenger, + state: initialState.NotificationServicesController, + env: { + featureAnnouncements: { + platform: 'mobile', + accessToken: process.env + .FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN as string, + spaceId: process.env.FEATURES_ANNOUNCEMENTS_SPACE_ID as string, + }, + }, + }); ///: END:ONLY_INCLUDE_IF this.transactionController = new TransactionController({ @@ -2170,7 +2225,8 @@ export default { keyringState: KeyringControllerState | null = null, metaMetricsId?: string, ) { - instance = Engine.instance || new Engine(state, keyringState, metaMetricsId); + instance = + Engine.instance || new Engine(state, keyringState, metaMetricsId); Object.freeze(instance); return instance; }, diff --git a/app/core/Engine/controllers/PushNotificationController/get-notification-message.ts b/app/core/Engine/controllers/PushNotificationController/get-notification-message.ts new file mode 100644 index 00000000000..d157692699d --- /dev/null +++ b/app/core/Engine/controllers/PushNotificationController/get-notification-message.ts @@ -0,0 +1,97 @@ +import { + type TranslationKeys, + createOnChainPushNotificationMessage, +} from '@metamask/notification-services-controller/push-services'; +import type { INotification } from '@metamask/notification-services-controller/notification-services'; +import { strings } from '../../../../../locales/i18n'; +import type Translations from '../../../../../locales/languages/en.json'; + +type NotificationTranslations = (typeof Translations)['notifications']; +type FlattenToString = { + [K in keyof TObj]: TObj[K] extends string + ? `${K & string}` + : `${K & string}.${FlattenToString}`; +}[keyof TObj]; + +type NotificationStrings = + `notifications.${FlattenToString}`; + +const t = (name: NotificationStrings, params?: object) => + strings(name, params) ?? ''; + +const translations: TranslationKeys = { + pushPlatformNotificationsFundsSentTitle: () => + t('notifications.push_notification_content.funds_sent_title'), + pushPlatformNotificationsFundsSentDescriptionDefault: () => + t('notifications.push_notification_content.funds_sent_default_description'), + pushPlatformNotificationsFundsSentDescription: (amount, symbol) => + t('notifications.push_notification_content.funds_sent_description', { + amount, + symbol, + }), + pushPlatformNotificationsFundsReceivedTitle: () => + t('notifications.push_notification_content.funds_received_title'), + pushPlatformNotificationsFundsReceivedDescriptionDefault: () => + t( + 'notifications.push_notification_content.funds_received_default_description', + ), + pushPlatformNotificationsFundsReceivedDescription: (amount, symbol) => + t('notifications.push_notification_content.funds_received_description', { + amount, + symbol, + }), + pushPlatformNotificationsSwapCompletedTitle: () => + t('notifications.metamask_swap_completed_title'), + pushPlatformNotificationsSwapCompletedDescription: () => + t( + 'notifications.push_notification_content.metamask_swap_completed_description', + ), + pushPlatformNotificationsNftSentTitle: () => + t('notifications.push_notification_content.nft_sent_title'), + pushPlatformNotificationsNftSentDescription: () => + t('notifications.push_notification_content.nft_sent_description'), + pushPlatformNotificationsNftReceivedTitle: () => + t('notifications.push_notification_content.nft_received_title'), + pushPlatformNotificationsNftReceivedDescription: () => + t('notifications.push_notification_content.nft_received_description'), + pushPlatformNotificationsStakingRocketpoolStakeCompletedTitle: () => + t('notifications.rocketpool_stake_completed_title'), + pushPlatformNotificationsStakingRocketpoolStakeCompletedDescription: () => + t( + 'notifications.push_notification_content.rocketpool_stake_completed_description', + ), + pushPlatformNotificationsStakingRocketpoolUnstakeCompletedTitle: () => + t('notifications.rocketpool_unstake_completed_title'), + pushPlatformNotificationsStakingRocketpoolUnstakeCompletedDescription: () => + t( + 'notifications.push_notification_content.rocketpool_unstake_completed_description', + ), + pushPlatformNotificationsStakingLidoStakeCompletedTitle: () => + t('notifications.lido_stake_completed_title'), + pushPlatformNotificationsStakingLidoStakeCompletedDescription: () => + t( + 'notifications.push_notification_content.lido_stake_completed_description', + ), + pushPlatformNotificationsStakingLidoStakeReadyToBeWithdrawnTitle: () => + t('notifications.lido_stake_ready_to_be_withdrawn_title'), + pushPlatformNotificationsStakingLidoStakeReadyToBeWithdrawnDescription: () => + t( + 'notifications.push_notification_content.lido_stake_ready_to_be_withdrawn_description', + ), + pushPlatformNotificationsStakingLidoWithdrawalRequestedTitle: () => + t('notifications.lido_withdrawal_requested_title'), + pushPlatformNotificationsStakingLidoWithdrawalRequestedDescription: () => + t( + 'notifications.push_notification_content.lido_withdrawal_requested_description', + ), + pushPlatformNotificationsStakingLidoWithdrawalCompletedTitle: () => + t('notifications.lido_withdrawal_completed_title'), + pushPlatformNotificationsStakingLidoWithdrawalCompletedDescription: () => + t( + 'notifications.push_notification_content.lido_withdrawal_completed_description', + ), +}; + +export function createNotificationMessage(notification: INotification) { + return createOnChainPushNotificationMessage(notification, translations); +} diff --git a/app/core/Engine/controllers/PushNotificationController/utils.ts b/app/core/Engine/controllers/PushNotificationController/utils.ts new file mode 100644 index 00000000000..2aea19e7285 --- /dev/null +++ b/app/core/Engine/controllers/PushNotificationController/utils.ts @@ -0,0 +1,27 @@ +import type { Types } from '@metamask/notification-services-controller/push-services'; +import FCMService from '../../../../util/notifications/services/FCMService'; +import NotificationsService from '../../../../util/notifications/services/NotificationService'; +import { PressActionId } from '../../../../util/notifications'; +import { createNotificationMessage } from './get-notification-message'; + +export const createRegToken: Types.CreateRegToken = FCMService.createRegToken; +export const deleteRegToken: Types.DeleteRegToken = FCMService.deleteRegToken; + +export const createSubscribeToPushNotifications = + (): Types.SubscribeToPushNotifications => async () => + FCMService.listenToPushNotificationsReceived(async (notification) => { + const notificationMessage = createNotificationMessage(notification); + if (!notificationMessage) { + return; + } + + await NotificationsService.displayNotification({ + id: notification.id, + pressActionId: PressActionId.OPEN_NOTIFICATIONS_VIEW, + title: notificationMessage.title, + body: notificationMessage.description, + data: notification, + }); + }); + +export const isPushNotificationsEnabled = FCMService.isPushNotificationsEnabled; diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 7153b4a4ff0..817c63c3d37 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -149,9 +149,17 @@ import { UserStorageController, } from '@metamask/profile-sync-controller'; import { - NotificationServicesPushController, - NotificationServicesController, -} from '@metamask/notification-services-controller'; + type Actions as NotificationServicesControllerActions, + type Events as NotificationServicesControllerEvents, + type Controller as NotificationServicesController, + type NotificationServicesControllerState, +} from '@metamask/notification-services-controller/notification-services'; +import { + type Actions as NotificationServicesPushControllerActions, + type Events as NotificationServicesPushControllerEvents, + type Controller as NotificationServicesPushController, + type NotificationServicesPushControllerState, +} from '@metamask/notification-services-controller/push-services'; ///: END:ONLY_INCLUDE_IF import { AccountsController, @@ -225,8 +233,8 @@ type GlobalActions = | SnapsGlobalActions | AuthenticationController.Actions | UserStorageController.Actions - | NotificationServicesController.Actions - | NotificationServicesPushController.Actions + | NotificationServicesControllerActions + | NotificationServicesPushControllerActions ///: END:ONLY_INCLUDE_IF | AccountsControllerActions | PreferencesControllerActions @@ -257,8 +265,8 @@ type GlobalEvents = | SnapsGlobalEvents | AuthenticationController.Events | UserStorageController.Events - | NotificationServicesController.Events - | NotificationServicesPushController.Events + | NotificationServicesControllerEvents + | NotificationServicesPushControllerEvents ///: END:ONLY_INCLUDE_IF | SignatureControllerEvents | LoggingControllerEvents @@ -333,8 +341,8 @@ export type Controllers = { SubjectMetadataController: SubjectMetadataController; AuthenticationController: AuthenticationController.Controller; UserStorageController: UserStorageController.Controller; - NotificationServicesController: NotificationServicesController.Controller; - NotificationServicesPushController: NotificationServicesPushController.Controller; + NotificationServicesController: NotificationServicesController; + NotificationServicesPushController: NotificationServicesPushController; ///: END:ONLY_INCLUDE_IF SwapsController: SwapsController; }; @@ -374,8 +382,8 @@ export type EngineState = { SubjectMetadataController: SubjectMetadataControllerState; AuthenticationController: AuthenticationController.AuthenticationControllerState; UserStorageController: UserStorageController.UserStorageControllerState; - NotificationServicesController: NotificationServicesController.NotificationServicesControllerState; - NotificationServicesPushController: NotificationServicesPushController.NotificationServicesPushControllerState; + NotificationServicesController: NotificationServicesControllerState; + NotificationServicesPushController: NotificationServicesPushControllerState; ///: END:ONLY_INCLUDE_IF PermissionController: PermissionControllerState; ApprovalController: ApprovalControllerState; diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js index 9588923a4d0..d097c5c4cb3 100644 --- a/app/core/NotificationManager.js +++ b/app/core/NotificationManager.js @@ -7,7 +7,7 @@ import { strings } from '../../locales/i18n'; import { AppState } from 'react-native'; import NotificationsService from '../util/notifications/services/NotificationService'; import { NotificationTransactionTypes, ChannelId } from '../util/notifications'; -import { safeToChecksumAddress, formatAddress } from '../util/address'; +import { safeToChecksumAddress } from '../util/address'; import ReviewManager from './ReviewManager'; import { selectTicker } from '../selectors/networkController'; import { store } from '../store'; @@ -73,14 +73,6 @@ export const constructTitleAndMessage = (notification) => { amount: notification.transaction.amount, }); break; - case NotificationTransactionTypes.eth_received: - title = strings('notifications.default_message_title'); - message = strings('notifications.eth_received_message', { - amount: notification.transaction.amount.usd, - ticker: 'USD', - address: formatAddress(notification.transaction.from, 'short'), - }); - break; default: title = notification?.data?.title || diff --git a/app/selectors/notifications/index.tsx b/app/selectors/notifications/index.tsx index d5a3313376f..224d23663ff 100644 --- a/app/selectors/notifications/index.tsx +++ b/app/selectors/notifications/index.tsx @@ -4,20 +4,35 @@ import { TRIGGER_TYPES, Notification } from '../../util/notifications'; import { createDeepEqualSelector } from '../util'; import { RootState } from '../../reducers'; -import { NotificationServicesController } from '@metamask/notification-services-controller'; +import { + NotificationServicesController, + NotificationServicesPushController, +} from '@metamask/notification-services-controller'; type NotificationServicesState = NotificationServicesController.NotificationServicesControllerState; +type NotifiationServicesPushState = + NotificationServicesPushController.NotificationServicesPushControllerState; + const selectNotificationServicesControllerState = (state: RootState) => state?.engine?.backgroundState?.NotificationServicesController ?? NotificationServicesController.defaultState; +const selectNotificationServicesPushControllerState = (state: RootState) => + state?.engine?.backgroundState?.NotificationServicesPushController ?? + NotificationServicesPushController.defaultState; + export const selectIsMetamaskNotificationsEnabled = createSelector( selectNotificationServicesControllerState, (notificationServicesControllerState: NotificationServicesState) => notificationServicesControllerState.isNotificationServicesEnabled, ); +export const selectIsMetaMaskPushNotificationsEnabled = createSelector( + selectNotificationServicesPushControllerState, + (state: NotifiationServicesPushState) => state.isPushEnabled, +); + export const selectIsMetamaskNotificationsFeatureSeen = createSelector( selectNotificationServicesControllerState, (notificationServicesControllerState: NotificationServicesState) => diff --git a/app/util/notifications/constants/triggers.ts b/app/util/notifications/constants/triggers.ts index 7480fa0bbda..782055cb2e0 100644 --- a/app/util/notifications/constants/triggers.ts +++ b/app/util/notifications/constants/triggers.ts @@ -42,6 +42,7 @@ export enum ChainId { SCROLL = 534352, } +// TODO - remove this as it is available in CORE export enum TRIGGER_TYPES { FEATURES_ANNOUNCEMENT = 'features_announcement', METAMASK_SWAP_COMPLETED = 'metamask_swap_completed', @@ -65,7 +66,7 @@ export enum TRIGGER_TYPES { ROCKETPOOL_STAKING_REWARDS = 'rocketpool_staking_rewards', NOTIONAL_LOAN_EXPIRATION = 'notional_loan_expiration', SPARK_FI_HEALTH_FACTOR = 'spark_fi_health_factor', - SNAP = 'snap' + SNAP = 'snap', } export const chains = { diff --git a/app/util/notifications/hooks/index.test.tsx b/app/util/notifications/hooks/index.test.tsx deleted file mode 100644 index 59535338cad..00000000000 --- a/app/util/notifications/hooks/index.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* eslint-disable import/no-namespace */ - -import { renderHook } from '@testing-library/react-hooks'; -import { Provider } from 'react-redux'; -import createMockStore from 'redux-mock-store'; -import React from 'react'; -import * as NotificationUtils from '../../../util/notifications'; -import FCMService from '../services/FCMService'; -import useNotificationHandler from './index'; -import initialRootState from '../../../util/test/initial-root-state'; -import * as Selectors from '../../../selectors/notifications'; -import { NavigationContainerRef } from '@react-navigation/native'; - -jest.mock('../../../util/notifications', () => ({ - isNotificationsFeatureEnabled: jest.fn(), -})); - -jest.mock('../services/FCMService', () => ({ - registerAppWithFCM: jest.fn(), - saveFCMToken: jest.fn(), - registerTokenRefreshListener: jest.fn(), - listenForMessagesForeground: jest.fn(), -})); - -function arrangeMocks(isFeatureEnabled: boolean, isMetaMaskEnabled: boolean) { - jest.spyOn(NotificationUtils, 'isNotificationsFeatureEnabled') - .mockReturnValue(isFeatureEnabled); - - jest.spyOn(Selectors, 'selectIsMetamaskNotificationsEnabled') - .mockReturnValue(isMetaMaskEnabled); -} - -function arrangeStore() { - const store = createMockStore()(initialRootState); - - store.dispatch = jest.fn().mockImplementation((action) => { - if (typeof action === 'function') { - return action(store.dispatch, store.getState); - } - return Promise.resolve(); - }); - - return store; -} - -const mockNavigate = jest.fn(); -const mockNavigation = { - navigate: mockNavigate, -} as unknown as NavigationContainerRef; - -function arrangeHook() { - const store = arrangeStore(); - const hook = renderHook(() => useNotificationHandler(mockNavigation), { - wrapper: ({ children }) => {children}, - }); - - return hook; -} - -describe('useNotificationHandler', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('does not register FCM when notifications are disabled', () => { - arrangeMocks(false, false); - - arrangeHook(); - - expect(FCMService.registerAppWithFCM).not.toHaveBeenCalled(); - expect(FCMService.saveFCMToken).not.toHaveBeenCalled(); - expect(FCMService.listenForMessagesForeground).not.toHaveBeenCalled(); - }); - - it('registers FCM when notifications feature is enabled', () => { - arrangeMocks(true, true); - - arrangeHook(); - - expect(FCMService.registerAppWithFCM).toHaveBeenCalledTimes(1); - expect(FCMService.saveFCMToken).toHaveBeenCalledTimes(1); - }); - - it('registers FCM when MetaMask notifications are enabled', () => { - arrangeMocks(true, true); - - arrangeHook(); - - expect(FCMService.registerAppWithFCM).toHaveBeenCalledTimes(1); - expect(FCMService.saveFCMToken).toHaveBeenCalledTimes(1); - }); - - it('handleNotificationCallback does nothing when notification is undefined', () => { - arrangeMocks(true, true); - - arrangeHook(); - - expect(mockNavigate).not.toHaveBeenCalled(); - }); -}); diff --git a/app/util/notifications/hooks/index.ts b/app/util/notifications/hooks/index.ts deleted file mode 100644 index 4b00e90492f..00000000000 --- a/app/util/notifications/hooks/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { NotificationServicesController } from '@metamask/notification-services-controller'; - -import { useSelector } from 'react-redux'; -import { - isNotificationsFeatureEnabled, - Notification, -} from '../../../util/notifications'; - -import FCMService from '../services/FCMService'; -import NotificationsService from '../services/NotificationService'; -import { selectIsMetamaskNotificationsEnabled } from '../../../selectors/notifications'; -import { Linking } from 'react-native'; -import { NavigationContainerRef } from '@react-navigation/native'; -import Routes from '../../../constants/navigation/Routes'; - -const { TRIGGER_TYPES } = NotificationServicesController.Constants; - -const useNotificationHandler = (navigation: NavigationContainerRef) => { - /** - * Handles the action based on the type of notification (sent from the backend & following Notification types) that is opened - * @param notification - The notification that is opened - */ - - const isNotificationEnabled = useSelector( - selectIsMetamaskNotificationsEnabled, - ); - - const handleNotificationCallback = useCallback( - async (notification: Notification) => { - if (!notification) { - return; - } - if ( - notification.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT && - notification.data.externalLink - ) { - Linking.openURL(notification.data.externalLink.externalLinkUrl); - } else { - navigation.navigate(Routes.NOTIFICATIONS.VIEW); - } - }, - [navigation], - ); - - const notificationEnabled = isNotificationsFeatureEnabled() && isNotificationEnabled; - - useEffect(() => { - if (!notificationEnabled) return; - - // Firebase Cloud Messaging - FCMService.registerAppWithFCM(); - FCMService.saveFCMToken(); - FCMService.getFCMToken(); - FCMService.listenForMessagesBackground(); - - // Notifee - NotificationsService.onBackgroundEvent( - async ({ type, detail }) => - await NotificationsService.handleNotificationEvent({ - type, - detail, - callback: handleNotificationCallback, - }), - ); - - const unsubscribeForegroundEvent = FCMService.listenForMessagesForeground(); - - return () => { - unsubscribeForegroundEvent(); - }; - }, [handleNotificationCallback, notificationEnabled]); -}; - -export default useNotificationHandler; diff --git a/app/util/notifications/hooks/usePushNotifications.ts b/app/util/notifications/hooks/usePushNotifications.ts index b7e2e697046..f69082162de 100644 --- a/app/util/notifications/hooks/usePushNotifications.ts +++ b/app/util/notifications/hooks/usePushNotifications.ts @@ -1,58 +1,14 @@ -import { useState, useCallback } from 'react'; -import { getErrorMessage } from '../../errorHandling'; -import { - disablePushNotifications, - enablePushNotifications, -} from '../../../actions/notification/helpers'; -import { mmStorage } from '../settings'; -import { UserStorage } from '@metamask/notification-services-controller/notification-services'; -import { isNotificationsFeatureEnabled } from '../constants'; +import { useCallback } from 'react'; +import Engine from '../../../core/Engine'; export function usePushNotifications() { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const resetStates = useCallback(() => { - setLoading(false); - setError(null); + const switchPushNotifications = useCallback((state: boolean) => { + Engine?.context?.NotificationServicesPushController?.setIsPushNotificationsEnabled( + state, + ); }, []); - const switchPushNotifications = useCallback( - async (state: boolean) => { - if (!isNotificationsFeatureEnabled()) { - return; - } - - resetStates(); - setLoading(true); - let errorMessage: string | undefined; - - try { - const userStorage: UserStorage = mmStorage.getLocal('pnUserStorage'); - if (state) { - const fcmToken = mmStorage.getLocal('metaMaskFcmToken'); - errorMessage = await enablePushNotifications( - userStorage, - fcmToken?.data, - ); - } else { - errorMessage = await disablePushNotifications(userStorage); - } - if (errorMessage) { - setError(getErrorMessage(errorMessage)); - } - } catch (e) { - errorMessage = getErrorMessage(e); - setError(errorMessage); - } finally { - setLoading(false); - } - }, - [resetStates], - ); - return { switchPushNotifications, - loading, - error, }; } diff --git a/app/util/notifications/hooks/useRegisterPushNotificationsEffect.ts b/app/util/notifications/hooks/useRegisterPushNotificationsEffect.ts new file mode 100644 index 00000000000..81d0354a530 --- /dev/null +++ b/app/util/notifications/hooks/useRegisterPushNotificationsEffect.ts @@ -0,0 +1,156 @@ +import { useNavigation, type NavigationProp } from '@react-navigation/native'; +import { + INotification, + TRIGGER_TYPES, +} from '@metamask/notification-services-controller/notification-services'; +import Engine from '../../../core/Engine'; +import Routes from '../../../constants/navigation/Routes'; +import NotificationsService from '../services/NotificationService'; +import { PressActionId } from '../types'; +import { useEffect } from 'react'; +import { isNotificationsFeatureEnabled } from '../constants'; +import { useSelector } from 'react-redux'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../selectors/notifications'; + +// TODO - improve navigation types, so we have Type-Safety for navigation props +type NavigationParams = Record; + +// TODO - improve type inference for notifications we support +function isINotification(n: unknown): n is INotification { + const assumedShape = n as INotification; + return Boolean(assumedShape?.type) && Boolean(assumedShape?.data); +} + +/** + * Logic for handling a push notification click. + * It will publish an event, and attempt navigation to notifications view + * @param notification - notification click received from App Start or Background + * @param navigation - navigation prop for page navigations + * @returns - void + */ +function clickPushNotification( + notification: INotification, + navigation: NavigationProp, +) { + // Publish Click Event + Engine.controllerMessenger.publish( + 'NotificationServicesPushController:pushNotificationClicked', + notification, + ); + + // TODO, find a nicer way of abstracting the notifications we do support push notifications for + if (notification.type === TRIGGER_TYPES.SNAP) { + return; + } + + // Navigate + navigation.navigate(Routes.NOTIFICATIONS.DETAILS, { + notification, + }); +} + +/** + * Android Devices use a `getInitialNotifications` if a push notification cold-starts the application. + * @param navigation - navigation prop for page navigations + * @returns - void + */ +async function onAppOpenNotification( + navigation: NavigationProp, +) { + const initialNotification = + await NotificationsService.getInitialNotification(); + if (!initialNotification) { + return; + } + + const { notification, pressAction } = initialNotification; + const notificationDataStr = notification?.data?.dataStr; + + if (!notificationDataStr) { + return; + } + + try { + // Notify can only store strings + const notificationData = JSON.parse(notificationDataStr as string); + if ( + pressAction?.id === PressActionId.OPEN_NOTIFICATIONS_VIEW && + isINotification(notificationData) + ) { + clickPushNotification(notificationData, navigation); + } + } catch { + // Do Nothing + } +} + +/** + * IOS/Anroid devices will use a notifee `backgroundEvent` if a push notification is delivered and clicked on a minimised app. + * (IOS also uses this for cold-starts). + * @param navigation - navigation prop used for page navigations + */ +async function onBackgroundEvent(navigation: NavigationProp) { + NotificationsService.onBackgroundEvent((event) => + NotificationsService.handleNotificationEvent({ + ...event, + callback: (notification) => { + const pressAction = event?.detail?.pressAction; + const notificationDataStr = notification?.data?.dataStr; + + if (!notificationDataStr) { + return; + } + + try { + // Notify can only store strings + const notificationData = JSON.parse(notificationDataStr as string); + if ( + pressAction?.id === PressActionId.OPEN_NOTIFICATIONS_VIEW && + isINotification(notificationData) + ) { + clickPushNotification(notificationData, navigation); + } + } catch { + // Do Nothing + } + }, + }), + ); +} + +/** + * Effect that registers Notifee Push listeners + * - When push notifications are recieved + * - When push notifications are clicked + */ +export function useRegisterPushNotificationsEffect() { + const navigation: NavigationProp = useNavigation(); + const notificationsFlagEnabled = isNotificationsFeatureEnabled(); + const notificationsControllerEnabled = useSelector( + selectIsMetamaskNotificationsEnabled, + ); + const notificationsPushControllerEnabled = useSelector( + selectIsMetaMaskPushNotificationsEnabled, + ); + const notificationsEnabled = + notificationsFlagEnabled && + notificationsControllerEnabled && + notificationsPushControllerEnabled; + + // App Open Effect + useEffect(() => { + if (notificationsEnabled) { + onAppOpenNotification(navigation); + } + }, [navigation, notificationsEnabled]); + + // On Background and Foreground Events + useEffect(() => { + if (notificationsEnabled) { + onBackgroundEvent(navigation); + } + }, [navigation, notificationsEnabled]); +} diff --git a/app/util/notifications/hooks/withIsHeadless.tsx b/app/util/notifications/hooks/withIsHeadless.tsx new file mode 100644 index 00000000000..c8449fcbd28 --- /dev/null +++ b/app/util/notifications/hooks/withIsHeadless.tsx @@ -0,0 +1,46 @@ +import React, { useState, useEffect } from 'react'; +import messaging from '@react-native-firebase/messaging'; +import { isNotificationsFeatureEnabled } from '../constants'; + +/** + * For IOS Background Messaging, when in headless mode (silently opening the app), we want to not render the whole app. + * https://rnfirebase.io/messaging/usage#background-application-state + * @returns boolean is the application is in headless mode or not + */ +export function useIsHeadless() { + const [isHeadless, setIsHeadless] = useState(false); + + useEffect(() => { + if (isNotificationsFeatureEnabled()) { + const checkHeadless = async () => { + const headless = await messaging().getIsHeadless(); + setIsHeadless(headless === true); + }; + + checkHeadless(); + } else { + setIsHeadless(false); + } + }, []); + + return isHeadless; +} + +/** + * IOS/Android Headless Mode for background push notifications. + * When headless, we do not want to render application + * https://rnfirebase.io/messaging/usage#background-application-state + */ +export const withIsHeadless = + (WrappedComponent: React.ComponentType) => + (props: Props) => { + const isHeadless = useIsHeadless(); + + if (isHeadless && isNotificationsFeatureEnabled()) { + // Do not render the UI if the app is in headless mode + return null; + } + + // eslint-disable-next-line react/react-in-jsx-scope + return ; + }; diff --git a/app/util/notifications/index.ts b/app/util/notifications/index.ts index d1e6201e1e9..c53837dd4df 100644 --- a/app/util/notifications/index.ts +++ b/app/util/notifications/index.ts @@ -2,6 +2,5 @@ export * from './types'; export * from './constants'; export * from './androidChannels'; export * from './settings'; -export * from './hooks'; export * from './methods'; export * from './services'; diff --git a/app/util/notifications/methods/common.test.ts b/app/util/notifications/methods/common.test.ts index d5ea5ddc285..aca87d2ec10 100644 --- a/app/util/notifications/methods/common.test.ts +++ b/app/util/notifications/methods/common.test.ts @@ -1,13 +1,11 @@ import { formatMenuItemDate, - parseNotification, shortenString, getLeadingZeroCount, formatAmount, getUsdAmount, } from './common'; import { strings } from '../../../../locales/i18n'; -import { FirebaseMessagingTypes } from '@react-native-firebase/messaging'; describe('formatMenuItemDate', () => { beforeAll(() => { @@ -201,25 +199,6 @@ describe('getUsdAmount', () => { }); }); -describe('parseNotification', () => { - it('parses notification', () => { - const notification = { - data: { - data: { - type: 'eth_received', - data: { kind: 'eth_received' }, - }, - }, - } as unknown as FirebaseMessagingTypes.RemoteMessage; - - expect(parseNotification(notification)).toEqual({ - type: 'eth_received', - transaction: { kind: 'eth_received' }, - duration: 5000, - }); - }); -}); - describe('shortenString', () => { it('should return the same string if it is shorter than TRUNCATED_NAME_CHAR_LIMIT', () => { expect(shortenString('string')).toStrictEqual('string'); diff --git a/app/util/notifications/methods/common.ts b/app/util/notifications/methods/common.ts index 2ee0ce6e18d..cc6d83f563f 100644 --- a/app/util/notifications/methods/common.ts +++ b/app/util/notifications/methods/common.ts @@ -1,14 +1,11 @@ import dayjs, { Dayjs } from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; -import notifee from '@notifee/react-native'; import localeData from 'dayjs/plugin/localeData'; import { Web3Provider } from '@ethersproject/providers'; import { toHex } from '@metamask/controller-utils'; import BigNumber from 'bignumber.js'; import { - UserStorage, - USER_STORAGE_VERSION_KEY, OnChainRawNotification, OnChainRawNotificationsWithNetworkFields, } from '@metamask/notification-services-controller/notification-services'; @@ -18,7 +15,6 @@ import { NOTIFICATION_NETWORK_CURRENCY_SYMBOL, SUPPORTED_NOTIFICATION_BLOCK_EXPLORERS, } from '@metamask/notification-services-controller/notification-services/ui'; -import { FirebaseMessagingTypes } from '@react-native-firebase/messaging'; import Engine from '../../../core/Engine'; import { IconName } from '../../../component-library/components/Icons/Icon'; import { hexWEIToDecETH, hexWEIToDecGWEI } from '../../conversions'; @@ -477,89 +473,9 @@ export const getUsdAmount = (amount: string, decimals: string, usd: string) => { return formatAmount(numericAmount); }; -export const hasInitialNotification = async () => - Boolean(await notifee.getInitialNotification()); - export function withTimeout(promise: Promise, ms: number): Promise { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(strings('notifications.timeout'))), ms), ); return Promise.race([promise, timeout]); } - -export interface NotificationTrigger { - id: string; - chainId: string; - kind: string; - address: string; -} - -type MapTriggerFn = (trigger: NotificationTrigger) => Result; - -interface TraverseTriggerOpts { - address?: string; - mapTrigger?: MapTriggerFn; -} - -const triggerToId = (trigger: NotificationTrigger) => trigger.id; -const triggerIdentity = (trigger: NotificationTrigger) => trigger; - -function traverseUserStorageTriggers( - userStorage: UserStorage, - options?: TraverseTriggerOpts, -) { - const triggers: ResultTriggers[] = []; - const mapTrigger = - options?.mapTrigger ?? (triggerIdentity as MapTriggerFn); - - for (const address in userStorage) { - if (address === (USER_STORAGE_VERSION_KEY as unknown as string)) continue; - if (options?.address && address !== options.address) continue; - for (const chain_id in userStorage[address]) { - for (const uuid in userStorage[address]?.[chain_id]) { - if (uuid) { - triggers.push( - mapTrigger({ - id: uuid, - kind: userStorage[address]?.[chain_id]?.[uuid]?.k, - chainId: chain_id, - address, - }), - ); - } - } - } - } - - return triggers; -} - -export function getUUIDs(userStorage: UserStorage, address: string): string[] { - return traverseUserStorageTriggers(userStorage, { - address, - mapTrigger: triggerToId, - }); -} - -export function getAllUUIDs(userStorage: UserStorage): string[] { - const uuids = traverseUserStorageTriggers(userStorage, { - mapTrigger: triggerToId, - }); - return uuids; -} - -export function parseNotification( - remoteMessage: FirebaseMessagingTypes.RemoteMessage, -) { - const notification = remoteMessage.data?.data; - const parsedNotification = - typeof notification === 'string' ? JSON.parse(notification) : notification; - - const notificationData = { - type: parsedNotification?.type || parsedNotification?.data?.kind, - transaction: parsedNotification?.data, - duration: 5000, - }; - - return notificationData; -} diff --git a/app/util/notifications/services/FCMService.test.ts b/app/util/notifications/services/FCMService.test.ts index cc29e672e57..501d6bd556a 100644 --- a/app/util/notifications/services/FCMService.test.ts +++ b/app/util/notifications/services/FCMService.test.ts @@ -1,135 +1,316 @@ -import { cleanup } from '@testing-library/react-native'; -import { MMKV } from 'react-native-mmkv'; -import messaging from '@react-native-firebase/messaging'; +import messaging, { + type FirebaseMessagingTypes, +} from '@react-native-firebase/messaging'; +// eslint-disable-next-line import/no-namespace +import { + toRawOnChainNotification, + processNotification, +} from '@metamask/notification-services-controller/notification-services'; + import FCMService from './FCMService'; -import { mmStorage, notificationStorage } from '../settings'; -import Logger from '../../../util/Logger'; - -jest.mock('../../../core/NotificationManager'); -jest.mock('../../../util/Logger'); -jest.mock('./NotificationService'); -jest.mock('../../../../locales/i18n', () => ({ - strings: jest.fn().mockReturnValue('Mocked string'), -})); - -jest.mock('../../../store', () => ({ - store: { - dispatch: jest.fn(), - }, -})); - -jest.mock('../methods', () => ({ - parseNotification: jest.fn(), -})); - -const mockedOnTokenRefresh = jest.fn((callback) => callback('fcmToken')); - -jest.mock('@react-native-firebase/messaging', () => ({ - __esModule: true, - default: jest.fn(() => ({ - onTokenRefresh: mockedOnTokenRefresh, - getToken: jest.fn(() => Promise.resolve('fcmToken')), - deleteToken: jest.fn(() => Promise.resolve()), - subscribeToTopic: jest.fn(), - unsubscribeFromTopic: jest.fn(), - hasPermission: jest.fn(() => Promise.resolve(1)), - requestPermission: jest.fn(() => Promise.resolve(1)), - setBackgroundMessageHandler: jest.fn(() => Promise.resolve()), - isDeviceRegisteredForRemoteMessages: jest.fn(() => Promise.resolve(false)), - registerDeviceForRemoteMessages: jest.fn(() => - Promise.resolve('registered'), - ), - unregisterDeviceForRemoteMessages: jest.fn(() => - Promise.resolve('unregistered'), - ), - onMessage: jest.fn(() => jest.fn()) - })) -})); - -jest.mock('../settings', () => ({ - mmStorage: { - saveLocal: jest.fn(), - getLocal: jest.fn().mockReturnValue({ data: 'fcmToken' }), - }, - notificationStorage: { - set: jest.fn(), - getString: jest.fn(), - }, -})); - -jest.mock('../../../core/NotificationManager', () => ({ - onMessageReceived: jest.fn(), -})); - -describe('FCMService', () => { - let storage: MMKV; - - afterEach(cleanup); - beforeAll(() => { - storage = notificationStorage; + +// Firebase Mock +jest.mock('@react-native-firebase/messaging', () => { + const originalModule = jest.requireActual('@react-native-firebase/messaging'); + + const hasPermission = jest.fn(); + const registerDeviceForRemoteMessages = jest.fn(); + const getToken = jest.fn(); + const deleteToken = jest.fn(); + const setBackgroundMessageHandler = jest.fn(); + const onMessage = jest.fn(); + + // Messaging() function mock + const mockMessaging = jest.fn(() => ({ + isDeviceRegisteredForRemoteMessages: false, + hasPermission, + registerDeviceForRemoteMessages, + getToken, + deleteToken, + setBackgroundMessageHandler, + onMessage, + })); + + // Retain the messaging properties + Object.assign(mockMessaging, originalModule.default); + + return { + __esModule: true, + ...originalModule, + default: mockMessaging, + }; +}); + +// Notification Services Mock +jest.mock('@metamask/notification-services-controller/notification-services'); + +const arrangeFirebaseMocks = () => { + const mockHasPermission = jest.mocked(messaging().hasPermission); + mockHasPermission.mockResolvedValue(messaging.AuthorizationStatus.AUTHORIZED); + + const mockRegisterDeviceForRemoteMessages = jest.mocked( + messaging().registerDeviceForRemoteMessages, + ); + + const mockGetToken = jest + .mocked(messaging().getToken) + .mockResolvedValue('MOCK_FCM_TOKEN'); + + const mockDeleteToken = jest.mocked(messaging().deleteToken); + + const mockSetBackgroundMessageHandler = jest.mocked( + messaging().setBackgroundMessageHandler, + ); + + const mockOnMessage = jest.mocked(messaging().onMessage); + + return { + mockHasPermission, + mockRegisterDeviceForRemoteMessages, + mockGetToken, + mockDeleteToken, + mockSetBackgroundMessageHandler, + mockOnMessage, + }; +}; + +describe('FCMService - createRegToken()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('creates registration token', async () => { + const firebaseMocks = arrangeFirebaseMocks(); + + const result = await FCMService.createRegToken(); + expect(result).toBeDefined(); + + expect(firebaseMocks.mockHasPermission).toHaveBeenCalled(); + expect( + firebaseMocks.mockRegisterDeviceForRemoteMessages, + ).toHaveBeenCalled(); + expect(firebaseMocks.mockGetToken).toHaveBeenCalled(); + }); + + it('returns null if push notifications are not enabled', async () => { + const firebaseMocks = arrangeFirebaseMocks(); + firebaseMocks.mockHasPermission.mockResolvedValue( + messaging.AuthorizationStatus.DENIED, + ); + + const result = await FCMService.createRegToken(); + expect(result).toBe(null); + expect(firebaseMocks.mockGetToken).not.toHaveBeenCalled(); + }); + + it('returns null if fails to get FCM token', async () => { + const firebaseMocks = arrangeFirebaseMocks(); + firebaseMocks.mockGetToken.mockRejectedValueOnce(new Error('TEST ERROR')); + + const result = await FCMService.createRegToken(); + expect(result).toBe(null); + expect(firebaseMocks.mockGetToken).toHaveBeenCalled(); + }); +}); + +describe('FCMService - deleteRegToken()', () => { + afterEach(() => { jest.clearAllMocks(); }); - it('gets local storage token correctly', () => { - const mockKey = 'metaMaskFcmToken'; - const mockValue = { data: 'fcmToken' }; + it('successfully deletes an FCM token', async () => { + const firebaseMocks = arrangeFirebaseMocks(); + + const result = await FCMService.deleteRegToken(); + expect(result).toBe(true); - storage.set(mockKey, JSON.stringify(mockValue)); - storage.getString(mockKey); + expect(firebaseMocks.mockHasPermission).toHaveBeenCalled(); + expect(firebaseMocks.mockDeleteToken).toHaveBeenCalled(); + }); - const result = mmStorage.getLocal(mockKey); + it('returns true (silently passes) if push notifications are not enabled', async () => { + const firebaseMocks = arrangeFirebaseMocks(); + firebaseMocks.mockHasPermission.mockResolvedValue( + messaging.AuthorizationStatus.DENIED, + ); - expect(result).toEqual(mockValue); + const result = await FCMService.deleteRegToken(); + expect(result).toBe(true); + expect(firebaseMocks.mockDeleteToken).not.toHaveBeenCalled(); }); - it('gets FCM token', async () => { - const mockToken = 'fcmToken'; + it('returns fails to delete FCM token', async () => { + const firebaseMocks = arrangeFirebaseMocks(); + firebaseMocks.mockDeleteToken.mockRejectedValueOnce( + new Error('TEST ERROR'), + ); - const token = await FCMService.getFCMToken(); - expect(token).toBe(mockToken); + const result = await FCMService.deleteRegToken(); + expect(result).toBe(false); + expect(firebaseMocks.mockDeleteToken).toHaveBeenCalled(); }); +}); - it('logs if FCM token is not found', async () => { - const mockKey = 'metaMaskFcmToken'; - const mockValue = { data: undefined }; +describe('FCMService - isPushNotificationsEnabled()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); - const getLocalMock = jest.spyOn(mmStorage, 'getLocal').mockReturnValue(undefined); + it.each([ + { status: 'AUTHORIZED', code: messaging.AuthorizationStatus.AUTHORIZED }, + { status: 'PROVISIONAL', code: messaging.AuthorizationStatus.PROVISIONAL }, + ])('returns true if push notifications are $status', async ({ code }) => { + const firebaseMocks = arrangeFirebaseMocks(); + firebaseMocks.mockHasPermission.mockResolvedValue(code); - storage.set(mockKey, JSON.stringify(mockValue)); - storage.getString(mockKey); + const result = await FCMService.isPushNotificationsEnabled(); + expect(result).toBe(true); + expect(firebaseMocks.mockHasPermission).toHaveBeenCalled(); + }); - await FCMService.getFCMToken(); - expect(Logger.log).toHaveBeenCalledWith('getFCMToken: No FCM token found'); + it('returns false if push notifications are denied', async () => { + const firebaseMocks = arrangeFirebaseMocks(); + firebaseMocks.mockHasPermission.mockResolvedValue( + messaging.AuthorizationStatus.DENIED, + ); - getLocalMock.mockRestore(); + const result = await FCMService.isPushNotificationsEnabled(); + expect(result).toBe(false); + expect(firebaseMocks.mockHasPermission).toHaveBeenCalled(); }); - it('saves FCM token', async () => { - const mockKey = 'metaMaskFcmToken'; - const mockValue = { data: 'fcmToken' }; + it('returns false if an error occurs while checking permission', async () => { + const firebaseMocks = arrangeFirebaseMocks(); + firebaseMocks.mockHasPermission.mockRejectedValueOnce( + new Error('TEST ERROR'), + ); - storage.set(mockKey, JSON.stringify(mockValue)); - storage.getString(mockKey); + const result = await FCMService.isPushNotificationsEnabled(); + expect(result).toBe(false); + expect(firebaseMocks.mockHasPermission).toHaveBeenCalled(); + }); +}); - await FCMService.saveFCMToken(); - expect(mmStorage.saveLocal).toHaveBeenCalled(); +describe('FCMService - listenToPushNotificationsReceived()', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - it('saves FCM token if permissionStatus === messaging.AuthorizationStatus.AUTHORIZED', async () => { - const mockToken = 'fcmToken'; - (messaging().requestPermission as jest.Mock).mockResolvedValue(1); - (messaging().getToken as jest.Mock).mockResolvedValue(mockToken); + const arrangeNotificationServicesMocks = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const identity = (x: any) => x; + const mockToRawOnChainNotification = jest + .mocked(toRawOnChainNotification) + .mockImplementation(identity); + const mockProcessNotification = jest + .mocked(processNotification) + .mockImplementation(identity); + + return { + mockToRawOnChainNotification, + mockProcessNotification, + }; + }; + + const arrangeMocks = () => { + const firebaseMocks = arrangeFirebaseMocks(); + const mockHandler = jest.fn(); + const mockOnMessageUnsubscribe = jest.fn(); + firebaseMocks.mockOnMessage.mockReturnValue(mockOnMessageUnsubscribe); + + return { firebaseMocks, mockOnMessageUnsubscribe, mockHandler }; + }; + + it('sets up listeners for push notifications and returns an unsubscribe handler', async () => { + const { mockHandler, firebaseMocks } = arrangeMocks(); + + const result = await FCMService.listenToPushNotificationsReceived( + mockHandler, + ); + expect(result).toBeDefined(); - await FCMService.saveFCMToken(); - expect(mmStorage.saveLocal).toHaveBeenCalledWith('metaMaskFcmToken', { data: mockToken }); + expect(firebaseMocks.mockSetBackgroundMessageHandler).toHaveBeenCalled(); + expect(firebaseMocks.mockOnMessage).toHaveBeenCalled(); }); - it('saves FCM token if permissionStatus === messaging.AuthorizationStatus.PROVISIONAL', async () => { - const mockToken = 'fcmToken'; - (messaging().requestPermission as jest.Mock).mockResolvedValue(2); - (messaging().getToken as jest.Mock).mockResolvedValue(mockToken); + it('returns null if an error occurs while setting up listeners', async () => { + const { mockHandler, firebaseMocks } = arrangeMocks(); + firebaseMocks.mockSetBackgroundMessageHandler.mockImplementationOnce(() => { + throw new Error('TEST ERROR'); + }); - await FCMService.saveFCMToken(); - expect(mmStorage.saveLocal).toHaveBeenCalledWith('metaMaskFcmToken', { data: mockToken }); + const result = await FCMService.listenToPushNotificationsReceived( + mockHandler, + ); + expect(result).toBe(null); }); + + const testMatrix = [ + { + messageType: 'background', + handlerMethod: 'mockSetBackgroundMessageHandler', + } as const, + { messageType: 'foreground', handlerMethod: 'mockOnMessage' } as const, + ]; + + describe.each(testMatrix)( + 'FCMService - $messageType messages', + ({ handlerMethod }) => { + const act = async ( + mocks: ReturnType, + overridePayload = {}, + ) => { + const defaultPayload = { + data: { data: JSON.stringify({ key: 'value' }) }, + } as unknown as FirebaseMessagingTypes.RemoteMessage; + + const mockPayload = { ...defaultPayload, ...overridePayload }; + + await FCMService.listenToPushNotificationsReceived(mocks.mockHandler); + const messageHandler = + mocks.firebaseMocks[handlerMethod].mock.lastCall?.[0]; + await messageHandler?.(mockPayload); + }; + + it('invokes notificationHandler & handler params', async () => { + const mocks = arrangeMocks(); + const notificationMocks = arrangeNotificationServicesMocks(); + + await act(mocks); + + expect(mocks.mockHandler).toHaveBeenCalled(); + expect( + notificationMocks.mockToRawOnChainNotification, + ).toHaveBeenCalled(); + expect(notificationMocks.mockProcessNotification).toHaveBeenCalled(); + }); + + it('handles errors in notification services', async () => { + const mocks = arrangeMocks(); + const notificationMocks = arrangeNotificationServicesMocks(); + notificationMocks.mockToRawOnChainNotification.mockImplementationOnce( + () => { + throw new Error('TEST ERROR'); + }, + ); + + await act(mocks); + + expect(mocks.mockHandler).not.toHaveBeenCalled(); + }); + + it('handles invalid or non-parseable payload', async () => { + const mocks = arrangeMocks(); + arrangeNotificationServicesMocks(); + + const invalidPayload = { + data: { data: 'invalid json' }, + } as unknown as FirebaseMessagingTypes.RemoteMessage; + + await act(mocks, invalidPayload); + + expect(mocks.mockHandler).not.toHaveBeenCalled(); + }); + }, + ); }); diff --git a/app/util/notifications/services/FCMService.ts b/app/util/notifications/services/FCMService.ts index 0c31f09ae92..0869d03384e 100644 --- a/app/util/notifications/services/FCMService.ts +++ b/app/util/notifications/services/FCMService.ts @@ -1,65 +1,174 @@ -import messaging, { FirebaseMessagingTypes } from '@react-native-firebase/messaging'; +import messaging, { + type FirebaseMessagingTypes, +} from '@react-native-firebase/messaging'; +import { + type INotification, + type UnprocessedOnChainRawNotification, + toRawOnChainNotification, + processNotification, +} from '@metamask/notification-services-controller/notification-services'; import Logger from '../../../util/Logger'; -import { mmStorage } from '../settings'; -import NotificationManager from '../../../core/NotificationManager'; -import { parseNotification } from '../methods'; -type UnsubscribeFunc = () => void +type Unsubscribe = () => void; +/** + * Utility to check if devices have enabled push notifications + * @returns boolean + */ +async function isPushNotificationsEnabled() { + try { + const permissionStatus = await messaging().hasPermission(); + return ( + permissionStatus === messaging.AuthorizationStatus.AUTHORIZED || + permissionStatus === messaging.AuthorizationStatus.PROVISIONAL + ); + } catch { + return false; + } +} + +/** + * IOS requires device registration for remote messages through APNs. + * To be invoked when creating or registering FCM tokens. + */ +async function registerForRemoteMessages() { + try { + const isRegistered = messaging().isDeviceRegisteredForRemoteMessages; + if (!isRegistered) { + await messaging().registerDeviceForRemoteMessages(); + } + } catch (error) { + // Do Nothing - silently fail + } +} + +async function notificationHandler( + payload: FirebaseMessagingTypes.RemoteMessage, + handler: (notification: INotification) => void | Promise, +) { + try { + const payloadData = payload?.data?.data + ? String(payload?.data?.data) + : undefined; + const data: UnprocessedOnChainRawNotification | undefined = payloadData + ? JSON.parse(payloadData) + : undefined; + + if (!data) { + return; + } + + // If we are able to handle push notification + // Then we do not want to render the original server notification but custom content + // Prevents duplicate notifications + delete payload.notification; + + const notificationData = toRawOnChainNotification(data); + const notification = processNotification(notificationData); + await handler(notification); + } catch (error) { + // Do Nothing, cannot parse a bad notification + Logger.log('Unable to send push notification:', { + notification: payload?.data?.data, + error, + }); + } +} + +/** + * Service that provides an interface used for `NotificationServicesPushController` + */ class FCMService { + /** + * Creates a registration token for Firebase Cloud Messaging + * + * @returns A promise that resolves with the registration token, or null if an error occurs + */ + createRegToken = async (): Promise => { + if (!(await isPushNotificationsEnabled())) { + return null; + } - getFCMToken = async (): Promise => { - const fcmTokenLocal = await mmStorage.getLocal('metaMaskFcmToken'); - const token = fcmTokenLocal?.data || undefined; - if (!token) { - Logger.log('getFCMToken: No FCM token found'); + try { + await registerForRemoteMessages(); + const fcmToken = await messaging().getToken(); + return fcmToken; + } catch { + return null; } - return token; }; - saveFCMToken = async () => { + /** + * Deletes the Firebase Cloud Messaging registration token. + * + * @returns A promise that resolves with true if the token was successfully deleted, false otherwise. + */ + deleteRegToken = async (): Promise => { + if (!(await isPushNotificationsEnabled())) { + return true; + } + try { - const permissionStatus = await messaging().hasPermission(); - if ( - permissionStatus === 1 || permissionStatus === 2 - ) { - const fcmToken = await messaging().getToken(); - if (fcmToken) { - mmStorage.saveLocal('metaMaskFcmToken', { data: fcmToken }); - } - } - } catch (error) { - Logger.log(error as Error, 'FCMService:: error saving'); + await messaging().deleteToken(); + return true; + } catch { + return false; } }; - listenForMessagesForeground = (): UnsubscribeFunc => messaging().onMessage(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { - const notificationData = parseNotification(remoteMessage); - NotificationManager.onMessageReceived(notificationData); - }); + /** + * Ensures we only register for background notifications once + */ + #hasRegisteredBackground = false; - listenForMessagesBackground = (): void => { - messaging().setBackgroundMessageHandler(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { - const notificationData = parseNotification(remoteMessage); - NotificationManager.onMessageReceived(notificationData); - }); + /** + * Ensures we only register for foreground notifications once + */ + #hasRegisteredForeground: Unsubscribe | null = null; + + /** + * Listener for when push notifications are received. + * Subscribed to both foreground and background messages + + * @param handler - handler used for displaying push notifications. Must be provided. + * @returns unsubscribe handler + */ + listenToPushNotificationsReceived = async ( + handler: (notification: INotification) => void | Promise, + ): Promise => { + try { + // We only subscribe to foreground messages, as subscribing to background messages that contain `notification` + `data` payloads have issues + // IOS - requires payload editing (https://notifee.app/react-native/docs/ios/remote-notification-support) + // IOS - requires isHeadless injection and app modification to ship a minimal app when headless (https://rnfirebase.io/messaging/usage#background-application-state). + // Android - will cause double notifications if a remote message contains both `notification` + `data` payloads + // Firebase will still send push notifications in background + app kill as there is a `notification` payload in the remote message + await this.registerForegroundMessages(handler); + return this.#hasRegisteredForeground; + } catch { + return null; + } }; - registerAppWithFCM = async () => { - Logger.log( - 'registerAppWithFCM status', - messaging().isDeviceRegisteredForRemoteMessages, - ); - if (!messaging().isDeviceRegisteredForRemoteMessages) { - await messaging() - .registerDeviceForRemoteMessages() - .then((status: unknown) => { - Logger.log('registerDeviceForRemoteMessages status', status); - }) - .catch((error: Error) => { - Logger.error(error); - }); + registerForegroundMessages = async ( + handler: (notification: INotification) => void | Promise, + ) => { + if (!(await isPushNotificationsEnabled())) { + return null; + } + + if (this.#hasRegisteredForeground) { + return; + } + + try { + this.#hasRegisteredForeground = messaging().onMessage(async (payload) => { + notificationHandler(payload, handler); + }); + } catch { + // Do nothing } }; + + isPushNotificationsEnabled = () => isPushNotificationsEnabled(); } export default new FCMService(); diff --git a/app/util/notifications/services/NotificationService.ts b/app/util/notifications/services/NotificationService.ts index 1cf9c9f4d8f..1b544199b1b 100644 --- a/app/util/notifications/services/NotificationService.ts +++ b/app/util/notifications/services/NotificationService.ts @@ -4,9 +4,11 @@ import notifee, { EventType, EventDetail, AndroidChannel, + Notification, + InitialNotification, } from '@notifee/react-native'; -import { HandleNotificationCallback, LAUNCH_ACTIVITY, Notification, PressActionId } from '../types'; +import { LAUNCH_ACTIVITY, PressActionId } from '../types'; import { Linking, Platform, Alert as NativeAlert } from 'react-native'; import { @@ -184,15 +186,15 @@ class NotificationsService { callback, }: { detail: EventDetail; - callback?: (notification: Notification) => void; + callback?: (notification: Notification | undefined) => void; }) => { this.decrementBadgeCount(1); if (detail?.notification?.id) { await this.cancelTriggerNotification(detail.notification.id); } - if (detail?.notification?.data) { - callback?.(detail.notification as Notification); + if (detail?.notification) { + callback?.(detail.notification); } }; @@ -201,9 +203,9 @@ class NotificationsService { detail, callback, }: NotifeeEvent & { - callback?: (notification: Notification) => void; + callback?: (notification: Notification | undefined) => void; }) => { - switch (type as unknown as EventType) { + switch (type) { case EventType.DELIVERED: this.incrementBadgeCount(1); break; @@ -221,14 +223,8 @@ class NotificationsService { await notifee.cancelTriggerNotification(id); }; - getInitialNotification = async ( - callback: HandleNotificationCallback - ): Promise => { - const event = await notifee.getInitialNotification() - if (event) { - callback(event.notification.data as Notification['data']) - } - }; + getInitialNotification = async (): Promise => + await notifee.getInitialNotification(); cancelAllNotifications = async () => { await notifee.cancelAllNotifications(); @@ -238,28 +234,35 @@ class NotificationsService { notifee.createChannel(channel); displayNotification = async ({ - channelId, + channelId = ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + pressActionId = PressActionId.OPEN_HOME, title, body, - data + data, + id, }: { - channelId: ChannelId - title: string - body?: string - data?: Notification['data'] + channelId?: ChannelId; + pressActionId?: PressActionId; + title: string; + body?: string; + data?: unknown; + id?: string; }): Promise => { await notifee.displayNotification({ + id, title, body, - data: data as unknown as Notification['data'], + // Notifee can only store and handle data strings + data: { dataStr: JSON.stringify(data) }, android: { smallIcon: 'ic_notification_small', largeIcon: 'ic_notification', channelId: channelId ?? ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, pressAction: { - id: PressActionId.OPEN_NOTIFICATIONS_VIEW, + id: pressActionId, launchActivity: LAUNCH_ACTIVITY, - } + }, + tag: id, }, ios: { launchImageName: 'Default', diff --git a/app/util/notifications/settings/storage/constants.ts b/app/util/notifications/settings/storage/constants.ts index 390f9922957..606f4ea04ef 100644 --- a/app/util/notifications/settings/storage/constants.ts +++ b/app/util/notifications/settings/storage/constants.ts @@ -1,7 +1,6 @@ export const STORAGE_IDS = { NOTIFICATIONS: 'notifications', GLOBAL_PUSH_NOTIFICATION_SETTINGS: 'globalNotificationSettings', - MM_FCM_TOKEN: 'metaMaskFcmToken', PUSH_NOTIFICATIONS_PROMPT_COUNT: 'pushNotificationsPromptCount', PUSH_NOTIFICATIONS_PROMPT_TIME: 'pushNotificationsPromptTime', DEVICE_ID_STORAGE_KEY: 'pns:deviceId', @@ -27,7 +26,6 @@ export const mapStorageTypeToIds = (id: string) => { switch (id) { case STORAGE_IDS.NOTIFICATIONS: case STORAGE_IDS.GLOBAL_PUSH_NOTIFICATION_SETTINGS: - case STORAGE_IDS.MM_FCM_TOKEN: case STORAGE_IDS.NOTIFICATIONS_SETTINGS: case STORAGE_IDS.PN_USER_STORAGE: return STORAGE_TYPES.OBJECT; diff --git a/app/util/notifications/settings/storage/contants.test.ts b/app/util/notifications/settings/storage/contants.test.ts index acfc00f0258..d1cd2d6edbd 100644 --- a/app/util/notifications/settings/storage/contants.test.ts +++ b/app/util/notifications/settings/storage/contants.test.ts @@ -5,7 +5,6 @@ describe('constants', () => { expect(STORAGE_IDS).toEqual({ NOTIFICATIONS: 'notifications', GLOBAL_PUSH_NOTIFICATION_SETTINGS: 'globalNotificationSettings', - MM_FCM_TOKEN: 'metaMaskFcmToken', PUSH_NOTIFICATIONS_PROMPT_COUNT: 'pushNotificationsPromptCount', PUSH_NOTIFICATIONS_PROMPT_TIME: 'pushNotificationsPromptTime', DEVICE_ID_STORAGE_KEY: 'pns:deviceId', @@ -37,9 +36,6 @@ describe('constants', () => { expect( mapStorageTypeToIds(STORAGE_IDS.GLOBAL_PUSH_NOTIFICATION_SETTINGS), ).toEqual(STORAGE_TYPES.OBJECT); - expect(mapStorageTypeToIds(STORAGE_IDS.MM_FCM_TOKEN)).toEqual( - STORAGE_TYPES.OBJECT, - ); expect( mapStorageTypeToIds(STORAGE_IDS.PUSH_NOTIFICATIONS_PROMPT_COUNT), ).toEqual(STORAGE_TYPES.NUMBER); diff --git a/app/util/notifications/types/notification/index.ts b/app/util/notifications/types/notification/index.ts index 8c8488dc230..8c973fa6797 100644 --- a/app/util/notifications/types/notification/index.ts +++ b/app/util/notifications/types/notification/index.ts @@ -10,13 +10,9 @@ import { TRIGGER_TYPES } from '../../constants'; */ export type Notification = NotificationServicesController.Types.INotification; -export type HandleNotificationCallback = ( - data: Notification['data'] | undefined -) => void - export enum PressActionId { + OPEN_HOME = 'open-home-press-action-id', OPEN_NOTIFICATIONS_VIEW = 'open-notifications-view-press-action-id', - OPEN_TRANSACTIONS_VIEW = 'open-transactions-view-press-action-id' } export const LAUNCH_ACTIVITY = 'com.metamask.ui.MainActivity'; @@ -95,22 +91,6 @@ export const NotificationTransactionTypes = { cancelled: 'cancelled', received: 'received', received_payment: 'received_payment', - eth_received: 'eth_received', - features_announcement: 'features_announcement', - metamask_swap_completed: 'metamask_swap_completed', - erc20_sent: 'erc20_sent', - erc20_received: 'erc20_received', - eth_sent: 'eth_sent', - rocketpool_stake_completed: 'rocketpool_stake_completed', - rocketpool_unstake_completed: 'rocketpool_unstake_completed', - lido_stake_completed: 'lido_stake_completed', - lido_withdrawal_requested: 'lido_withdrawal_requested', - lido_withdrawal_completed: 'lido_withdrawal_completed', - lido_stake_ready_to_be_withdrawn: 'lido_stake_ready_to_be_withdrawn', - erc721_sent: 'erc721_sent', - erc721_received: 'erc721_received', - erc1155_sent: 'erc1155_sent', - erc1155_received: 'erc1155_received', } as const; export type NotificationTransactionTypesType = @@ -123,10 +103,10 @@ export interface MarketingNotificationData { } export const STAKING_PROVIDER_MAP: Record< -NotificationServicesController.Constants.TRIGGER_TYPES.LIDO_STAKE_COMPLETED -| NotificationServicesController.Constants.TRIGGER_TYPES.ROCKETPOOL_STAKE_COMPLETED -| NotificationServicesController.Constants.TRIGGER_TYPES.ROCKETPOOL_UNSTAKE_COMPLETED -| NotificationServicesController.Constants.TRIGGER_TYPES.LIDO_WITHDRAWAL_COMPLETED, + | NotificationServicesController.Constants.TRIGGER_TYPES.LIDO_STAKE_COMPLETED + | NotificationServicesController.Constants.TRIGGER_TYPES.ROCKETPOOL_STAKE_COMPLETED + | NotificationServicesController.Constants.TRIGGER_TYPES.ROCKETPOOL_UNSTAKE_COMPLETED + | NotificationServicesController.Constants.TRIGGER_TYPES.LIDO_WITHDRAWAL_COMPLETED, string > = { [TRIGGER_TYPES.LIDO_STAKE_COMPLETED]: 'Lido-staked ETH', diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index aee4e8e9a84..e7b0dade554 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -360,41 +360,3 @@ global.crypto = { return arr; }, }; - -jest.mock('@react-native-firebase/messaging', () => { - const module = () => { - return { - getToken: jest.fn(() => Promise.resolve('fcmToken')), - deleteToken: jest.fn(() => Promise.resolve()), - subscribeToTopic: jest.fn(), - unsubscribeFromTopic: jest.fn(), - hasPermission: jest.fn(() => - Promise.resolve(module.AuthorizationStatus.AUTHORIZED), - ), - requestPermission: jest.fn(() => - Promise.resolve(module.AuthorizationStatus.AUTHORIZED), - ), - setBackgroundMessageHandler: jest.fn(() => Promise.resolve()), - isDeviceRegisteredForRemoteMessages: jest.fn(() => - Promise.resolve(false), - ), - registerDeviceForRemoteMessages: jest.fn(() => - Promise.resolve('registered'), - ), - unregisterDeviceForRemoteMessages: jest.fn(() => - Promise.resolve('unregistered'), - ), - onMessage: jest.fn(), - onTokenRefresh: jest.fn(), - }; - }; - - module.AuthorizationStatus = { - NOT_DETERMINED: -1, - DENIED: 0, - AUTHORIZED: 1, - PROVISIONAL: 2, - }; - - return module; -}); diff --git a/index.js b/index.js index f837c7df6bd..f31e50456db 100644 --- a/index.js +++ b/index.js @@ -20,7 +20,12 @@ import { name } from './app.config.js'; import { isE2E } from './app/util/test/utils.js'; import { Performance } from './app/core/Performance'; -import { handleCustomError, setReactNativeDefaultHandler } from './app/core/ErrorHandler'; +import { + handleCustomError, + setReactNativeDefaultHandler, +} from './app/core/ErrorHandler'; +import { withIsHeadless } from './app/util/notifications/hooks/withIsHeadless'; + Performance.setupPerformanceObservers(); LogBox.ignoreAllLogs(); @@ -90,7 +95,7 @@ if (IGNORE_BOXLOGS_DEVELOPMENT === 'true') { */ AppRegistry.registerComponent(name, () => // Disable Sentry for E2E tests - isE2E ? Root : Sentry.wrap(Root), + isE2E ? withIsHeadless(Root) : withIsHeadless(Sentry.wrap(Root)), ); function setupGlobalErrorHandler() { @@ -102,4 +107,3 @@ function setupGlobalErrorHandler() { } setupGlobalErrorHandler(); - diff --git a/locales/languages/en.json b/locales/languages/en.json index c53d9c643fe..1698e902049 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2285,36 +2285,30 @@ "lido_withdrawal_completed_message": "Unstaking complete", "lido_stake_ready_to_be_withdrawn_message": "Withdrawal requested", "push_notification_content": { + "funds_sent_title": "Funds sent", + "funds_sent_description": "You successfully sent {{amount}} {{symbol}}", + "funds_sent_default_description": "You successfully sent some tokens", + "funds_received_title": "Funds received", + "funds_received_description": "You received {{amount}} {{symbol}}", + "funds_received_default_description": "You received some tokens", "metamask_swap_completed_title": "Swap completed", "metamask_swap_completed_description": "Your MetaMask Swap was successful", - "erc20_sent_title": "Funds sent", - "erc20_sent_description": "You successfully sent {{amount}} {{token}}", - "erc20_received_title": "Funds received", - "erc20_received_description": "You received {{amount}} {{token}}", - "eth_sent_title": "Funds sent", - "eth_sent_description": "You successfully sent {{amount}} ETH", - "eth_received_title": "Funds received", - "eth_received_description": "You received {{amount}} {{token}}", + "nft_sent_title": "NFT sent", + "nft_sent_description": "You have successfully sent an NFT", + "nft_received_title": "NFT received", + "nft_received_description": "You received new NFTs", "rocketpool_stake_completed_title": "Stake complete", "rocketpool_stake_completed_description": "Your RocketPool stake was successful", "rocketpool_unstake_completed_title": "Unstake complete", "rocketpool_unstake_completed_description": "Your RocketPool unstake was successful", "lido_stake_completed_title": "Stake complete", "lido_stake_completed_description": "Your Lido stake was successful", + "lido_stake_ready_to_be_withdrawn_title": "Stake ready for withdrawal ", + "lido_stake_ready_to_be_withdrawn_description": "Your Lido stake is now ready to be withdrawn", "lido_withdrawal_requested_title": "Withdrawal requested", "lido_withdrawal_requested_description": "Your Lido withdrawal request was submitted", "lido_withdrawal_completed_title": "Withdrawal completed", - "lido_withdrawal_completed_description": "Your Lido withdrawal was successful", - "lido_stake_ready_to_be_withdrawn_title": "Stake ready for withdrawal ", - "lido_stake_ready_to_be_withdrawn_description": "Your Lido stake is now ready to be withdrawn", - "erc721_sent_title": "NFT sent", - "erc721_sent_description": "You've successfully sent an NFT", - "erc721_received_title": "NFT received", - "erc721_received_description": "You received a new NFT", - "erc1155_sent_title": "NFT sent", - "erc1155_sent_description": "You've successfully sent an NFT", - "erc1155_received_title": "NFT received", - "erc1155_received_description": "You received a new NFT" + "lido_withdrawal_completed_description": "Your Lido withdrawal was successful" }, "prompt_title": "Receive Push Notifications", "notifications_enabled_error_title": "Something went wrong", diff --git a/package.json b/package.json index d48df88919c..5722388543d 100644 --- a/package.json +++ b/package.json @@ -181,7 +181,7 @@ "@metamask/logging-controller": "^6.0.1", "@metamask/message-signing-snap": "^0.3.3", "@metamask/network-controller": "^22.1.0", - "@metamask/notification-services-controller": "^0.15.0", + "@metamask/notification-services-controller": "npm:@metamask-previews/notification-services-controller@0.16.0-preview-5f45f70f", "@metamask/permission-controller": "^11.0.0", "@metamask/phishing-controller": "^12.0.3", "@metamask/post-message-stream": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index 651c62ce858..efb35ae7f12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5108,15 +5108,15 @@ "@ethersproject/providers" "^5.7.2" async-mutex "^0.3.1" -"@metamask/notification-services-controller@^0.15.0": - version "0.15.0" - resolved "https://registry.yarnpkg.com/@metamask/notification-services-controller/-/notification-services-controller-0.15.0.tgz#d846fa49df62838a8ae48e80a8fee098730f06b0" - integrity sha512-RJtCI0GkVLStmhNoq9QNqSQNag6gD37iWU/qU19ds5PujSrtmfS5t2Sk6YRNV3SkRrfiIFrhGDToUDBDBu13OA== +"@metamask/notification-services-controller@npm:@metamask-previews/notification-services-controller@0.16.0-preview-5f45f70f": + version "0.16.0-preview-5f45f70f" + resolved "https://registry.yarnpkg.com/@metamask-previews/notification-services-controller/-/notification-services-controller-0.16.0-preview-5f45f70f.tgz#415498c6dddbc45a501a944adc30e53f5ac46002" + integrity sha512-AchWWmkZrydgwiGbfxayofc2PX+rnkyuDbAcCfuWHeKM2YEoolWkRMMXeS2S4Az2wffLzqN9o4t5yABsPgBdcQ== dependencies: "@contentful/rich-text-html-renderer" "^16.5.2" - "@metamask/base-controller" "^7.0.2" - "@metamask/controller-utils" "^11.4.4" - "@metamask/utils" "^10.0.0" + "@metamask/base-controller" "^7.1.1" + "@metamask/controller-utils" "^11.4.5" + "@metamask/utils" "^11.0.1" bignumber.js "^9.1.2" firebase "^10.11.0" loglevel "^1.8.1"