diff --git a/app/selectors/notifications/index.tsx b/app/selectors/notifications/index.tsx index dc77ebe44e0..e1c8221119e 100644 --- a/app/selectors/notifications/index.tsx +++ b/app/selectors/notifications/index.tsx @@ -1,6 +1,6 @@ import { createSelector } from 'reselect'; -import { TRIGGER_TYPES } from '../../util/notifications'; +import { TRIGGER_TYPES, Notification } from '../../util/notifications'; import { createDeepEqualSelector } from '../util'; import { RootState } from '../../reducers'; @@ -99,14 +99,14 @@ export const getMetamaskNotificationsUnreadCount = createSelector( (notificationServicesControllerState: NotificationServicesState) => ( notificationServicesControllerState.metamaskNotificationsList ?? [] - ).filter((notification) => !notification.isRead).length, + ).filter((notification: Notification) => !notification.isRead).length, ); export const getMetamaskNotificationsReadCount = createSelector( selectNotificationServicesControllerState, (notificationServicesControllerState: NotificationServicesState) => ( notificationServicesControllerState.metamaskNotificationsList ?? [] - ).filter((notification) => notification.isRead).length, + ).filter((notification: Notification) => notification.isRead).length, ); export const getOnChainMetamaskNotificationsUnreadCount = createSelector( selectNotificationServicesControllerState, @@ -114,7 +114,7 @@ export const getOnChainMetamaskNotificationsUnreadCount = createSelector( ( notificationServicesControllerState.metamaskNotificationsList ?? [] ).filter( - (notification) => + (notification: Notification) => !notification.isRead && notification.type !== TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, ).length, diff --git a/app/util/notifications/androidChannels.test.ts b/app/util/notifications/androidChannels.test.ts index debc8448ab3..24f3c9ba227 100644 --- a/app/util/notifications/androidChannels.test.ts +++ b/app/util/notifications/androidChannels.test.ts @@ -15,9 +15,9 @@ describe('notificationChannels', () => { expect(firstChannel).toEqual({ id: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, name: 'Transaction Complete', - lights: false, - vibration: false, - importance: AndroidImportance.DEFAULT, + lights: true, + vibration: true, + importance: AndroidImportance.HIGH, title: 'Transaction', subtitle: 'Transaction Complete', }); @@ -28,9 +28,9 @@ describe('notificationChannels', () => { expect(secondChannel).toEqual({ id: ChannelId.ANNOUNCEMENT_NOTIFICATION_CHANNEL_ID, name: 'MetaMask Announcement', - lights: false, - vibration: false, - importance: AndroidImportance.DEFAULT, + lights: true, + vibration: true, + importance: AndroidImportance.HIGH, title: 'Announcement', subtitle: 'MetaMask Announcement', }); diff --git a/app/util/notifications/androidChannels.ts b/app/util/notifications/androidChannels.ts index 4841cb7e1a5..47311200977 100644 --- a/app/util/notifications/androidChannels.ts +++ b/app/util/notifications/androidChannels.ts @@ -15,18 +15,18 @@ export const notificationChannels = [ { id: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, name: 'Transaction Complete', - lights: false, - vibration: false, - importance: AndroidImportance.DEFAULT, + lights: true, + vibration: true, + importance: AndroidImportance.HIGH, title: 'Transaction', subtitle: 'Transaction Complete', } as MetaMaskAndroidChannel, { id: ChannelId.ANNOUNCEMENT_NOTIFICATION_CHANNEL_ID, name: 'MetaMask Announcement', - lights: false, - vibration: false, - importance: AndroidImportance.DEFAULT, + lights: true, + vibration: true, + importance: AndroidImportance.HIGH, title: 'Announcement', subtitle: 'MetaMask Announcement', } as MetaMaskAndroidChannel, diff --git a/app/util/notifications/methods/common.test.ts b/app/util/notifications/methods/common.test.ts index 0aa9a9cb40b..68c05880131 100644 --- a/app/util/notifications/methods/common.test.ts +++ b/app/util/notifications/methods/common.test.ts @@ -35,3 +35,5 @@ describe('formatMenuItemDate', () => { }); }); }); + + diff --git a/app/util/notifications/methods/fcmHelper.test.ts b/app/util/notifications/methods/fcmHelper.test.ts deleted file mode 100644 index 1dc2eca655b..00000000000 --- a/app/util/notifications/methods/fcmHelper.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - checkPlayServices, - registerAppWithFCM, - unRegisterAppWithFCM, - checkApplicationNotificationPermission, - getFcmToken, -} from './fcmHelper'; - -jest.mock('@react-native-firebase/app', () => ({ - utils: () => ({ - playServicesAvailability: { - status: 1, - isAvailable: false, - hasResolution: true, - isUserResolvableError: true, - }, - makePlayServicesAvailable: jest.fn(() => Promise.resolve()), - resolutionForPlayServices: jest.fn(() => Promise.resolve()), - promptForPlayServices: jest.fn(() => Promise.resolve()), - }), -})); - -jest.mock('@react-native-firebase/messaging', () => ({ - __esModule: true, - default: () => ({ - hasPermission: jest.fn(() => Promise.resolve(true)), - subscribeToTopic: jest.fn(), - unsubscribeFromTopic: jest.fn(), - isDeviceRegisteredForRemoteMessages: false, - registerDeviceForRemoteMessages: jest.fn(() => - Promise.resolve('registered'), - ), - unregisterDeviceForRemoteMessages: jest.fn(() => - Promise.resolve('unregistered'), - ), - deleteToken: jest.fn(() => Promise.resolve()), - requestPermission: jest.fn(() => Promise.resolve(1)), - getToken: jest.fn(() => Promise.resolve('fcm-token')), - }), - FirebaseMessagingTypes: { - AuthorizationStatus: { - AUTHORIZED: 1, - PROVISIONAL: 2, - }, - }, -})); - -jest.mock('react-native-permissions', () => ({ - PERMISSIONS: { - ANDROID: { - POST_NOTIFICATIONS: 'android.permission.POST_NOTIFICATIONS', - }, - }, - request: jest.fn(() => Promise.resolve('granted')), -})); - -describe('Firebase and Permission Functions', () => { - it('should check checkPlayServices function call for coverage', async () => { - await checkPlayServices(); - const token = await getFcmToken(); - - expect(token).toBe('fcm-token'); - }); - it('should check registerAppWithFCM function call for coverage', async () => { - await registerAppWithFCM(); - - const token = await getFcmToken(); - - expect(token).toBe('fcm-token'); - }); - it('should check unRegisterAppWithFCM function call for coverage', async () => { - await unRegisterAppWithFCM(); - const token = await getFcmToken(); - - expect(token).toBe('fcm-token'); - }); - it('should check checkApplicationNotificationPermission function call for coverage', async () => { - await checkApplicationNotificationPermission(); - const token = await getFcmToken(); - - expect(token).toBe('fcm-token'); - }); -}); diff --git a/app/util/notifications/methods/fcmHelper.ts b/app/util/notifications/methods/fcmHelper.ts deleted file mode 100644 index 8b811605fc3..00000000000 --- a/app/util/notifications/methods/fcmHelper.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { utils } from '@react-native-firebase/app'; -import messaging, { - FirebaseMessagingTypes, -} from '@react-native-firebase/messaging'; -import Logger from '../../../util/Logger'; -import { PERMISSIONS, request } from 'react-native-permissions'; - -export async function checkPlayServices() { - const { status, isAvailable, hasResolution, isUserResolvableError } = - utils().playServicesAvailability; - if (isAvailable) return Promise.resolve(); - - if (isUserResolvableError || hasResolution) { - switch (status) { - case 1: - return utils().makePlayServicesAvailable(); - case 2: - return utils().resolutionForPlayServices(); - default: - if (isUserResolvableError) return utils().promptForPlayServices(); - if (hasResolution) return utils().resolutionForPlayServices(); - } - } - return Promise.reject( - new Error('Unable to find a valid play services version.'), - ); -} - -export async function registerAppWithFCM() { - 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); - }); - } -} - -export async function unRegisterAppWithFCM() { - Logger.log( - 'unRegisterAppWithFCM status', - messaging().isDeviceRegisteredForRemoteMessages, - ); - - if (messaging().isDeviceRegisteredForRemoteMessages) { - await messaging() - .unregisterDeviceForRemoteMessages() - .then((status: unknown) => { - Logger.log('unregisterDeviceForRemoteMessages status', status); - }) - .catch((error: Error) => { - Logger.error(error); - }); - } - await messaging().deleteToken(); - Logger.log( - 'unRegisterAppWithFCM status', - messaging().isDeviceRegisteredForRemoteMessages, - ); -} - -export const checkApplicationNotificationPermission = async () => { - const authStatus = await messaging().requestPermission(); - - const enabled = - authStatus === FirebaseMessagingTypes.AuthorizationStatus.AUTHORIZED || - authStatus === FirebaseMessagingTypes.AuthorizationStatus.PROVISIONAL; - - if (enabled) { - Logger.log('Authorization status:', authStatus); - } - request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS) - .then((result) => { - Logger.log('POST_NOTIFICATIONS status:', result); - }) - .catch((error: Error) => { - Logger.error(error); - }); -}; - -export const getFcmToken = async () => { - let token = null; - await checkApplicationNotificationPermission(); - await registerAppWithFCM(); - try { - token = await messaging().getToken(); - Logger.log('getFcmToken-->', token); - } catch (error: unknown) { - Logger.error(error as Error); - } - return token; -}; diff --git a/app/util/notifications/methods/index.ts b/app/util/notifications/methods/index.ts index 17c65417c84..d0b93236650 100644 --- a/app/util/notifications/methods/index.ts +++ b/app/util/notifications/methods/index.ts @@ -1,2 +1 @@ -export * from './fcmHelper'; export * from './common'; diff --git a/app/util/notifications/services/FCMService.test.ts b/app/util/notifications/services/FCMService.test.ts new file mode 100644 index 00000000000..cc29e672e57 --- /dev/null +++ b/app/util/notifications/services/FCMService.test.ts @@ -0,0 +1,135 @@ +import { cleanup } from '@testing-library/react-native'; +import { MMKV } from 'react-native-mmkv'; +import messaging from '@react-native-firebase/messaging'; +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; + jest.clearAllMocks(); + }); + + it('gets local storage token correctly', () => { + const mockKey = 'metaMaskFcmToken'; + const mockValue = { data: 'fcmToken' }; + + storage.set(mockKey, JSON.stringify(mockValue)); + storage.getString(mockKey); + + const result = mmStorage.getLocal(mockKey); + + expect(result).toEqual(mockValue); + }); + + it('gets FCM token', async () => { + const mockToken = 'fcmToken'; + + const token = await FCMService.getFCMToken(); + expect(token).toBe(mockToken); + }); + + it('logs if FCM token is not found', async () => { + const mockKey = 'metaMaskFcmToken'; + const mockValue = { data: undefined }; + + const getLocalMock = jest.spyOn(mmStorage, 'getLocal').mockReturnValue(undefined); + + storage.set(mockKey, JSON.stringify(mockValue)); + storage.getString(mockKey); + + await FCMService.getFCMToken(); + expect(Logger.log).toHaveBeenCalledWith('getFCMToken: No FCM token found'); + + getLocalMock.mockRestore(); + }); + + it('saves FCM token', async () => { + const mockKey = 'metaMaskFcmToken'; + const mockValue = { data: 'fcmToken' }; + + storage.set(mockKey, JSON.stringify(mockValue)); + storage.getString(mockKey); + + await FCMService.saveFCMToken(); + expect(mmStorage.saveLocal).toHaveBeenCalled(); + }); + + 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); + + await FCMService.saveFCMToken(); + expect(mmStorage.saveLocal).toHaveBeenCalledWith('metaMaskFcmToken', { data: mockToken }); + }); + + 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); + + await FCMService.saveFCMToken(); + expect(mmStorage.saveLocal).toHaveBeenCalledWith('metaMaskFcmToken', { data: mockToken }); + }); +}); diff --git a/app/util/notifications/services/FCMService.ts b/app/util/notifications/services/FCMService.ts new file mode 100644 index 00000000000..0c31f09ae92 --- /dev/null +++ b/app/util/notifications/services/FCMService.ts @@ -0,0 +1,65 @@ +import messaging, { FirebaseMessagingTypes } from '@react-native-firebase/messaging'; +import Logger from '../../../util/Logger'; +import { mmStorage } from '../settings'; +import NotificationManager from '../../../core/NotificationManager'; +import { parseNotification } from '../methods'; + +type UnsubscribeFunc = () => void + +class FCMService { + + getFCMToken = async (): Promise => { + const fcmTokenLocal = await mmStorage.getLocal('metaMaskFcmToken'); + const token = fcmTokenLocal?.data || undefined; + if (!token) { + Logger.log('getFCMToken: No FCM token found'); + } + return token; + }; + + saveFCMToken = async () => { + 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'); + } + }; + + listenForMessagesForeground = (): UnsubscribeFunc => messaging().onMessage(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { + const notificationData = parseNotification(remoteMessage); + NotificationManager.onMessageReceived(notificationData); + }); + + listenForMessagesBackground = (): void => { + messaging().setBackgroundMessageHandler(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { + const notificationData = parseNotification(remoteMessage); + NotificationManager.onMessageReceived(notificationData); + }); + }; + + 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); + }); + } + }; +} +export default new FCMService(); diff --git a/app/util/notifications/settings/storage/constants.ts b/app/util/notifications/settings/storage/constants.ts index f3bc4c2fb5c..390f9922957 100644 --- a/app/util/notifications/settings/storage/constants.ts +++ b/app/util/notifications/settings/storage/constants.ts @@ -12,6 +12,7 @@ export const STORAGE_IDS = { REQUEST_PERMISSION_GRANTED: 'REQUEST_PERMISSION_GRANTED', NOTIFICATION_DATE_FORMAT: 'DD/MM/YYYY HH:mm:ss', NOTIFICATIONS_SETTINGS: 'notifications-settings', + PN_USER_STORAGE: 'pnUserStorage', }; export const STORAGE_TYPES = { @@ -25,19 +26,16 @@ export const STORAGE_TYPES = { export const mapStorageTypeToIds = (id: string) => { switch (id) { case STORAGE_IDS.NOTIFICATIONS: - return STORAGE_TYPES.OBJECT; case STORAGE_IDS.GLOBAL_PUSH_NOTIFICATION_SETTINGS: - return STORAGE_TYPES.OBJECT; case STORAGE_IDS.MM_FCM_TOKEN: + case STORAGE_IDS.NOTIFICATIONS_SETTINGS: + case STORAGE_IDS.PN_USER_STORAGE: return STORAGE_TYPES.OBJECT; case STORAGE_IDS.PUSH_NOTIFICATIONS_PROMPT_COUNT: return STORAGE_TYPES.NUMBER; case STORAGE_IDS.REQUEST_PERMISSION_ASKED: - return STORAGE_TYPES.BOOLEAN; case STORAGE_IDS.REQUEST_PERMISSION_GRANTED: return STORAGE_TYPES.BOOLEAN; - case STORAGE_IDS.NOTIFICATIONS_SETTINGS: - return STORAGE_TYPES.OBJECT; default: return STORAGE_TYPES.STRING; } diff --git a/app/util/notifications/settings/storage/contants.test.ts b/app/util/notifications/settings/storage/contants.test.ts index efb2cf68f91..acfc00f0258 100644 --- a/app/util/notifications/settings/storage/contants.test.ts +++ b/app/util/notifications/settings/storage/contants.test.ts @@ -17,6 +17,7 @@ describe('constants', () => { REQUEST_PERMISSION_GRANTED: 'REQUEST_PERMISSION_GRANTED', NOTIFICATION_DATE_FORMAT: 'DD/MM/YYYY HH:mm:ss', NOTIFICATIONS_SETTINGS: 'notifications-settings', + PN_USER_STORAGE: 'pnUserStorage', }); });