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"