diff --git a/ios/Podfile.lock b/ios/Podfile.lock index aefd694e2a7..2fbfe69d785 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -441,6 +441,8 @@ PODS: - React-Core - react-native-netinfo (6.0.6): - React-Core + - react-native-notifications-utils (0.3.0): + - React-Core - react-native-pager-view (6.2.3): - RCT-Folly (= 2021.07.22.00) - React-Core @@ -733,6 +735,7 @@ DEPENDENCIES: - react-native-get-random-values (from `../node_modules/react-native-get-random-values`) - react-native-image-picker (from `../node_modules/react-native-image-picker`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" + - react-native-notifications-utils (from `../node_modules/react-native-notifications-utils`) - react-native-pager-view (from `../node_modules/react-native-pager-view`) - react-native-pdf (from `../node_modules/react-native-pdf`) - react-native-pdf-thumbnail (from `../node_modules/react-native-pdf-thumbnail`) @@ -906,6 +909,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-image-picker" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" + react-native-notifications-utils: + :path: "../node_modules/react-native-notifications-utils" react-native-pager-view: :path: "../node_modules/react-native-pager-view" react-native-pdf: @@ -1067,6 +1072,7 @@ SPEC CHECKSUMS: react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06 react-native-image-picker: 60f4246eb5bb7187fc15638a8c1f13abd3820695 react-native-netinfo: 40b91995cd49c33ae57996486db76f0af067b630 + react-native-notifications-utils: f083307d1261f34444a89ca2f9b69f920c9b81e7 react-native-pager-view: 948dc00b6545d82b53e5f99cafef4a8521c60dd4 react-native-pdf: 79aa75e39a80c1d45ffe58aa500f3cf08f267a2e react-native-pdf-thumbnail: a042fffdab7a49f0f9df0e11da0a90beebfd4241 diff --git a/package.json b/package.json index ca413e72416..5bf8c6df78d 100644 --- a/package.json +++ b/package.json @@ -129,10 +129,10 @@ "@react-navigation/stack": "6.3.20", "@redux-saga/testing-utils": "^1.1.3", "@sentry/react-native": "^5.32.0", - "@shopify/react-native-skia": "^1.3.13", "@shopify/flash-list": "~1.7.0", - "@xstate/react": "^4.0.1", + "@shopify/react-native-skia": "^1.3.13", "@textlint/markdown-to-ast": "^14.0.4", + "@xstate/react": "^4.0.1", "async-mutex": "^0.1.3", "buffer": "^4.9.1", "color": "^3.0.0", @@ -180,6 +180,7 @@ "react-native-linear-gradient": "^2.5.6", "react-native-markdown-display": "^7.0.2", "react-native-masked-text": "^1.13.0", + "react-native-notifications-utils": "^0.3.0", "react-native-pager-view": "^6.2.3", "react-native-pdf": "6.7.4", "react-native-pdf-thumbnail": "^1.2.1", diff --git a/ts/boot/__tests__/persistedStore.test.ts b/ts/boot/__tests__/persistedStore.test.ts index 0ee2af66af5..34967b55bb7 100644 --- a/ts/boot/__tests__/persistedStore.test.ts +++ b/ts/boot/__tests__/persistedStore.test.ts @@ -17,7 +17,11 @@ describe("Check the addition for new fields to the persisted store. If one of th id: "fakeInstallationId" } }; - expect(notifications).toMatchSnapshot(); + const notificationsWithoutPermissions = _.omit( + notifications, + "permissions" + ); + expect(notificationsWithoutPermissions).toMatchSnapshot(); }); it("Freeze 'profile' state", () => { expect(globalState.profile).toMatchSnapshot(); diff --git a/ts/boot/configureStoreAndPersistor.ts b/ts/boot/configureStoreAndPersistor.ts index 1772c1ae77f..e8df018d3e1 100644 --- a/ts/boot/configureStoreAndPersistor.ts +++ b/ts/boot/configureStoreAndPersistor.ts @@ -41,7 +41,10 @@ import { INSTALLATION_INITIAL_STATE, InstallationState } from "../store/reducers/installation"; -import { NotificationsState } from "../features/pushNotifications/store/reducers"; +import { + NOTIFICATIONS_STORE_VERSION, + NotificationsState +} from "../features/pushNotifications/store/reducers"; import { getInitialState as getInstallationInitialState } from "../features/pushNotifications/store/reducers/installation"; import { GlobalState, PersistedGlobalState } from "../store/reducers/types"; import { walletsPersistConfig } from "../store/reducers/wallet"; @@ -53,7 +56,7 @@ import { configureReactotron } from "./configureRectotron"; /** * Redux persist will migrate the store to the current version */ -const CURRENT_REDUX_STORE_VERSION = 36; +const CURRENT_REDUX_STORE_VERSION = 37; // see redux-persist documentation: // https://github.com/rt2zz/redux-persist/blob/master/docs/migrations.md @@ -440,7 +443,21 @@ const migrations: MigrationManifest = { }), // Remove isNewScanSectionEnabled from persistedPreferences "36": (state: PersistedState) => - omit(state, "persistedPreferences.isNewScanSectionEnabled") + omit(state, "persistedPreferences.isNewScanSectionEnabled"), + // Move 'notifications' from root persistor to its own + "37": (state: PersistedState) => { + const typedState = state as GlobalState; + return { + ...state, + notifications: { + ...typedState.notifications, + _persist: { + version: NOTIFICATIONS_STORE_VERSION, + rehydrated: true + } + } + }; + } }; const isDebuggingInChrome = isDevEnv && !!window.navigator.userAgent; @@ -456,7 +473,6 @@ const rootPersistConfig: PersistConfig = { // Sections of the store that must be persisted and rehydrated with this storage. whitelist: [ "onboarding", - "notifications", "profile", "persistedPreferences", "installation", diff --git a/ts/features/pushNotifications/analytics/__tests__/index.test.ts b/ts/features/pushNotifications/analytics/__tests__/index.test.ts new file mode 100644 index 00000000000..a109e625a6a --- /dev/null +++ b/ts/features/pushNotifications/analytics/__tests__/index.test.ts @@ -0,0 +1,176 @@ +import { + trackNewPushNotificationsTokenGenerated, + trackNotificationInstallationTokenNotChanged, + trackNotificationsOptInOpenSettings, + trackNotificationsOptInPreviewStatus, + trackNotificationsOptInReminderOnPermissionsOff, + trackNotificationsOptInReminderStatus, + trackNotificationsOptInSkipSystemPermissions, + trackPushNotificationTokenUploadFailure, + trackPushNotificationTokenUploadSucceeded +} from ".."; +import { PushNotificationsContentTypeEnum } from "../../../../../definitions/backend/PushNotificationsContentType"; +import { ReminderStatusEnum } from "../../../../../definitions/backend/ReminderStatus"; +import * as Mixpanel from "../../../../mixpanel"; + +describe("pushNotifications analytics", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + it("'trackNotificationInstallationTokenNotChanged' should have expected event name and properties", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNotificationInstallationTokenNotChanged(); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_INSTALLATION_TOKEN_NOT_CHANGED" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "TECH" + }); + }); + it("'trackNotificationsOptInPreviewStatus' should have expected event name and properties for 'ANONYMOUS' content type", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNotificationsOptInPreviewStatus( + PushNotificationsContentTypeEnum.ANONYMOUS + ); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_OPTIN_PREVIEW_STATUS" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + enabled: false + }); + }); + it("'trackNotificationsOptInPreviewStatus' should have expected event name and properties for 'FULL' content type", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNotificationsOptInPreviewStatus( + PushNotificationsContentTypeEnum.FULL + ); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_OPTIN_PREVIEW_STATUS" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + enabled: true + }); + }); + it("'trackNotificationsOptInReminderStatus' should have expected event name and properties for 'DISABLED' reminder", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNotificationsOptInReminderStatus(ReminderStatusEnum.DISABLED); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_OPTIN_REMINDER_STATUS" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + enabled: false + }); + }); + it("'trackNotificationsOptInReminderStatus' should have expected event name and properties for 'ENABLED' reminder", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNotificationsOptInReminderStatus(ReminderStatusEnum.ENABLED); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_OPTIN_REMINDER_STATUS" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + enabled: true + }); + }); + it("'trackNotificationsOptInReminderOnPermissionsOff' should have expected event name and properties", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNotificationsOptInReminderOnPermissionsOff(); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_OPTIN_REMINDER_ON_PERMISSIONS_OFF" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "control" + }); + }); + it("'trackNotificationsOptInOpenSettings' should have expected event name and properties", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNotificationsOptInOpenSettings(); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_OPTIN_OPEN_SETTINGS" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action" + }); + }); + it("'trackNotificationsOptInSkipSystemPermissions' should have expected event name and properties", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNotificationsOptInSkipSystemPermissions(); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_OPTIN_SKIP_SYSTEM_PERMISSIONS" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action" + }); + }); + it("'trackNewPushNotificationsTokenGenerated' should have expected event name and properties", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackNewPushNotificationsTokenGenerated(); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_INSTALLATION_TOKEN_UPDATE" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "TECH" + }); + }); + it("'trackPushNotificationTokenUploadSucceeded' should have expected event name and properties", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + void trackPushNotificationTokenUploadSucceeded(); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_INSTALLATION_TOKEN_REGISTERED" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "TECH" + }); + }); + it("'trackPushNotificationTokenUploadFailure' should have expected event name and properties", () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + const reason = "The reason"; + void trackPushNotificationTokenUploadFailure(reason); + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "NOTIFICATIONS_INSTALLATION_UPDATE_FAILURE" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "KO", + event_type: "error", + reason + }); + }); +}); + +const getMockMixpanelTrack = () => + jest + .spyOn(Mixpanel, "mixpanelTrack") + .mockImplementation((_event, _properties) => undefined); diff --git a/ts/features/pushNotifications/analytics/index.ts b/ts/features/pushNotifications/analytics/index.ts index eaea115ea88..5e180ed6f8a 100644 --- a/ts/features/pushNotifications/analytics/index.ts +++ b/ts/features/pushNotifications/analytics/index.ts @@ -3,49 +3,64 @@ import { ReminderStatusEnum } from "../../../../definitions/backend/ReminderStat import { mixpanelTrack } from "../../../mixpanel"; import { buildEventProperties } from "../../../utils/analytics"; -export function trackNotificationInstallationTokenNotChanged() { - void mixpanelTrack("NOTIFICATIONS_INSTALLATION_TOKEN_NOT_CHANGED"); -} +export const trackNotificationInstallationTokenNotChanged = () => + void mixpanelTrack( + "NOTIFICATIONS_INSTALLATION_TOKEN_NOT_CHANGED", + buildEventProperties("TECH", undefined) + ); -export function trackNotificationsOptInPreviewStatus( +export const trackNotificationsOptInPreviewStatus = ( contentType: PushNotificationsContentTypeEnum -) { +) => void mixpanelTrack( "NOTIFICATIONS_OPTIN_PREVIEW_STATUS", buildEventProperties("UX", "action", { enabled: contentType === PushNotificationsContentTypeEnum.FULL }) ); -} -export function trackNotificationsOptInReminderStatus( +export const trackNotificationsOptInReminderStatus = ( reminderStatus: ReminderStatusEnum -) { +) => void mixpanelTrack( "NOTIFICATIONS_OPTIN_REMINDER_STATUS", buildEventProperties("UX", "action", { enabled: reminderStatus === ReminderStatusEnum.ENABLED }) ); -} -export function trackNotificationsOptInReminderOnPermissionsOff() { +export const trackNotificationsOptInReminderOnPermissionsOff = () => void mixpanelTrack( "NOTIFICATIONS_OPTIN_REMINDER_ON_PERMISSIONS_OFF", buildEventProperties("UX", "control") ); -} -export function trackNotificationsOptInOpenSettings() { +export const trackNotificationsOptInOpenSettings = () => void mixpanelTrack( "NOTIFICATIONS_OPTIN_OPEN_SETTINGS", buildEventProperties("UX", "action") ); -} -export function trackNotificationsOptInSkipSystemPermissions() { +export const trackNotificationsOptInSkipSystemPermissions = () => void mixpanelTrack( "NOTIFICATIONS_OPTIN_SKIP_SYSTEM_PERMISSIONS", buildEventProperties("UX", "action") ); -} + +export const trackNewPushNotificationsTokenGenerated = () => + void mixpanelTrack( + "NOTIFICATIONS_INSTALLATION_TOKEN_UPDATE", + buildEventProperties("TECH", undefined) + ); + +export const trackPushNotificationTokenUploadSucceeded = () => + void mixpanelTrack( + "NOTIFICATIONS_INSTALLATION_TOKEN_REGISTERED", + buildEventProperties("TECH", undefined) + ); + +export const trackPushNotificationTokenUploadFailure = (reason: string) => + void mixpanelTrack( + "NOTIFICATIONS_INSTALLATION_UPDATE_FAILURE", + buildEventProperties("KO", "error", { reason }) + ); diff --git a/ts/features/pushNotifications/sagas/__tests__/common.test.ts b/ts/features/pushNotifications/sagas/__tests__/common.test.ts new file mode 100644 index 00000000000..15a18a08fc7 --- /dev/null +++ b/ts/features/pushNotifications/sagas/__tests__/common.test.ts @@ -0,0 +1,256 @@ +import { testSaga } from "redux-saga-test-plan"; +import { + PendingMessageState, + pendingMessageStateSelector +} from "../../store/reducers/pendingMessage"; +import * as Analytics from "../../../messages/analytics"; +import { + checkAndUpdateNotificationPermissionsIfNeeded, + handlePendingMessageStateIfAllowed, + trackMessageNotificationTapIfNeeded, + updateNotificationPermissionsIfNeeded +} from "../common"; +import { isPaymentOngoingSelector } from "../../../../store/reducers/wallet/payment"; +import { navigateToMessageRouterAction } from "../../utils/navigation"; +import { UIMessageId } from "../../../messages/types"; +import { clearNotificationPendingMessage } from "../../store/actions/pendingMessage"; +import { isArchivingDisabledSelector } from "../../../messages/store/reducers/archiving"; +import NavigationService from "../../../../navigation/NavigationService"; +import { navigateToMainNavigatorAction } from "../../../../store/actions/navigation"; +import { resetMessageArchivingAction } from "../../../messages/store/actions/archiving"; +import { areNotificationPermissionsEnabled } from "../../store/reducers/permissions"; +import { updateSystemNotificationsEnabled } from "../../store/actions/permissions"; +import { checkNotificationPermissions } from "../../utils"; + +describe("handlePendingMessageStateIfAllowed", () => { + const mockedPendingMessageState: PendingMessageState = { + id: "M01", + foreground: true, + trackEvent: false + }; + + it("make the app navigate to the message detail when the user press on a notification", () => { + const dispatchNavigationActionParameter = navigateToMessageRouterAction({ + messageId: mockedPendingMessageState.id as UIMessageId, + fromNotification: true + }); + + testSaga(handlePendingMessageStateIfAllowed, false) + .next() + .select(pendingMessageStateSelector) + .next(mockedPendingMessageState) + .call(trackMessageNotificationTapIfNeeded, mockedPendingMessageState) + .next() + .select(isPaymentOngoingSelector) + .next(false) + .put(clearNotificationPendingMessage()) + .next() + .select(isArchivingDisabledSelector) + .next(true) + .call( + NavigationService.dispatchNavigationAction, + dispatchNavigationActionParameter + ) + .next() + .isDone(); + }); + + it("make the app navigate to the message detail when the user press on a notification, resetting the navigation stack before", () => { + const dispatchNavigationActionParameter = navigateToMessageRouterAction({ + messageId: mockedPendingMessageState.id as UIMessageId, + fromNotification: true + }); + + testSaga(handlePendingMessageStateIfAllowed, true) + .next() + .select(pendingMessageStateSelector) + .next(mockedPendingMessageState) + .call(trackMessageNotificationTapIfNeeded, mockedPendingMessageState) + .next() + .select(isPaymentOngoingSelector) + .next(false) + .put(clearNotificationPendingMessage()) + .next() + .call(navigateToMainNavigatorAction) + .next() + .select(isArchivingDisabledSelector) + .next(true) + .call( + NavigationService.dispatchNavigationAction, + dispatchNavigationActionParameter + ) + .next() + .isDone(); + }); + + it("make the app navigate to the message detail when the user press on a notification, resetting the message archiving/restoring if such is disabled", () => { + const dispatchNavigationActionParameter = navigateToMessageRouterAction({ + messageId: mockedPendingMessageState.id as UIMessageId, + fromNotification: true + }); + + testSaga(handlePendingMessageStateIfAllowed, false) + .next() + .select(pendingMessageStateSelector) + .next(mockedPendingMessageState) + .call(trackMessageNotificationTapIfNeeded, mockedPendingMessageState) + .next() + .select(isPaymentOngoingSelector) + .next(false) + .put(clearNotificationPendingMessage()) + .next() + .select(isArchivingDisabledSelector) + .next(false) + .put(resetMessageArchivingAction(undefined)) + .next() + .call( + NavigationService.dispatchNavigationAction, + dispatchNavigationActionParameter + ) + .next() + .isDone(); + }); + + it("does nothing if there is a payment going on", () => { + testSaga(handlePendingMessageStateIfAllowed, false) + .next() + .select(pendingMessageStateSelector) + .next(mockedPendingMessageState) + .next() + .select(isPaymentOngoingSelector) + .next(true) + .isDone(); + }); + + it("does nothing if there are not pending messages", () => { + testSaga(handlePendingMessageStateIfAllowed, false) + .next() + .select(pendingMessageStateSelector) + .next(null) + .next() + .select(isPaymentOngoingSelector) + .next(false) + .isDone(); + }); + + it("does nothing if there are not pending messages and there is a payment going on", () => { + testSaga(handlePendingMessageStateIfAllowed, false) + .next() + .select(pendingMessageStateSelector) + .next(null) + .next() + .select(isPaymentOngoingSelector) + .next(true) + .isDone(); + }); +}); + +describe("trackMessageNotificationTapIfNeeded", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it("should call trackMessageNotificationTap when there is a PendingMessageState that requires tracking", () => { + const spiedTrackMessageNotificationTap = jest + .spyOn(Analytics, "trackMessageNotificationTap") + .mockImplementation(jest.fn()); + const mockPendingMessageState = { + id: "001", + foreground: true, + trackEvent: true + } as PendingMessageState; + trackMessageNotificationTapIfNeeded(mockPendingMessageState); + expect(spiedTrackMessageNotificationTap).toBeCalledWith("001"); + }); + it("should not call trackMessageNotificationTap when there is a PendingMessageState that does not require tracking", () => { + const spiedTrackMessageNotificationTap = jest + .spyOn(Analytics, "trackMessageNotificationTap") + .mockImplementation(jest.fn()); + const mockPendingMessageState = { + id: "001", + foreground: true, + trackEvent: false + } as PendingMessageState; + trackMessageNotificationTapIfNeeded(mockPendingMessageState); + expect(spiedTrackMessageNotificationTap).not.toHaveBeenCalled(); + }); + it("should not call trackMessageNotificationTap when there is a PendingMessageState that does not have a tracking information", () => { + const spiedTrackMessageNotificationTap = jest + .spyOn(Analytics, "trackMessageNotificationTap") + .mockImplementation(jest.fn()); + const mockPendingMessageState = { + id: "001", + foreground: true + } as PendingMessageState; + trackMessageNotificationTapIfNeeded(mockPendingMessageState); + expect(spiedTrackMessageNotificationTap).not.toHaveBeenCalled(); + }); + it("should not call trackMessageNotificationTap when there is not a PendingMessageState", () => { + const spiedTrackMessageNotificationTap = jest + .spyOn(Analytics, "trackMessageNotificationTap") + .mockImplementation(jest.fn()); + trackMessageNotificationTapIfNeeded(); + expect(spiedTrackMessageNotificationTap).not.toHaveBeenCalled(); + }); +}); + +describe("updateNotificationPermissionsIfNeeded", () => { + it("should not dispatch 'updateSystemNotificationsEnabled' when system permissions are 'false' and in-memory data are 'false'", () => { + testSaga(updateNotificationPermissionsIfNeeded, false) + .next() + .select(areNotificationPermissionsEnabled) + .next(false) + .isDone(); + }); + it("should dispatch 'updateSystemNotificationsEnabled(false)' when system permissions are 'false' and in-memory data are 'true'", () => { + testSaga(updateNotificationPermissionsIfNeeded, false) + .next() + .select(areNotificationPermissionsEnabled) + .next(true) + .put(updateSystemNotificationsEnabled(false)) + .next() + .isDone(); + }); + it("should dispatch 'updateSystemNotificationsEnabled(true)' when system permissions are 'true' and in-memory data are 'false'", () => { + testSaga(updateNotificationPermissionsIfNeeded, true) + .next() + .select(areNotificationPermissionsEnabled) + .next(false) + .put(updateSystemNotificationsEnabled(true)) + .next() + .isDone(); + }); + it("should not dispatch 'updateSystemNotificationsEnabled' when system permissions are 'true' and in-memory data are 'true'", () => { + testSaga(updateNotificationPermissionsIfNeeded, true) + .next() + .select(areNotificationPermissionsEnabled) + .next(true) + .isDone(); + }); +}); + +describe("checkAndUpdateNotificationPermissionsIfNeeded", () => { + it("when 'checkNotificationPermissions' returns 'false', should call 'updateNotificationPermissionsIfNeeded' with 'false' and return 'false'", () => { + const systemPermissionsEnabled = false; + testSaga(checkAndUpdateNotificationPermissionsIfNeeded) + .next() + .call(checkNotificationPermissions) + .next(systemPermissionsEnabled) + .call(updateNotificationPermissionsIfNeeded, systemPermissionsEnabled) + .next() + .returns(systemPermissionsEnabled) + .next() + .isDone(); + }); + it("when 'checkNotificationPermissions' returns 'true', should call 'updateNotificationPermissionsIfNeeded' with 'true' and return 'true'", () => { + const systemPermissionsEnabled = true; + testSaga(checkAndUpdateNotificationPermissionsIfNeeded) + .next() + .call(checkNotificationPermissions) + .next(systemPermissionsEnabled) + .call(updateNotificationPermissionsIfNeeded, systemPermissionsEnabled) + .next() + .returns(systemPermissionsEnabled) + .next() + .isDone(); + }); +}); diff --git a/ts/features/pushNotifications/sagas/__tests__/notificationPermissionsListener.test.ts b/ts/features/pushNotifications/sagas/__tests__/notificationPermissionsListener.test.ts new file mode 100644 index 00000000000..e16ff29993e --- /dev/null +++ b/ts/features/pushNotifications/sagas/__tests__/notificationPermissionsListener.test.ts @@ -0,0 +1,67 @@ +import { testSaga } from "redux-saga-test-plan"; +import { + checkNotificationPermissionsOnAppForegroundState, + notificationPermissionsListener +} from "../notificationPermissionsListener"; +import { checkAndUpdateNotificationPermissionsIfNeeded } from "../common"; +import { applicationChangeState } from "../../../../store/actions/application"; + +describe("notificationPermissionsListener", () => { + it("Should get and update system permissions and start listening for 'applicationChangeState' action with 'takeLatest'", () => { + testSaga(notificationPermissionsListener) + .next() + .call(checkAndUpdateNotificationPermissionsIfNeeded) + .next() + .takeLatest( + applicationChangeState, + checkNotificationPermissionsOnAppForegroundState + ) + .next() + .isDone(); + }); +}); + +describe("checkNotificationPermissionsOnAppForegroundState", () => { + it("Should call 'checkAndUpdateNotificationPermissionsIfNeeded' and terminate if new app state is 'active'", () => { + testSaga( + checkNotificationPermissionsOnAppForegroundState, + applicationChangeState("active") + ) + .next() + .call(checkAndUpdateNotificationPermissionsIfNeeded) + .next() + .isDone(); + }); + it("Should do nothing and terminate if new app state is 'background'", () => { + testSaga( + checkNotificationPermissionsOnAppForegroundState, + applicationChangeState("background") + ) + .next() + .isDone(); + }); + it("Should do nothing and terminate if new app state is 'extension'", () => { + testSaga( + checkNotificationPermissionsOnAppForegroundState, + applicationChangeState("extension") + ) + .next() + .isDone(); + }); + it("Should do nothing and terminate if new app state is 'inactive'", () => { + testSaga( + checkNotificationPermissionsOnAppForegroundState, + applicationChangeState("inactive") + ) + .next() + .isDone(); + }); + it("Should do nothing and terminate if new app state is 'unknown'", () => { + testSaga( + checkNotificationPermissionsOnAppForegroundState, + applicationChangeState("unknown") + ) + .next() + .isDone(); + }); +}); diff --git a/ts/features/pushNotifications/sagas/__tests__/notifications.test.ts b/ts/features/pushNotifications/sagas/__tests__/notifications.test.ts deleted file mode 100644 index 53f48ddaba3..00000000000 --- a/ts/features/pushNotifications/sagas/__tests__/notifications.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -import * as E from "fp-ts/lib/Either"; -import { Action } from "redux"; -import { expectSaga, testSaga } from "redux-saga-test-plan"; -import * as matchers from "redux-saga-test-plan/matchers"; -import { applicationChangeState } from "../../../../store/actions/application"; -import { appReducer } from "../../../../store/reducers"; -import { - handlePendingMessageStateIfAllowedSaga, - notificationsPlatform, - trackMessageNotificationTapIfNeeded, - updateInstallationSaga -} from "../notifications"; - -import { - logoutRequest, - sessionExpired, - sessionInvalid -} from "../../../../store/actions/authentication"; -import { - clearNotificationPendingMessage, - notificationsInstallationTokenRegistered, - updateNotificationsInstallationToken -} from "../../store/actions/notifications"; -import { - PendingMessageState, - pendingMessageStateSelector -} from "../../store/reducers/pendingMessage"; -import NavigationService from "../../../../navigation/NavigationService"; -import { isPaymentOngoingSelector } from "../../../../store/reducers/wallet/payment"; -import { navigateToMainNavigatorAction } from "../../../../store/actions/navigation"; -import { navigateToMessageRouterAction } from "../../utils/navigation"; -import { UIMessageId } from "../../../messages/types"; -import * as Analytics from "../../../messages/analytics"; -import { isArchivingDisabledSelector } from "../../../messages/store/reducers/archiving"; -import { resetMessageArchivingAction } from "../../../messages/store/actions/archiving"; - -const installationId = "installationId"; -jest.mock("../../utils/index", () => ({ - generateInstallationId: () => installationId -})); - -describe("updateInstallationSaga", () => { - const updateState = ( - actions: ReadonlyArray, - currentState: ReturnType | undefined = undefined - ) => actions.reduce((acc, curr) => appReducer(acc, curr), currentState); - - describe("when the store is empty and the push notification token is not stored yet", () => { - it("then it should check and do nothing", () => { - const globalState = updateState([applicationChangeState("active")]); - const createOrUpdateInstallation = jest.fn(); - void expectSaga(updateInstallationSaga, createOrUpdateInstallation) - .withState(globalState) - .returns(undefined) - .run(); - }); - }); - - describe("when push notification token is available and saved in the store", () => { - const pushNotificationToken = "googleOrApplePushNotificationToken"; - const globalState = updateState([ - applicationChangeState("active"), - updateNotificationsInstallationToken(pushNotificationToken) - ]); - describe("and no previous token is been sent to the backend", () => { - const createOrUpdateInstallation = jest.fn(); - - it("then it should send it to the backend", () => - expectSaga(updateInstallationSaga, createOrUpdateInstallation) - .withState(globalState) - .provide([ - [ - matchers.call.fn(createOrUpdateInstallation), - E.right({ status: 200 }) - ] - ]) - .call(createOrUpdateInstallation, { - installationID: installationId, - body: { - platform: notificationsPlatform, - pushChannel: pushNotificationToken - } - }) - .put(notificationsInstallationTokenRegistered(pushNotificationToken)) - .run()); - }); - }); - - describe("when push notification token is available and saved in the store and it is already sent to the backend", () => { - const pushNotificationToken = "googleOrApplePushNotificationToken"; - const globalState = updateState([ - applicationChangeState("active"), - updateNotificationsInstallationToken(pushNotificationToken), - notificationsInstallationTokenRegistered(pushNotificationToken) - ]); - - describe("and it doesn't change", () => { - it("the it should not send the push notification token to the backend", () => { - const localState = updateState( - [notificationsInstallationTokenRegistered(pushNotificationToken)], - globalState - ); - const createOrUpdateInstallation = jest.fn(); - return expectSaga(updateInstallationSaga, createOrUpdateInstallation) - .withState(localState) - .returns(undefined) - .run(); - }); - }); - - describe("and it changes", () => { - const newPushNotificationToken = "newGoogleOrApplePushNotificationToken"; - it("should send the push notification token to the backend", () => { - const localState = updateState( - [updateNotificationsInstallationToken(newPushNotificationToken)], - globalState - ); - const createOrUpdateInstallation = jest.fn(); - return expectSaga(updateInstallationSaga, createOrUpdateInstallation) - .withState(localState) - .provide([ - [ - matchers.call.fn(createOrUpdateInstallation), - E.right({ status: 200 }) - ] - ]) - .call(createOrUpdateInstallation, { - installationID: installationId, - body: { - platform: notificationsPlatform, - pushChannel: newPushNotificationToken - } - }) - .put( - notificationsInstallationTokenRegistered(newPushNotificationToken) - ) - .run(); - }); - }); - - describe("and the user did logout", () => { - it("should send the push notification token", () => { - const localState = updateState( - [logoutRequest({ withApiCall: true })], - globalState - ); - const createOrUpdateInstallation = jest.fn(); - return expectSaga(updateInstallationSaga, createOrUpdateInstallation) - .withState(localState) - .provide([ - [ - matchers.call.fn(createOrUpdateInstallation), - E.right({ status: 200 }) - ] - ]) - .call(createOrUpdateInstallation, { - installationID: installationId, - body: { - platform: notificationsPlatform, - pushChannel: pushNotificationToken - } - }) - .put(notificationsInstallationTokenRegistered(pushNotificationToken)) - .run(); - }); - }); - - describe("and the session expires", () => { - it("should send the push notification token", () => { - const localState = updateState([sessionExpired()], globalState); - const createOrUpdateInstallation = jest.fn(); - return expectSaga(updateInstallationSaga, createOrUpdateInstallation) - .withState(localState) - .provide([ - [ - matchers.call.fn(createOrUpdateInstallation), - E.right({ status: 200 }) - ] - ]) - .call(createOrUpdateInstallation, { - installationID: installationId, - body: { - platform: notificationsPlatform, - pushChannel: pushNotificationToken - } - }) - .put(notificationsInstallationTokenRegistered(pushNotificationToken)) - .run(); - }); - }); - - describe("and the session becomes invalid", () => { - it("should send the push notification token", () => { - const anotherPushNotificationToken = - "newGoogleOrApplePushNotificationToken"; - const anotherGlobalState = updateState([ - applicationChangeState("active"), - updateNotificationsInstallationToken(anotherPushNotificationToken), - notificationsInstallationTokenRegistered( - anotherPushNotificationToken - ), - sessionInvalid() - ]); - const createOrUpdateInstallation = jest.fn(); - return expectSaga(updateInstallationSaga, createOrUpdateInstallation) - .withState(anotherGlobalState) - .provide([ - [ - matchers.call.fn(createOrUpdateInstallation), - E.right({ status: 200 }) - ] - ]) - .call(createOrUpdateInstallation, { - installationID: installationId, - body: { - platform: notificationsPlatform, - pushChannel: anotherPushNotificationToken - } - }) - .put( - notificationsInstallationTokenRegistered( - anotherPushNotificationToken - ) - ) - .run(); - }); - }); - }); -}); - -describe("handlePendingMessageStateIfAllowedSaga", () => { - const mockedPendingMessageState: PendingMessageState = { - id: "M01", - foreground: true, - trackEvent: false - }; - - it("make the app navigate to the message detail when the user press on a notification", () => { - const dispatchNavigationActionParameter = navigateToMessageRouterAction({ - messageId: mockedPendingMessageState.id as UIMessageId, - fromNotification: true - }); - - testSaga(handlePendingMessageStateIfAllowedSaga, false) - .next() - .select(pendingMessageStateSelector) - .next(mockedPendingMessageState) - .call(trackMessageNotificationTapIfNeeded, mockedPendingMessageState) - .next() - .select(isPaymentOngoingSelector) - .next(false) - .put(clearNotificationPendingMessage()) - .next() - .select(isArchivingDisabledSelector) - .next(true) - .call( - NavigationService.dispatchNavigationAction, - dispatchNavigationActionParameter - ) - .next() - .isDone(); - }); - - it("make the app navigate to the message detail when the user press on a notification, resetting the navigation stack before", () => { - const dispatchNavigationActionParameter = navigateToMessageRouterAction({ - messageId: mockedPendingMessageState.id as UIMessageId, - fromNotification: true - }); - - testSaga(handlePendingMessageStateIfAllowedSaga, true) - .next() - .select(pendingMessageStateSelector) - .next(mockedPendingMessageState) - .call(trackMessageNotificationTapIfNeeded, mockedPendingMessageState) - .next() - .select(isPaymentOngoingSelector) - .next(false) - .put(clearNotificationPendingMessage()) - .next() - .call(navigateToMainNavigatorAction) - .next() - .select(isArchivingDisabledSelector) - .next(true) - .call( - NavigationService.dispatchNavigationAction, - dispatchNavigationActionParameter - ) - .next() - .isDone(); - }); - - it("make the app navigate to the message detail when the user press on a notification, resetting the message archiving/restoring if such is disabled", () => { - const dispatchNavigationActionParameter = navigateToMessageRouterAction({ - messageId: mockedPendingMessageState.id as UIMessageId, - fromNotification: true - }); - - testSaga(handlePendingMessageStateIfAllowedSaga, false) - .next() - .select(pendingMessageStateSelector) - .next(mockedPendingMessageState) - .call(trackMessageNotificationTapIfNeeded, mockedPendingMessageState) - .next() - .select(isPaymentOngoingSelector) - .next(false) - .put(clearNotificationPendingMessage()) - .next() - .select(isArchivingDisabledSelector) - .next(false) - .put(resetMessageArchivingAction(undefined)) - .next() - .call( - NavigationService.dispatchNavigationAction, - dispatchNavigationActionParameter - ) - .next() - .isDone(); - }); - - it("does nothing if there is a payment going on", () => { - testSaga(handlePendingMessageStateIfAllowedSaga, false) - .next() - .select(pendingMessageStateSelector) - .next(mockedPendingMessageState) - .next() - .select(isPaymentOngoingSelector) - .next(true) - .isDone(); - }); - - it("does nothing if there are not pending messages", () => { - testSaga(handlePendingMessageStateIfAllowedSaga, false) - .next() - .select(pendingMessageStateSelector) - .next(null) - .next() - .select(isPaymentOngoingSelector) - .next(false) - .isDone(); - }); - - it("does nothing if there are not pending messages and there is a payment going on", () => { - testSaga(handlePendingMessageStateIfAllowedSaga, false) - .next() - .select(pendingMessageStateSelector) - .next(null) - .next() - .select(isPaymentOngoingSelector) - .next(true) - .isDone(); - }); -}); - -describe("trackMessageNotificationTapIfNeeded", () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - it("should call trackMessageNotificationTap when there is a PendingMessageState that requires tracking", () => { - const spiedTrackMessageNotificationTap = jest - .spyOn(Analytics, "trackMessageNotificationTap") - .mockImplementation(jest.fn()); - const mockPendingMessageState = { - id: "001", - foreground: true, - trackEvent: true - } as PendingMessageState; - trackMessageNotificationTapIfNeeded(mockPendingMessageState); - expect(spiedTrackMessageNotificationTap).toBeCalledWith("001"); - }); - it("should not call trackMessageNotificationTap when there is a PendingMessageState that does not require tracking", () => { - const spiedTrackMessageNotificationTap = jest - .spyOn(Analytics, "trackMessageNotificationTap") - .mockImplementation(jest.fn()); - const mockPendingMessageState = { - id: "001", - foreground: true, - trackEvent: false - } as PendingMessageState; - trackMessageNotificationTapIfNeeded(mockPendingMessageState); - expect(spiedTrackMessageNotificationTap).not.toHaveBeenCalled(); - }); - it("should not call trackMessageNotificationTap when there is a PendingMessageState that does not have a tracking information", () => { - const spiedTrackMessageNotificationTap = jest - .spyOn(Analytics, "trackMessageNotificationTap") - .mockImplementation(jest.fn()); - const mockPendingMessageState = { - id: "001", - foreground: true - } as PendingMessageState; - trackMessageNotificationTapIfNeeded(mockPendingMessageState); - expect(spiedTrackMessageNotificationTap).not.toHaveBeenCalled(); - }); - it("should not call trackMessageNotificationTap when there is not a PendingMessageState", () => { - const spiedTrackMessageNotificationTap = jest - .spyOn(Analytics, "trackMessageNotificationTap") - .mockImplementation(jest.fn()); - trackMessageNotificationTapIfNeeded(); - expect(spiedTrackMessageNotificationTap).not.toHaveBeenCalled(); - }); -}); diff --git a/ts/features/pushNotifications/sagas/__tests__/checkNotificationsPreferencesSaga.test.tsx b/ts/features/pushNotifications/sagas/__tests__/profileAndSystemNotificationsPermissions.test.tsx similarity index 85% rename from ts/features/pushNotifications/sagas/__tests__/checkNotificationsPreferencesSaga.test.tsx rename to ts/features/pushNotifications/sagas/__tests__/profileAndSystemNotificationsPermissions.test.tsx index e3c9048b7b9..5003a6b58ca 100644 --- a/ts/features/pushNotifications/sagas/__tests__/checkNotificationsPreferencesSaga.test.tsx +++ b/ts/features/pushNotifications/sagas/__tests__/profileAndSystemNotificationsPermissions.test.tsx @@ -2,12 +2,9 @@ import { CommonActions, StackActions } from "@react-navigation/native"; import { testSaga } from "redux-saga-test-plan"; import NavigationService from "../../../../navigation/NavigationService"; import ROUTES from "../../../../navigation/routes"; -import { - checkNotificationPermissions, - requestNotificationPermissions -} from "../../utils"; -import { notificationsInfoScreenConsent } from "../../store/actions/notifications"; -import { checkNotificationsPreferencesSaga } from "../checkNotificationsPreferencesSaga"; +import { requestNotificationPermissions } from "../../utils"; +import { notificationsInfoScreenConsent } from "../../store/actions/profileNotificationPermissions"; +import { profileAndSystemNotificationsPermissions } from "../profileAndSystemNotificationsPermissions"; import { InitializedProfile } from "../../../../../definitions/backend/InitializedProfile"; import { ServicesPreferencesModeEnum } from "../../../../../definitions/backend/ServicesPreferencesMode"; import { profileUpsert } from "../../../../store/actions/profile"; @@ -19,6 +16,10 @@ import { } from "../../analytics"; import { updateMixpanelSuperProperties } from "../../../../mixpanelConfig/superProperties"; import { updateMixpanelProfileProperties } from "../../../../mixpanelConfig/profileProperties"; +import { + checkAndUpdateNotificationPermissionsIfNeeded, + updateNotificationPermissionsIfNeeded +} from "../common"; const generateUserProfile = ( hasDoneNotificationOptIn: boolean, @@ -52,7 +53,7 @@ describe("checkNotificationsPreferencesSaga", () => { const profile = generateUserProfile(false, true); const profileUpsertOutput = profileUpsertResult(); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() .call( NavigationService.dispatchNavigationAction, @@ -74,10 +75,12 @@ describe("checkNotificationsPreferencesSaga", () => { profileUpsertOutput.payload.newValue.reminder_status ) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(false) .call(requestNotificationPermissions) .next(true) + .call(updateNotificationPermissionsIfNeeded, true) + .next() .select() .next(globalState) .call(updateMixpanelSuperProperties, globalState) @@ -92,7 +95,7 @@ describe("checkNotificationsPreferencesSaga", () => { const profile = generateUserProfile(false, true); const profileUpsertOutput = profileUpsertResult(); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() .call( NavigationService.dispatchNavigationAction, @@ -114,10 +117,12 @@ describe("checkNotificationsPreferencesSaga", () => { profileUpsertOutput.payload.newValue.reminder_status ) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(false) .call(requestNotificationPermissions) .next(false) + .call(updateNotificationPermissionsIfNeeded, false) + .next() .call( NavigationService.dispatchNavigationAction, CommonActions.navigate(ROUTES.ONBOARDING, { @@ -143,7 +148,7 @@ describe("checkNotificationsPreferencesSaga", () => { const profile = generateUserProfile(false, true); const profileUpsertOutput = profileUpsertResult(); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() .call( NavigationService.dispatchNavigationAction, @@ -165,7 +170,7 @@ describe("checkNotificationsPreferencesSaga", () => { profileUpsertOutput.payload.newValue.reminder_status ) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(true) .select() .next(globalState) @@ -180,12 +185,14 @@ describe("checkNotificationsPreferencesSaga", () => { it("profile has notification settings, missing service configuration, device has no notification permissions, gives device notification permissions", () => { const profile = generateUserProfile(true, true); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(false) .call(requestNotificationPermissions) .next(true) + .call(updateNotificationPermissionsIfNeeded, true) + .next() .select() .next(globalState) .call(updateMixpanelSuperProperties, globalState) @@ -197,12 +204,14 @@ describe("checkNotificationsPreferencesSaga", () => { it("profile has notification settings, missing service configuration, device has no notification permissions, denies device notification permissions", () => { const profile = generateUserProfile(true, true); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(false) .call(requestNotificationPermissions) .next(false) + .call(updateNotificationPermissionsIfNeeded, false) + .next() .select() .next(globalState) .call(updateMixpanelSuperProperties, globalState) @@ -214,9 +223,9 @@ describe("checkNotificationsPreferencesSaga", () => { it("profile has notification settings, missing service configuration, device has notification permissions", () => { const profile = generateUserProfile(true, true); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(true) .select() .next(globalState) @@ -230,7 +239,7 @@ describe("checkNotificationsPreferencesSaga", () => { const profile = generateUserProfile(false, false); const profileUpsertOutput = profileUpsertResult(); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() .call( NavigationService.dispatchNavigationAction, @@ -252,10 +261,12 @@ describe("checkNotificationsPreferencesSaga", () => { profileUpsertOutput.payload.newValue.reminder_status ) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(false) .call(requestNotificationPermissions) .next(true) + .call(updateNotificationPermissionsIfNeeded, true) + .next() .select() .next(globalState) .call(updateMixpanelSuperProperties, globalState) @@ -270,7 +281,7 @@ describe("checkNotificationsPreferencesSaga", () => { const profile = generateUserProfile(false, false); const profileUpsertOutput = profileUpsertResult(); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() .call( NavigationService.dispatchNavigationAction, @@ -292,10 +303,12 @@ describe("checkNotificationsPreferencesSaga", () => { profileUpsertOutput.payload.newValue.reminder_status ) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(false) .call(requestNotificationPermissions) .next(false) + .call(updateNotificationPermissionsIfNeeded, false) + .next() .call( NavigationService.dispatchNavigationAction, CommonActions.navigate(ROUTES.ONBOARDING, { @@ -321,7 +334,7 @@ describe("checkNotificationsPreferencesSaga", () => { const profile = generateUserProfile(false, false); const profileUpsertOutput = profileUpsertResult(); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() .call( NavigationService.dispatchNavigationAction, @@ -343,7 +356,7 @@ describe("checkNotificationsPreferencesSaga", () => { profileUpsertOutput.payload.newValue.reminder_status ) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(true) .select() .next(globalState) @@ -358,12 +371,14 @@ describe("checkNotificationsPreferencesSaga", () => { it("profile has notification settings, has service configuration, device has no notification permissions, gives device notification permissions", () => { const profile = generateUserProfile(true, false); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(false) .call(requestNotificationPermissions) .next(true) + .call(updateNotificationPermissionsIfNeeded, true) + .next() .select() .next(globalState) .call(updateMixpanelSuperProperties, globalState) @@ -375,12 +390,14 @@ describe("checkNotificationsPreferencesSaga", () => { it("profile has notification settings, has service configuration, device has no notification permissions, denies device notification permissions", () => { const profile = generateUserProfile(true, false); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(false) .call(requestNotificationPermissions) .next(false) + .call(updateNotificationPermissionsIfNeeded, false) + .next() .select() .next(globalState) .call(updateMixpanelSuperProperties, globalState) @@ -392,9 +409,9 @@ describe("checkNotificationsPreferencesSaga", () => { it("profile has notification settings, has service configuration, device has notification permissions", () => { const profile = generateUserProfile(true, false); const globalState = {}; - testSaga(checkNotificationsPreferencesSaga, profile) + testSaga(profileAndSystemNotificationsPermissions, profile) .next() - .call(checkNotificationPermissions) + .call(checkAndUpdateNotificationPermissionsIfNeeded) .next(true) .select() .next(globalState) diff --git a/ts/features/pushNotifications/sagas/__tests__/pushNotificationTokenUpload.test.ts b/ts/features/pushNotifications/sagas/__tests__/pushNotificationTokenUpload.test.ts new file mode 100644 index 00000000000..fdd2079481c --- /dev/null +++ b/ts/features/pushNotifications/sagas/__tests__/pushNotificationTokenUpload.test.ts @@ -0,0 +1,202 @@ +import * as E from "fp-ts/lib/Either"; +import { testSaga } from "redux-saga-test-plan"; +import { + awaitForPushNotificationToken, + notificationsPlatform, + pushNotificationTokenUpload +} from "../pushNotificationTokenUpload"; +import { + newPushNotificationsToken, + pushNotificationsTokenUploaded +} from "../../store/actions/installation"; +import { + InstallationState, + notificationsInstallationSelector +} from "../../store/reducers/installation"; +import { + trackNotificationInstallationTokenNotChanged, + trackPushNotificationTokenUploadFailure, + trackPushNotificationTokenUploadSucceeded +} from "../../analytics"; + +describe("pushNotificationTokenUpload", () => { + it("when the push token is available and not yet registered, it should invoke the backend API and, upon success, dispatch 'pushNotificationsTokenUploaded(token)' and call 'trackPushNotificationTokenUploadSucceeded'", () => { + const backendAPI = jest.fn(); + const installation = { + id: "001abe9de70768541f2ad76d62636797f4f", + token: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad" + }; + testSaga(pushNotificationTokenUpload, backendAPI) + .next() + .call(awaitForPushNotificationToken) + .next(installation) + .call(backendAPI, { + installationID: installation.id, + body: { + platform: notificationsPlatform, + pushChannel: installation.token + } + }) + .next( + E.right({ + status: 200 + }) + ) + .put(pushNotificationsTokenUploaded(installation.token)) + .next() + .call(trackPushNotificationTokenUploadSucceeded) + .next() + .isDone(); + }); + it("when the push token is available and registered, it should call 'trackNotificationInstallationTokenNotChanged' and end", () => { + const backendAPI = jest.fn(); + const installation = { + id: "001abe9de70768541f2ad76d62636797f4f", + token: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad", + registeredToken: + "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad" + }; + testSaga(pushNotificationTokenUpload, backendAPI) + .next() + .call(awaitForPushNotificationToken) + .next(installation) + .call(trackNotificationInstallationTokenNotChanged) + .next() + .isDone(); + }); + it("when the push token is available and not yet registered, it should invoke the backend API but, upon response decoding failure, it should call 'trackPushNotificationTokenUploadFailure' and end", () => { + const backendAPI = jest.fn(); + const installation = { + id: "001abe9de70768541f2ad76d62636797f4f", + token: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad" + }; + testSaga(pushNotificationTokenUpload, backendAPI) + .next() + .call(awaitForPushNotificationToken) + .next(installation) + .call(backendAPI, { + installationID: installation.id, + body: { + platform: "apns", + pushChannel: installation.token + } + }) + .next(E.left({})) + .call( + trackPushNotificationTokenUploadFailure, + "TypeError: es.map is not a function" + ) + .next() + .isDone(); + }); + it("when the push token is available and not yet registered, it should invoke the backend API but, upon HTTP response code different than 200, it should call 'trackPushNotificationTokenUploadFailure' and end", () => { + const backendAPI = jest.fn(); + const installation = { + id: "001abe9de70768541f2ad76d62636797f4f", + token: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad" + }; + [ + 100, 101, 102, 103, 201, 202, 203, 204, 205, 206, 207, 208, 226, 300, 301, + 302, 303, 304, 305, 306, 307, 308, 400, 401, 402, 403, 404, 405, 406, 407, + 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, + 425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, + 510, 511 + ].forEach(httpStatusCode => + testSaga(pushNotificationTokenUpload, backendAPI) + .next() + .call(awaitForPushNotificationToken) + .next(installation) + .call(backendAPI, { + installationID: installation.id, + body: { + platform: "apns", + pushChannel: installation.token + } + }) + .next( + E.right({ + status: httpStatusCode + }) + ) + .call( + trackPushNotificationTokenUploadFailure, + `response status code ${httpStatusCode}` + ) + .next() + .isDone() + ); + }); + it("when the push token is available and not yet registered, it should invoke the backend API but, upon exception, it should call 'trackPushNotificationTokenUploadFailure' and end", () => { + const backendAPI = jest.fn(); + const installation = { + id: "001abe9de70768541f2ad76d62636797f4f", + token: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad" + }; + const error = new Error("An unknown error occourred"); + testSaga(pushNotificationTokenUpload, backendAPI) + .next() + .call(awaitForPushNotificationToken) + .next(installation) + .call(backendAPI, { + installationID: installation.id, + body: { + platform: "apns", + pushChannel: installation.token + } + }) + .throw(error) + .call(trackPushNotificationTokenUploadFailure, `${error}`) + .next() + .isDone(); + }); +}); + +describe("awaitForPushNotificationToken", () => { + it("should return the 'installation' instance when 'token' is defined", () => { + const installation: InstallationState = { + id: "001abe9de70768541f2ad76d62636797f4f", + token: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad" + }; + testSaga(awaitForPushNotificationToken) + .next() + .select(notificationsInstallationSelector) + .next(installation) + .returns(installation) + .next() + .isDone(); + }); + it("should wait for 'newPushNotificationToken' dispatch when no 'token' is available and return the new installation", () => { + const installationNoToken: InstallationState = { + id: "001plh9de70768541f2ad76d62636797f4f" + }; + const installationWithToken: InstallationState = { + id: "001oki9de70768541f2ad76d62636797f4f", + token: "740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad" + }; + testSaga(awaitForPushNotificationToken) + .next() + .select(notificationsInstallationSelector) + .next(installationNoToken) + .take(newPushNotificationsToken) + .next() + .select(notificationsInstallationSelector) + .next(installationWithToken) + .returns(installationWithToken) + .next() + .isDone(); + }); + it("should wait for 'newPushNotificationToken' dispatch when no 'token' is available and wait for it again if, for some reason, the token is still not available", () => { + const installationNoToken: InstallationState = { + id: "001okj9de70768541f2ad76d62636797f4f" + }; + testSaga(awaitForPushNotificationToken) + .next() + .select(notificationsInstallationSelector) + .next(installationNoToken) + .take(newPushNotificationsToken) + .next() + .select(notificationsInstallationSelector) + .next(installationNoToken) + .take(newPushNotificationsToken); + }); +}); diff --git a/ts/features/pushNotifications/sagas/common.ts b/ts/features/pushNotifications/sagas/common.ts new file mode 100644 index 00000000000..b7d8c17b6ad --- /dev/null +++ b/ts/features/pushNotifications/sagas/common.ts @@ -0,0 +1,113 @@ +import { identity, pipe } from "fp-ts/lib/function"; +import * as O from "fp-ts/lib/Option"; +import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import { call, put, select } from "typed-redux-saga/macro"; +import { areNotificationPermissionsEnabled } from "../store/reducers/permissions"; +import { updateSystemNotificationsEnabled } from "../store/actions/permissions"; +import { checkNotificationPermissions } from "../utils"; +import { + PendingMessageState, + pendingMessageStateSelector +} from "../store/reducers/pendingMessage"; +import { isPaymentOngoingSelector } from "../../../store/reducers/wallet/payment"; +import { clearNotificationPendingMessage } from "../store/actions/pendingMessage"; +import { navigateToMainNavigatorAction } from "../../../store/actions/navigation"; +import { isArchivingDisabledSelector } from "../../messages/store/reducers/archiving"; +import { resetMessageArchivingAction } from "../../messages/store/actions/archiving"; +import NavigationService from "../../../navigation/NavigationService"; +import { navigateToMessageRouterAction } from "../utils/navigation"; +import { UIMessageId } from "../../messages/types"; +import { trackMessageNotificationTap } from "../../messages/analytics"; + +export function* checkAndUpdateNotificationPermissionsIfNeeded() { + // Retrieve system notification receival permission + const systemNotificationPermissions = yield* call( + checkNotificationPermissions + ); + // Update the in-memory redux value if needed + yield* call( + updateNotificationPermissionsIfNeeded, + systemNotificationPermissions + ); + return systemNotificationPermissions; +} + +export function* updateNotificationPermissionsIfNeeded( + systemNotificationPermissions: boolean +) { + // Retrieve the in-memory redux value of the + // notification receival permission + const storedNotificationPermissions = yield* select( + areNotificationPermissionsEnabled + ); + // If it is different, compared to the input one + if (systemNotificationPermissions !== storedNotificationPermissions) { + // Update the in-memory redux value + yield* put(updateSystemNotificationsEnabled(systemNotificationPermissions)); + } +} + +export function* handlePendingMessageStateIfAllowed( + shouldResetToMainNavigator: boolean = false +) { + // Check if we have a pending notification message + const pendingMessageState = yield* select(pendingMessageStateSelector); + // It may be needed to track the push notification tap event (since mixpanel + // was not initialized at the moment where the notification came - e.g., when + // the application was killed and the push notification is tapped) + yield* call(trackMessageNotificationTapIfNeeded, pendingMessageState); + + // Check if there is a payment ongoing + const isPaymentOngoing = yield* select(isPaymentOngoingSelector); + + if (!isPaymentOngoing && pendingMessageState) { + // We have a pending notification message to handle + const messageId = pendingMessageState.id; + + // Remove the pending message from the notification state + yield* put(clearNotificationPendingMessage()); + + if (shouldResetToMainNavigator) { + yield* call(navigateToMainNavigatorAction); + } + + // It the archiving/restoring of messages is not disabled, make + // sure to cancel it, whatever status it may be in (since it + // hides the bottom tab bar and we cannot trigger a navigation + // flow that may later deliver the user back to a main tab bar + // screen where such tab bar is hidden) + const isArchivingDisabled = yield* select(isArchivingDisabledSelector); + if (!isArchivingDisabled) { + // Auto-reset does not provide feedback to the user + yield* put(resetMessageArchivingAction(undefined)); + } + + // Navigate to message router screen + yield* call( + NavigationService.dispatchNavigationAction, + navigateToMessageRouterAction({ + messageId: messageId as UIMessageId, + fromNotification: true + }) + ); + } +} + +export function trackMessageNotificationTapIfNeeded( + pendingMessageStateOpt?: PendingMessageState +) { + pipe( + pendingMessageStateOpt, + O.fromNullable, + O.chain(pendingMessageState => + pipe( + pendingMessageState.trackEvent, + O.fromNullable, + O.filter(identity), + O.map(_ => + trackMessageNotificationTap(pendingMessageState.id as NonEmptyString) + ) + ) + ) + ); +} diff --git a/ts/features/pushNotifications/sagas/notificationPermissionsListener.ts b/ts/features/pushNotifications/sagas/notificationPermissionsListener.ts new file mode 100644 index 00000000000..f3155f004ad --- /dev/null +++ b/ts/features/pushNotifications/sagas/notificationPermissionsListener.ts @@ -0,0 +1,28 @@ +import { call, takeLatest } from "typed-redux-saga/macro"; +import { ActionType } from "typesafe-actions"; +import { applicationChangeState } from "../../../store/actions/application"; +import { checkAndUpdateNotificationPermissionsIfNeeded } from "./common"; + +export function* notificationPermissionsListener() { + // Update the in-memory status (since it is not stored) + yield* call(checkAndUpdateNotificationPermissionsIfNeeded); + // Listen for application state changes in order to + // retrieve the system notification permission status + // when the application goes back into foreground + yield* takeLatest( + applicationChangeState, + checkNotificationPermissionsOnAppForegroundState + ); +} + +export function* checkNotificationPermissionsOnAppForegroundState( + applicationChangeStateAction: ActionType +) { + // If the application is now active in foreground + const currentAppState = applicationChangeStateAction.payload; + if (currentAppState === "active") { + // Check if the system notification permission has changed + // and update it into the in-memory status (if needed) + yield* call(checkAndUpdateNotificationPermissionsIfNeeded); + } +} diff --git a/ts/features/pushNotifications/sagas/notifications.ts b/ts/features/pushNotifications/sagas/notifications.ts deleted file mode 100644 index 9bfa9b1f773..00000000000 --- a/ts/features/pushNotifications/sagas/notifications.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * A saga to manage notifications - */ -import { readableReport } from "@pagopa/ts-commons/lib/reporters"; -import { identity, pipe } from "fp-ts/lib/function"; -import * as O from "fp-ts/lib/Option"; -import * as E from "fp-ts/lib/Either"; -import { Platform } from "react-native"; -import { SagaIterator } from "redux-saga"; -import { call, put, select } from "typed-redux-saga/macro"; -import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; -import { PlatformEnum } from "../../../../definitions/backend/Platform"; -import { BackendClient } from "../../../api/backend"; -import { notificationsInstallationSelector } from "../store/reducers/installation"; -import { SagaCallReturnType } from "../../../types/utils"; -import { convertUnknownToError } from "../../../utils/errors"; -import { - PendingMessageState, - pendingMessageStateSelector -} from "../store/reducers/pendingMessage"; -import { isPaymentOngoingSelector } from "../../../store/reducers/wallet/payment"; -import { navigateToMainNavigatorAction } from "../../../store/actions/navigation"; -import { navigateToMessageRouterAction } from "../utils/navigation"; -import NavigationService from "../../../navigation/NavigationService"; -import { UIMessageId } from "../../messages/types"; -import { trackMessageNotificationTap } from "../../messages/analytics"; -import { trackNotificationInstallationTokenNotChanged } from "../analytics"; -import { - clearNotificationPendingMessage, - notificationsInstallationTokenRegistered, - updateNotificationInstallationFailure -} from "../store/actions/notifications"; -import { isArchivingDisabledSelector } from "../../messages/store/reducers/archiving"; -import { resetMessageArchivingAction } from "../../messages/store/actions/archiving"; - -export const notificationsPlatform: PlatformEnum = - Platform.select({ - ios: PlatformEnum.apns, - android: PlatformEnum.gcm, - default: PlatformEnum.gcm - }); - -/** - * This generator function calls the ProxyApi `installation` endpoint - */ -export function* updateInstallationSaga( - createOrUpdateInstallation: ReturnType< - typeof BackendClient - >["createOrUpdateInstallation"] -): SagaIterator { - // Get the notifications installation data from the store - const notificationsInstallation: ReturnType< - typeof notificationsInstallationSelector - > = yield* select(notificationsInstallationSelector); - // Check if the notification server token is available (non available on iOS simulator) - if (notificationsInstallation.token === undefined) { - return undefined; - } - // Check if the notification token is changed from the one registered in the backend - if ( - notificationsInstallation.token === - notificationsInstallation.registeredToken - ) { - trackNotificationInstallationTokenNotChanged(); - return undefined; - } - try { - // Send the request to the backend - const response: SagaCallReturnType = - yield* call(createOrUpdateInstallation, { - installationID: notificationsInstallation.id, - body: { - platform: notificationsPlatform, - pushChannel: notificationsInstallation.token - } - }); - /** - * If the response isLeft (got an error) dispatch a failure action - */ - if (E.isLeft(response)) { - throw Error(readableReport(response.left)); - } - if (response.right.status === 200) { - yield* put( - notificationsInstallationTokenRegistered( - notificationsInstallation.token - ) - ); - } else { - yield* put( - updateNotificationInstallationFailure( - new Error(`response status code ${response.right.status}`) - ) - ); - } - } catch (e) { - yield* put(updateNotificationInstallationFailure(convertUnknownToError(e))); - } -} - -export function* handlePendingMessageStateIfAllowedSaga( - shouldResetToMainNavigator: boolean = false -) { - // Check if we have a pending notification message - const pendingMessageState: ReturnType = - yield* select(pendingMessageStateSelector); - // It may be needed to track the push notification tap event (since mixpanel - // was not initialized at the moment where the notification came - e.g., when - // the application was killed and the push notification is tapped) - yield* call(trackMessageNotificationTapIfNeeded, pendingMessageState); - - // Check if there is a payment ongoing - const isPaymentOngoing: ReturnType = - yield* select(isPaymentOngoingSelector); - - if (!isPaymentOngoing && pendingMessageState) { - // We have a pending notification message to handle - const messageId = pendingMessageState.id; - - // Remove the pending message from the notification state - yield* put(clearNotificationPendingMessage()); - - if (shouldResetToMainNavigator) { - yield* call(navigateToMainNavigatorAction); - } - - // It the archiving/restoring of messages is not disabled, make - // sure to cancel it, whatever status it may be in (since it - // hides the bottom tab bar and we cannot trigger a navigation - // flow that may later deliver the user back to a main tab bar - // screen where such tab bar is hidden) - const isArchivingDisabled = yield* select(isArchivingDisabledSelector); - if (!isArchivingDisabled) { - // Auto-reset does not provide feedback to the user - yield* put(resetMessageArchivingAction(undefined)); - } - - // Navigate to message router screen - yield* call( - NavigationService.dispatchNavigationAction, - navigateToMessageRouterAction({ - messageId: messageId as UIMessageId, - fromNotification: true - }) - ); - } -} - -export function trackMessageNotificationTapIfNeeded( - pendingMessageStateOpt?: PendingMessageState -) { - pipe( - pendingMessageStateOpt, - O.fromNullable, - O.chain(pendingMessageState => - pipe( - pendingMessageState.trackEvent, - O.fromNullable, - O.filter(identity), - O.map(_ => - trackMessageNotificationTap(pendingMessageState.id as NonEmptyString) - ) - ) - ) - ); -} diff --git a/ts/features/pushNotifications/sagas/checkNotificationsPreferencesSaga.ts b/ts/features/pushNotifications/sagas/profileAndSystemNotificationsPermissions.ts similarity index 79% rename from ts/features/pushNotifications/sagas/checkNotificationsPreferencesSaga.ts rename to ts/features/pushNotifications/sagas/profileAndSystemNotificationsPermissions.ts index be47582e899..22096ca959e 100644 --- a/ts/features/pushNotifications/sagas/checkNotificationsPreferencesSaga.ts +++ b/ts/features/pushNotifications/sagas/profileAndSystemNotificationsPermissions.ts @@ -6,21 +6,21 @@ import NavigationService from "../../../navigation/NavigationService"; import ROUTES from "../../../navigation/routes"; import { profileUpsert } from "../../../store/actions/profile"; import { isProfileFirstOnBoarding } from "../../../store/reducers/profile"; -import { - checkNotificationPermissions, - requestNotificationPermissions -} from "../utils"; +import { requestNotificationPermissions } from "../utils"; import { trackNotificationsOptInPreviewStatus, trackNotificationsOptInReminderStatus } from "../analytics"; -import { SagaCallReturnType } from "../../../types/utils"; -import { notificationsInfoScreenConsent } from "../store/actions/notifications"; import { updateMixpanelSuperProperties } from "../../../mixpanelConfig/superProperties"; import { GlobalState } from "../../../store/reducers/types"; import { updateMixpanelProfileProperties } from "../../../mixpanelConfig/profileProperties"; +import { notificationsInfoScreenConsent } from "../store/actions/profileNotificationPermissions"; +import { + checkAndUpdateNotificationPermissionsIfNeeded, + updateNotificationPermissionsIfNeeded +} from "./common"; -export function* checkNotificationsPreferencesSaga( +export function* profileAndSystemNotificationsPermissions( userProfile: InitializedProfile ) { const profileMissedPushSettings = @@ -55,16 +55,22 @@ export function* checkNotificationsPreferencesSaga( } } - // check if the user has given system notification permissions - const hasNotificationPermission: SagaCallReturnType< - typeof checkNotificationPermissions - > = yield* call(checkNotificationPermissions); + // Check if the user has given system notification permissions + // and update the in-memory redux value if needed + const hasNotificationPermission = yield* call( + checkAndUpdateNotificationPermissionsIfNeeded + ); if (!hasNotificationPermission) { - // Ask the user for notification permission - const userHasGivenNotificationPermission: SagaCallReturnType< - typeof requestNotificationPermissions - > = yield* call(requestNotificationPermissions); + // Ask the user for notification permission and update + // the in-memory redux value if needed + const userHasGivenNotificationPermission = yield* call( + requestNotificationPermissions + ); + yield* call( + updateNotificationPermissionsIfNeeded, + userHasGivenNotificationPermission + ); if (!userHasGivenNotificationPermission && profileMissedPushSettings) { // Show how to enable notification permission from the settings @@ -77,9 +83,7 @@ export function* checkNotificationsPreferencesSaga( }) ); - yield* take>( - notificationsInfoScreenConsent - ); + yield* take(notificationsInfoScreenConsent); // Make sure to dismiss the modal yield* call( diff --git a/ts/features/pushNotifications/sagas/pushNotificationTokenUpload.ts b/ts/features/pushNotifications/sagas/pushNotificationTokenUpload.ts new file mode 100644 index 00000000000..b6f2758a969 --- /dev/null +++ b/ts/features/pushNotifications/sagas/pushNotificationTokenUpload.ts @@ -0,0 +1,102 @@ +import { readableReport } from "@pagopa/ts-commons/lib/reporters"; +import * as E from "fp-ts/lib/Either"; +import { Platform } from "react-native"; +import { SagaIterator } from "redux-saga"; +import { call, put, select, take } from "typed-redux-saga/macro"; +import { PlatformEnum } from "../../../../definitions/backend/Platform"; +import { BackendClient } from "../../../api/backend"; +import { notificationsInstallationSelector } from "../store/reducers/installation"; +import { convertUnknownToError } from "../../../utils/errors"; +import { + trackNotificationInstallationTokenNotChanged, + trackPushNotificationTokenUploadFailure, + trackPushNotificationTokenUploadSucceeded +} from "../analytics"; +import { + newPushNotificationsToken, + pushNotificationsTokenUploaded +} from "../store/actions/installation"; + +export const notificationsPlatform: PlatformEnum = + Platform.select({ + ios: PlatformEnum.apns, + android: PlatformEnum.gcm, + default: PlatformEnum.gcm + }); + +export function* pushNotificationTokenUpload( + createOrUpdateInstallation: ReturnType< + typeof BackendClient + >["createOrUpdateInstallation"] +): SagaIterator { + // Await for a notification token, since it may not + // be available yet when this function is caled + const notificationsInstallation = yield* call(awaitForPushNotificationToken); + // Check if the notification token has changed + // from the one registered in the backend + if ( + notificationsInstallation.token === + notificationsInstallation.registeredToken + ) { + yield* call(trackNotificationInstallationTokenNotChanged); + return; + } + try { + // Send the token to the backend + const response = yield* call(createOrUpdateInstallation, { + installationID: notificationsInstallation.id, + body: { + platform: notificationsPlatform, + pushChannel: notificationsInstallation.token + } + }); + // Decoding failure + if (E.isLeft(response)) { + yield* call( + trackPushNotificationTokenUploadFailure, + readableReport(response.left) + ); + return; + } + // Unexpected response code + if (response.right.status !== 200) { + yield* call( + trackPushNotificationTokenUploadFailure, + `response status code ${response.right.status}` + ); + return; + } + + // Success + yield* put(pushNotificationsTokenUploaded(notificationsInstallation.token)); + yield* call(trackPushNotificationTokenUploadSucceeded); + } catch (e) { + // Unknwon error + yield* call( + trackPushNotificationTokenUploadFailure, + `${convertUnknownToError(e)}` + ); + } +} + +export function* awaitForPushNotificationToken() { + // When this function is called, the push notification token may + // not be available yet. In such case, the code will wait for + // 'newPushNotificationsToken' action, which is dispatched as + // soon as the token becomes available. A do-while loop is used + // to be extra sure that the token has been stored inside redux + do { + const notificationsInstallation = yield* select( + notificationsInstallationSelector + ); + if (notificationsInstallation.token) { + // The output object is re-created in order + // to have a non-optional 'token' instance + return { + ...notificationsInstallation, + token: notificationsInstallation.token + }; + } + yield* take(newPushNotificationsToken); + } while (true); +} diff --git a/ts/features/pushNotifications/screens/OnboardingNotificationsInfoScreenConsent.tsx b/ts/features/pushNotifications/screens/OnboardingNotificationsInfoScreenConsent.tsx index 23f151cf962..fc57afcd9e1 100644 --- a/ts/features/pushNotifications/screens/OnboardingNotificationsInfoScreenConsent.tsx +++ b/ts/features/pushNotifications/screens/OnboardingNotificationsInfoScreenConsent.tsx @@ -13,14 +13,16 @@ import { VSpacer } from "@pagopa/io-app-design-system"; import I18n from "../../../i18n"; -import { openAppSettings } from "../../../utils/appSettings"; import { useIODispatch, useIOSelector } from "../../../store/hooks"; -import { checkNotificationPermissions } from "../utils"; +import { + checkNotificationPermissions, + openSystemNotificationSettingsScreen +} from "../utils"; import { pushNotificationPreviewEnabledSelector, pushNotificationRemindersEnabledSelector } from "../../../store/reducers/profile"; -import { notificationsInfoScreenConsent } from "../store/actions/notifications"; +import { notificationsInfoScreenConsent } from "../store/actions/profileNotificationPermissions"; import { trackNotificationsOptInOpenSettings, trackNotificationsOptInReminderOnPermissionsOff, @@ -161,7 +163,7 @@ export const OnboardingNotificationsInfoScreenConsent = () => { const openSettings = useCallback(() => { trackNotificationsOptInOpenSettings(); - openAppSettings(); + openSystemNotificationSettingsScreen(); }, []); const ListHeader = ( @@ -203,7 +205,8 @@ export const OnboardingNotificationsInfoScreenConsent = () => { type: "Solid", buttonProps: { label: I18n.t("onboarding.infoConsent.openSettings"), - onPress: openSettings + onPress: openSettings, + testID: "settings-btn" } }} type="SingleButton" diff --git a/ts/features/pushNotifications/screens/__tests__/OnboardingNotificationsInfoScreenConsent.test.tsx b/ts/features/pushNotifications/screens/__tests__/OnboardingNotificationsInfoScreenConsent.test.tsx index 3f9c381e7b9..5f5cbcd8a4a 100644 --- a/ts/features/pushNotifications/screens/__tests__/OnboardingNotificationsInfoScreenConsent.test.tsx +++ b/ts/features/pushNotifications/screens/__tests__/OnboardingNotificationsInfoScreenConsent.test.tsx @@ -1,3 +1,4 @@ +import { constUndefined } from "fp-ts/lib/function"; import { createStore } from "redux"; import { AppState } from "react-native"; import { fireEvent, waitFor } from "@testing-library/react-native"; @@ -6,9 +7,10 @@ import { applicationChangeState } from "../../../../store/actions/application"; import { appReducer } from "../../../../store/reducers"; import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; import { OnboardingNotificationsInfoScreenConsent } from "../OnboardingNotificationsInfoScreenConsent"; -import * as notificationsActions from "../../store/actions/notifications"; +import * as profileNotificationPermissions from "../../store/actions/profileNotificationPermissions"; import * as notification from "../../utils"; import { preferencesDesignSystemSetEnabled } from "../../../../store/actions/persistedPreferences"; +import * as analytics from "../../analytics"; const checkNotificationPermissions = jest.spyOn( notification, @@ -16,14 +18,24 @@ const checkNotificationPermissions = jest.spyOn( ); const notificationsInfoScreenConsentSpy = jest.spyOn( - notificationsActions, + profileNotificationPermissions, "notificationsInfoScreenConsent" ); +const analyticsOpenSettingsSpy = jest + .spyOn(analytics, "trackNotificationsOptInOpenSettings") + .mockImplementation(constUndefined); + +const openSystemNotificationSettingsScreenSpy = jest + .spyOn(notification, "openSystemNotificationSettingsScreen") + .mockImplementation(constUndefined); + describe("OnboardingNotificationsInfoScreenConsent", () => { beforeEach(() => { checkNotificationPermissions.mockClear(); notificationsInfoScreenConsentSpy.mockClear(); + analyticsOpenSettingsSpy.mockClear(); + openSystemNotificationSettingsScreenSpy.mockClear(); }); it("Click on the button continue check that the NOTIFICATIONS_INFO_SCREEN_CONSENT action is triggered", () => { @@ -38,6 +50,19 @@ describe("OnboardingNotificationsInfoScreenConsent", () => { } }); + it("Settings button should be there and its tap should call 'trackNotificationsOptInOpenSettings' and 'openSystemNotificationSettingsScreen'", () => { + const screen = renderScreen(); + + const settingsButton = screen.queryByTestId("settings-btn"); + expect(settingsButton).not.toBeUndefined(); + + if (settingsButton) { + fireEvent(settingsButton, "onPress"); + expect(analyticsOpenSettingsSpy).toHaveBeenCalledTimes(1); + expect(openSystemNotificationSettingsScreenSpy).toHaveBeenCalledTimes(1); + } + }); + it("If AppState is active and permissions true trigger NOTIFICATIONS_INFO_SCREEN_CONSENT action", async () => { checkNotificationPermissions.mockImplementation(() => Promise.resolve(true) diff --git a/ts/features/pushNotifications/screens/__tests__/__snapshots__/OnboardingNotificationsInfoScreenConsent.test.tsx.snap b/ts/features/pushNotifications/screens/__tests__/__snapshots__/OnboardingNotificationsInfoScreenConsent.test.tsx.snap index 68440983a92..981e00e9b20 100644 --- a/ts/features/pushNotifications/screens/__tests__/__snapshots__/OnboardingNotificationsInfoScreenConsent.test.tsx.snap +++ b/ts/features/pushNotifications/screens/__tests__/__snapshots__/OnboardingNotificationsInfoScreenConsent.test.tsx.snap @@ -280,7 +280,7 @@ exports[`OnboardingNotificationsInfoScreenConsent should match snapshot 1`] = ` > { + it("should have 'NOTIFICATIONS_INSTALLATION_TOKEN_UPDATE' as type and the specified payload", () => { + const payload = "The Payload"; + const action = newPushNotificationsToken(payload); + expect(action.type).toBe("NOTIFICATIONS_INSTALLATION_TOKEN_UPDATE"); + expect(action.payload).toBe(payload); + }); +}); + +describe("pushNotificationsTokenUploaded", () => { + it("should have 'NOTIFICATIONS_INSTALLATION_TOKEN_REGISTERED' as type and the specified payload", () => { + const payload = "The Payload"; + const action = pushNotificationsTokenUploaded(payload); + expect(action.type).toBe("NOTIFICATIONS_INSTALLATION_TOKEN_REGISTERED"); + expect(action.payload).toBe(payload); + }); +}); diff --git a/ts/features/pushNotifications/store/actions/__tests__/pendingMessage.test.ts b/ts/features/pushNotifications/store/actions/__tests__/pendingMessage.test.ts new file mode 100644 index 00000000000..7cdb00c2fe2 --- /dev/null +++ b/ts/features/pushNotifications/store/actions/__tests__/pendingMessage.test.ts @@ -0,0 +1,26 @@ +import { PendingMessageState } from "../../reducers/pendingMessage"; +import { + clearNotificationPendingMessage, + updateNotificationsPendingMessage +} from "../pendingMessage"; + +describe("updateNotificationsPendingMessage", () => { + it("should have 'NOTIFICATIONS_PENDING_MESSAGE_UPDATE' as type and given payload", () => { + const pendingMessageState: PendingMessageState = { + foreground: true, + id: "01J8R2C1Q3ZXE5BX7XHSTTN493", + trackEvent: true + }; + const action = updateNotificationsPendingMessage(pendingMessageState); + expect(action.type).toBe("NOTIFICATIONS_PENDING_MESSAGE_UPDATE"); + expect(action.payload).toBe(pendingMessageState); + }); +}); + +describe("clearNotificationPendingMessage", () => { + it("should have 'NOTIFICATIONS_PENDING_MESSAGE_CLEAR' as type and no payload", () => { + const action = clearNotificationPendingMessage(); + expect(action.type).toBe("NOTIFICATIONS_PENDING_MESSAGE_CLEAR"); + expect(action.payload).toBeUndefined(); + }); +}); diff --git a/ts/features/pushNotifications/store/actions/__tests__/permissions.test.ts b/ts/features/pushNotifications/store/actions/__tests__/permissions.test.ts new file mode 100644 index 00000000000..cc157f6a893 --- /dev/null +++ b/ts/features/pushNotifications/store/actions/__tests__/permissions.test.ts @@ -0,0 +1,14 @@ +import { updateSystemNotificationsEnabled } from "../permissions"; + +describe("updateSystemNotificationsEnabled", () => { + it("should have 'UPDATE_SYSTEM_NOTIFICATIONS_ENABLED' as type and 'true' payload", () => { + const action = updateSystemNotificationsEnabled(true); + expect(action.type).toBe("UPDATE_SYSTEM_NOTIFICATIONS_ENABLED"); + expect(action.payload).toBe(true); + }); + it("should have 'UPDATE_SYSTEM_NOTIFICATIONS_ENABLED' as type and 'false' payload", () => { + const action = updateSystemNotificationsEnabled(false); + expect(action.type).toBe("UPDATE_SYSTEM_NOTIFICATIONS_ENABLED"); + expect(action.payload).toBe(false); + }); +}); diff --git a/ts/features/pushNotifications/store/actions/__tests__/profileNotificationPermissions.test.ts b/ts/features/pushNotifications/store/actions/__tests__/profileNotificationPermissions.test.ts new file mode 100644 index 00000000000..360999f2e29 --- /dev/null +++ b/ts/features/pushNotifications/store/actions/__tests__/profileNotificationPermissions.test.ts @@ -0,0 +1,9 @@ +import { notificationsInfoScreenConsent } from "../profileNotificationPermissions"; + +describe("notificationsInfoScreenConsent", () => { + it("should have 'NOTIFICATIONS_INFO_SCREEN_CONSENT' as type and no payload", () => { + const action = notificationsInfoScreenConsent(); + expect(action.type).toBe("NOTIFICATIONS_INFO_SCREEN_CONSENT"); + expect(action.payload).toBeUndefined(); + }); +}); diff --git a/ts/features/pushNotifications/store/actions/installation.ts b/ts/features/pushNotifications/store/actions/installation.ts new file mode 100644 index 00000000000..e4bf4211b0f --- /dev/null +++ b/ts/features/pushNotifications/store/actions/installation.ts @@ -0,0 +1,14 @@ +import { ActionType, createStandardAction } from "typesafe-actions"; + +export const newPushNotificationsToken = createStandardAction( + "NOTIFICATIONS_INSTALLATION_TOKEN_UPDATE" +)(); + +// the notification token is registered in the backend +export const pushNotificationsTokenUploaded = createStandardAction( + "NOTIFICATIONS_INSTALLATION_TOKEN_REGISTERED" +)(); + +export type NotificationsActions = + | ActionType + | ActionType; diff --git a/ts/features/pushNotifications/store/actions/notifications.ts b/ts/features/pushNotifications/store/actions/notifications.ts deleted file mode 100644 index bb80c20123f..00000000000 --- a/ts/features/pushNotifications/store/actions/notifications.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Action types and action creator related to the Notifications. - */ - -import { ActionType, createStandardAction } from "typesafe-actions"; -import { PendingMessageState } from "../reducers/pendingMessage"; - -export const updateNotificationsInstallationToken = createStandardAction( - "NOTIFICATIONS_INSTALLATION_TOKEN_UPDATE" -)(); - -// the notification token is registered in the backend -export const notificationsInstallationTokenRegistered = createStandardAction( - "NOTIFICATIONS_INSTALLATION_TOKEN_REGISTERED" -)(); - -export const updateNotificationInstallationFailure = createStandardAction( - "NOTIFICATIONS_INSTALLATION_UPDATE_FAILURE" -)(); - -export const updateNotificationsPendingMessage = createStandardAction( - "NOTIFICATIONS_PENDING_MESSAGE_UPDATE" -)(); - -export const clearNotificationPendingMessage = createStandardAction( - "NOTIFICATIONS_PENDING_MESSAGE_CLEAR" -)(); - -export const notificationsInfoScreenConsent = createStandardAction( - "NOTIFICATIONS_INFO_SCREEN_CONSENT" -)(); - -export type NotificationsActions = - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType - | ActionType; diff --git a/ts/features/pushNotifications/store/actions/pendingMessage.ts b/ts/features/pushNotifications/store/actions/pendingMessage.ts new file mode 100644 index 00000000000..13555506cda --- /dev/null +++ b/ts/features/pushNotifications/store/actions/pendingMessage.ts @@ -0,0 +1,14 @@ +import { ActionType, createStandardAction } from "typesafe-actions"; +import { PendingMessageState } from "../reducers/pendingMessage"; + +export const updateNotificationsPendingMessage = createStandardAction( + "NOTIFICATIONS_PENDING_MESSAGE_UPDATE" +)(); + +export const clearNotificationPendingMessage = createStandardAction( + "NOTIFICATIONS_PENDING_MESSAGE_CLEAR" +)(); + +export type PendingMessageActions = + | ActionType + | ActionType; diff --git a/ts/features/pushNotifications/store/actions/permissions.ts b/ts/features/pushNotifications/store/actions/permissions.ts new file mode 100644 index 00000000000..9e54a37e232 --- /dev/null +++ b/ts/features/pushNotifications/store/actions/permissions.ts @@ -0,0 +1,9 @@ +import { ActionType, createStandardAction } from "typesafe-actions"; + +export const updateSystemNotificationsEnabled = createStandardAction( + "UPDATE_SYSTEM_NOTIFICATIONS_ENABLED" +)(); + +export type NotificationPermissionsActions = ActionType< + typeof updateSystemNotificationsEnabled +>; diff --git a/ts/features/pushNotifications/store/actions/profileNotificationPermissions.ts b/ts/features/pushNotifications/store/actions/profileNotificationPermissions.ts new file mode 100644 index 00000000000..ca663feaa26 --- /dev/null +++ b/ts/features/pushNotifications/store/actions/profileNotificationPermissions.ts @@ -0,0 +1,9 @@ +import { ActionType, createStandardAction } from "typesafe-actions"; + +export const notificationsInfoScreenConsent = createStandardAction( + "NOTIFICATIONS_INFO_SCREEN_CONSENT" +)(); + +export type ProfileNotificationPermissionsActions = ActionType< + typeof notificationsInfoScreenConsent +>; diff --git a/ts/features/pushNotifications/store/reducers/__tests__/__snapshots__/index.test.ts.snap b/ts/features/pushNotifications/store/reducers/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000000..eac755d4e37 --- /dev/null +++ b/ts/features/pushNotifications/store/reducers/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Main pushNotifications reducer notificationsReducer initial state should match snapshot 1`] = ` +{ + "installation": { + "registeredToken": undefined, + "token": undefined, + }, + "pendingMessage": null, + "permissions": { + "systemNotificationsEnabled": false, + }, +} +`; + +exports[`Main pushNotifications reducer persistedNotificationsReducer initial state should match snapshot 1`] = ` +{ + "installation": { + "registeredToken": undefined, + "token": undefined, + }, + "pendingMessage": null, + "permissions": { + "systemNotificationsEnabled": false, + }, +} +`; diff --git a/ts/features/pushNotifications/store/reducers/__tests__/__snapshots__/permissions.test.ts.snap b/ts/features/pushNotifications/store/reducers/__tests__/__snapshots__/permissions.test.ts.snap new file mode 100644 index 00000000000..edc6e593023 --- /dev/null +++ b/ts/features/pushNotifications/store/reducers/__tests__/__snapshots__/permissions.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`permissions reducer initial state should match expected values 1`] = ` +{ + "systemNotificationsEnabled": false, +} +`; diff --git a/ts/features/pushNotifications/store/reducers/__tests__/index.test.ts b/ts/features/pushNotifications/store/reducers/__tests__/index.test.ts new file mode 100644 index 00000000000..62429f67d44 --- /dev/null +++ b/ts/features/pushNotifications/store/reducers/__tests__/index.test.ts @@ -0,0 +1,42 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { omit } from "lodash"; +import { + NOTIFICATIONS_STORE_VERSION, + notificationsPersistConfig, + notificationsReducer, + persistedNotificationsReducer +} from ".."; +import { applicationChangeState } from "../../../../../store/actions/application"; + +describe("Main pushNotifications reducer", () => { + it("persistor version should be -1", () => { + expect(NOTIFICATIONS_STORE_VERSION).toBe(-1); + }); + it("notificationsPersistConfig should match expected values", () => { + expect(notificationsPersistConfig.key).toBe("notifications"); + expect(notificationsPersistConfig.storage).toBe(AsyncStorage); + expect(notificationsPersistConfig.version).toBe( + NOTIFICATIONS_STORE_VERSION + ); + expect(notificationsPersistConfig.whitelist).toStrictEqual([ + "installation", + "pendingMessage" + ]); + }); + it("notificationsReducer initial state should match snapshot", () => { + const state = notificationsReducer( + undefined, + applicationChangeState("active") + ); + const stateWithoutInstallationId = omit(state, "installation.id"); + expect(stateWithoutInstallationId).toMatchSnapshot(); + }); + it("persistedNotificationsReducer initial state should match snapshot", () => { + const state = persistedNotificationsReducer( + undefined, + applicationChangeState("active") + ); + const stateWithoutInstallationId = omit(state, "installation.id"); + expect(stateWithoutInstallationId).toMatchSnapshot(); + }); +}); diff --git a/ts/features/pushNotifications/store/reducers/__tests__/permissions.test.ts b/ts/features/pushNotifications/store/reducers/__tests__/permissions.test.ts new file mode 100644 index 00000000000..8db968f6d15 --- /dev/null +++ b/ts/features/pushNotifications/store/reducers/__tests__/permissions.test.ts @@ -0,0 +1,65 @@ +import { applicationChangeState } from "../../../../../store/actions/application"; +import { GlobalState } from "../../../../../store/reducers/types"; +import { updateSystemNotificationsEnabled } from "../../actions/permissions"; +import { + areNotificationPermissionsEnabled, + INITIAL_STATE, + permissionsReducer +} from "../permissions"; + +describe("permissions reducer initial state", () => { + it("should match expected values", () => { + expect(INITIAL_STATE).toMatchSnapshot(); + }); +}); + +describe("permissionsReducer", () => { + it("output state should match INITIAL_STATE for an unrelated action", () => { + const state = permissionsReducer( + undefined, + applicationChangeState("active") + ); + expect(state).toStrictEqual(INITIAL_STATE); + }); + it("'systemNotificationsEnabled' in output state should be 'true' after receiving 'updateSystemNotificationsEnabled(true)'", () => { + const state = permissionsReducer( + undefined, + updateSystemNotificationsEnabled(true) + ); + expect(state.systemNotificationsEnabled).toBe(true); + }); + it("'systemNotificationsEnabled' in output state should be 'false' after receiving 'updateSystemNotificationsEnabled(false)'", () => { + const state = permissionsReducer( + { systemNotificationsEnabled: true }, + updateSystemNotificationsEnabled(false) + ); + expect(state.systemNotificationsEnabled).toBe(false); + }); +}); + +describe("areNotificationPermissionsEnabled selector", () => { + it("should output 'true' when 'systemNotificationsEnabled' is 'true'", () => { + const state = { + notifications: { + permissions: { + systemNotificationsEnabled: true + } + } + } as GlobalState; + const notificationPermissionsEnabled = + areNotificationPermissionsEnabled(state); + expect(notificationPermissionsEnabled).toBe(true); + }); + it("should output 'false' when 'systemNotificationsEnabled' is 'false'", () => { + const state = { + notifications: { + permissions: { + systemNotificationsEnabled: false + } + } + } as GlobalState; + const notificationPermissionsEnabled = + areNotificationPermissionsEnabled(state); + expect(notificationPermissionsEnabled).toBe(false); + }); +}); diff --git a/ts/features/pushNotifications/store/reducers/index.ts b/ts/features/pushNotifications/store/reducers/index.ts index b8fc94c5638..81164692eea 100644 --- a/ts/features/pushNotifications/store/reducers/index.ts +++ b/ts/features/pushNotifications/store/reducers/index.ts @@ -3,18 +3,42 @@ */ import { combineReducers } from "redux"; +import { PersistConfig, PersistPartial, persistReducer } from "redux-persist"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { Action } from "../../../../store/actions/types"; import { InstallationState, installationReducer } from "./installation"; import { PendingMessageState, pendingMessageReducer } from "./pendingMessage"; +import { + NotificationsPermissionsState, + permissionsReducer +} from "./permissions"; + +export const NOTIFICATIONS_STORE_VERSION = -1; + +export type PersistedNotificationsState = NotificationsState & PersistPartial; export type NotificationsState = { installation: InstallationState; pendingMessage: PendingMessageState; + permissions: NotificationsPermissionsState; +}; + +export const notificationsPersistConfig: PersistConfig = { + key: "notifications", + storage: AsyncStorage, + version: NOTIFICATIONS_STORE_VERSION, + whitelist: ["installation", "pendingMessage"] }; export const notificationsReducer = combineReducers( { installation: installationReducer, - pendingMessage: pendingMessageReducer + pendingMessage: pendingMessageReducer, + permissions: permissionsReducer } ); + +export const persistedNotificationsReducer = persistReducer< + NotificationsState, + Action +>(notificationsPersistConfig, notificationsReducer); diff --git a/ts/features/pushNotifications/store/reducers/installation.ts b/ts/features/pushNotifications/store/reducers/installation.ts index 92e5bf7f732..4052a530bc2 100644 --- a/ts/features/pushNotifications/store/reducers/installation.ts +++ b/ts/features/pushNotifications/store/reducers/installation.ts @@ -7,11 +7,11 @@ import { sessionInvalid } from "../../../../store/actions/authentication"; import { clearCache } from "../../../../store/actions/profile"; -import { - notificationsInstallationTokenRegistered, - updateNotificationsInstallationToken -} from "../actions/notifications"; import { generateInstallationId } from "../../utils"; +import { + newPushNotificationsToken, + pushNotificationsTokenUploaded +} from "../actions/installation"; export type InstallationState = Readonly<{ id: string; @@ -34,9 +34,9 @@ export const installationReducer = ( action: Action ): InstallationState => { switch (action.type) { - case getType(updateNotificationsInstallationToken): + case getType(newPushNotificationsToken): return { ...state, token: action.payload }; - case getType(notificationsInstallationTokenRegistered): + case getType(pushNotificationsTokenUploaded): return { ...state, registeredToken: action.payload }; // clear registeredToken when the authentication is not longer valid // IO backend will automatically delete it on the next user login diff --git a/ts/features/pushNotifications/store/reducers/pendingMessage.ts b/ts/features/pushNotifications/store/reducers/pendingMessage.ts index 80689cb9527..0903e63c949 100644 --- a/ts/features/pushNotifications/store/reducers/pendingMessage.ts +++ b/ts/features/pushNotifications/store/reducers/pendingMessage.ts @@ -8,7 +8,7 @@ import { GlobalState } from "../../../../store/reducers/types"; import { clearNotificationPendingMessage, updateNotificationsPendingMessage -} from "../actions/notifications"; +} from "../actions/pendingMessage"; export type PendingMessageState = Readonly<{ id: string; @@ -25,10 +25,8 @@ export const pendingMessageReducer = ( switch (action.type) { case getType(updateNotificationsPendingMessage): return action.payload; - case getType(clearNotificationPendingMessage): return INITIAL_STATE; - default: return state; } diff --git a/ts/features/pushNotifications/store/reducers/permissions.ts b/ts/features/pushNotifications/store/reducers/permissions.ts new file mode 100644 index 00000000000..25c5a469126 --- /dev/null +++ b/ts/features/pushNotifications/store/reducers/permissions.ts @@ -0,0 +1,26 @@ +import { getType } from "typesafe-actions"; +import { Action } from "../../../../store/actions/types"; +import { updateSystemNotificationsEnabled } from "../actions/permissions"; +import { GlobalState } from "../../../../store/reducers/types"; + +export type NotificationsPermissionsState = { + systemNotificationsEnabled: boolean; +}; + +export const INITIAL_STATE = { + systemNotificationsEnabled: false +}; + +export const permissionsReducer = ( + state: NotificationsPermissionsState = INITIAL_STATE, + action: Action +): NotificationsPermissionsState => { + switch (action.type) { + case getType(updateSystemNotificationsEnabled): + return { ...state, systemNotificationsEnabled: action.payload }; + } + return state; +}; + +export const areNotificationPermissionsEnabled = (state: GlobalState) => + state.notifications.permissions.systemNotificationsEnabled; diff --git a/ts/features/pushNotifications/utils/__tests__/configurePushNotification.test.ts b/ts/features/pushNotifications/utils/__tests__/configurePushNotification.test.ts new file mode 100644 index 00000000000..3bf7b97ae95 --- /dev/null +++ b/ts/features/pushNotifications/utils/__tests__/configurePushNotification.test.ts @@ -0,0 +1,45 @@ +import { constNull, constUndefined } from "fp-ts/lib/function"; +import PushNotification from "react-native-push-notification"; +import { Platform } from "react-native"; +import configurePushNotifications from "../configurePushNotification"; + +jest.mock("../../../../boot/configureStoreAndPersistor", () => ({ + get store() { + return { + dispatch: jest.fn() + }; + } +})); + +describe("configurePushNotifications", () => { + it("should initialize the 'PushNotification' library with proper parameters and callbacks", () => { + const createChannelSpy = jest + .spyOn(PushNotification, "createChannel") + .mockImplementation(constUndefined); + const configureSpy = jest + .spyOn(PushNotification, "configure") + .mockImplementation(constUndefined); + + configurePushNotifications(); + + expect(createChannelSpy.mock.calls.length).toBe(1); + expect(createChannelSpy.mock.calls[0][0]).toEqual({ + channelId: "io_default_notification_channel", + channelName: "IO default notification channel", + playSound: true, + soundName: "default", + importance: 4, + vibrate: true + }); + expect(createChannelSpy.mock.calls[0][1]).toEqual(constNull); + expect(configureSpy.mock.calls.length).toBe(1); + const pushNotificationOptions = configureSpy.mock.calls[0][0]; + expect(pushNotificationOptions.onRegister).toBeDefined(); + expect(typeof pushNotificationOptions.onRegister).toBe("function"); + expect(pushNotificationOptions.onNotification).toBeDefined(); + expect(typeof pushNotificationOptions.onNotification).toBe("function"); + expect(pushNotificationOptions.requestPermissions).toEqual( + Platform.OS !== "ios" + ); + }); +}); diff --git a/ts/features/pushNotifications/utils/__tests__/index.test.ts b/ts/features/pushNotifications/utils/__tests__/index.test.ts new file mode 100644 index 00000000000..111b38e3d24 --- /dev/null +++ b/ts/features/pushNotifications/utils/__tests__/index.test.ts @@ -0,0 +1,13 @@ +import NotificationsUtils from "react-native-notifications-utils"; +import { openSystemNotificationSettingsScreen } from ".."; + +describe("openSystemNotificationSettingsScreen", () => { + it("should call NotificationsUtils.openSettings with proper parameters", () => { + const openSettingsSpy = jest + .spyOn(NotificationsUtils, "openSettings") + .mockImplementation(_channelId => undefined); + openSystemNotificationSettingsScreen(); + expect(openSettingsSpy.mock.calls.length).toBe(1); + expect(openSettingsSpy.mock.calls[0].length).toBe(0); + }); +}); diff --git a/ts/features/pushNotifications/utils/configurePushNotification.ts b/ts/features/pushNotifications/utils/configurePushNotification.ts index b7fd8412de4..2356710b8e3 100644 --- a/ts/features/pushNotifications/utils/configurePushNotification.ts +++ b/ts/features/pushNotifications/utils/configurePushNotification.ts @@ -1,6 +1,3 @@ -/** - * Set the basic PushNotification configuration - */ import PushNotificationIOS from "@react-native-community/push-notification-ios"; import { constNull, pipe } from "fp-ts/lib/function"; import * as B from "fp-ts/lib/boolean"; @@ -11,7 +8,6 @@ import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; import { Platform } from "react-native"; import PushNotification from "react-native-push-notification"; import * as pot from "@pagopa/ts-commons/lib/pot"; - import { maximumItemsFromAPI, pageSize } from "../../../config"; import { loadPreviousPageMessages, @@ -23,13 +19,12 @@ import { trackMessageNotificationTap } from "../../messages/analytics"; import { store } from "../../../boot/configureStoreAndPersistor"; -import { - updateNotificationsInstallationToken, - updateNotificationsPendingMessage -} from "../store/actions/notifications"; +import { newPushNotificationsToken } from "../store/actions/installation"; +import { updateNotificationsPendingMessage } from "../store/actions/pendingMessage"; import { isLoadingOrUpdating } from "../../../utils/pot"; import { isArchivingInProcessingModeSelector } from "../../messages/store/reducers/archiving"; import { GlobalState } from "../../../store/reducers/types"; +import { trackNewPushNotificationsTokenGenerated } from "../analytics"; /** * Helper type used to validate the notification payload. @@ -107,7 +102,8 @@ function configurePushNotifications() { // Called when token is generated onRegister: token => { // Dispatch an action to save the token in the store - store.dispatch(updateNotificationsInstallationToken(token.token)); + store.dispatch(newPushNotificationsToken(token.token)); + trackNewPushNotificationsTokenGenerated(); }, // Called when a remote or local notification is opened or received diff --git a/ts/features/pushNotifications/utils/index.ts b/ts/features/pushNotifications/utils/index.ts index 98728c58d8a..ed18e7f4396 100644 --- a/ts/features/pushNotifications/utils/index.ts +++ b/ts/features/pushNotifications/utils/index.ts @@ -7,11 +7,9 @@ import { v4 as uuid } from "uuid"; import { Platform, PermissionsAndroid } from "react-native"; import PushNotificationIOS from "@react-native-community/push-notification-ios"; import PushNotification from "react-native-push-notification"; +import NotificationsUtils from "react-native-notifications-utils"; import { isIos } from "../../../utils/platform"; -// DO NOT CHANGE THIS UNLESS YOU KNOW WHAT YOU ARE DOING -const UUID_VERSION_PREFIX = "001"; - export enum AuthorizationStatus { NotDetermined = 0, StatusDenied = 1, @@ -121,14 +119,14 @@ export const cancellAllLocalNotifications = () => PushNotification.cancelAllLocalNotifications(); /** - * Generates a new random installation ID with the following format: - * - * - * - * Where: - * VERSION_PREFIX is \d{3} - * UUID is [a-z0-9]{32} + * This is a legacy code that was used to generate a unique Id + * from client side. It is still used because the backend API + * requires it as part of the URL's path but it is later not + * used in any way. + * When the backend API spec will remove it, it can also be + * unlinked and deleted here */ -export function generateInstallationId(): string { - return `${UUID_VERSION_PREFIX}${uuid().replace(/-/g, "")}`; -} +export const generateInstallationId = () => `001${uuid().replace(/-/g, "")}`; + +export const openSystemNotificationSettingsScreen = () => + NotificationsUtils.openSettings(); diff --git a/ts/sagas/__tests__/initializeApplicationSaga.test.ts b/ts/sagas/__tests__/initializeApplicationSaga.test.ts index 3767c216713..07a9e9c3961 100644 --- a/ts/sagas/__tests__/initializeApplicationSaga.test.ts +++ b/ts/sagas/__tests__/initializeApplicationSaga.test.ts @@ -43,6 +43,7 @@ import { cancellAllLocalNotifications } from "../../features/pushNotifications/u import { handleApplicationStartupTransientError } from "../../features/startup/sagas"; import { startupTransientErrorInitialState } from "../../store/reducers/startup"; import { isBlockingScreenSelector } from "../../features/ingress/store/selectors"; +import { notificationPermissionsListener } from "../../features/pushNotifications/sagas/notificationPermissionsListener"; const aSessionToken = "a_session_token" as SessionToken; @@ -84,6 +85,8 @@ describe("initializeApplicationSaga", () => { .next() .next() .next() + .fork(notificationPermissionsListener) + .next() .select(profileSelector) .next(pot.some(profile)) .fork(watchProfileEmailValidationChangedSaga, O.none) @@ -135,6 +138,8 @@ describe("initializeApplicationSaga", () => { .next() .next() .next() + .fork(notificationPermissionsListener) + .next() .select(profileSelector) .next(pot.some(profile)) .fork(watchProfileEmailValidationChangedSaga, O.none) @@ -179,6 +184,8 @@ describe("initializeApplicationSaga", () => { .next() .next() .next() + .fork(notificationPermissionsListener) + .next() .select(profileSelector) .next(pot.some(profile)) .fork(watchProfileEmailValidationChangedSaga, O.none) @@ -228,6 +235,8 @@ describe("initializeApplicationSaga", () => { .next() .next() .next() + .fork(notificationPermissionsListener) + .next() .select(profileSelector) .next(pot.some(profile)) .fork(watchProfileEmailValidationChangedSaga, O.none) @@ -289,6 +298,8 @@ describe("initializeApplicationSaga", () => { .next() .next() .next() + .fork(notificationPermissionsListener) + .next() .select(profileSelector) .next(pot.some(profile)) .fork(watchProfileEmailValidationChangedSaga, O.none) @@ -339,6 +350,8 @@ describe("initializeApplicationSaga", () => { .next() .next() .next() + .fork(notificationPermissionsListener) + .next() .select(profileSelector) .next(pot.some(profile)) .fork(watchProfileEmailValidationChangedSaga, O.none) diff --git a/ts/sagas/identification.ts b/ts/sagas/identification.ts index 12d7affb68e..79069fcc45c 100644 --- a/ts/sagas/identification.ts +++ b/ts/sagas/identification.ts @@ -29,7 +29,7 @@ import { paymentsCurrentStateSelector } from "../store/reducers/payments/current import { PinString } from "../types/PinString"; import { ReduxSagaEffect, SagaCallReturnType } from "../types/utils"; import { deletePin, getPin } from "../utils/keychain"; -import { handlePendingMessageStateIfAllowedSaga } from "../features/pushNotifications/sagas/notifications"; +import { handlePendingMessageStateIfAllowed } from "../features/pushNotifications/sagas/common"; import { isFastLoginEnabledSelector } from "./../features/fastLogin/store/selectors/index"; type ResultAction = @@ -158,7 +158,7 @@ function* startAndHandleIdentificationResult( yield* put(startApplicationInitialization()); } else if (identificationResult === IdentificationResult.success) { // Check if we have a pending notification message - yield* call(handlePendingMessageStateIfAllowedSaga); + yield* call(handlePendingMessageStateIfAllowed); } } diff --git a/ts/sagas/startup.ts b/ts/sagas/startup.ts index 58c5e4840a2..21d0bca3efb 100644 --- a/ts/sagas/startup.ts +++ b/ts/sagas/startup.ts @@ -108,11 +108,10 @@ import { watchWalletSaga as watchNewWalletSaga } from "../features/newWallet/sag import { watchServicesSaga } from "../features/services/common/saga"; import { watchItwSaga } from "../features/itwallet/common/saga"; import { watchTrialSystemSaga } from "../features/trialSystem/store/sagas/watchTrialSystemSaga"; -import { - handlePendingMessageStateIfAllowedSaga, - updateInstallationSaga -} from "../features/pushNotifications/sagas/notifications"; -import { checkNotificationsPreferencesSaga } from "../features/pushNotifications/sagas/checkNotificationsPreferencesSaga"; +import { notificationPermissionsListener } from "../features/pushNotifications/sagas/notificationPermissionsListener"; +import { profileAndSystemNotificationsPermissions } from "../features/pushNotifications/sagas/profileAndSystemNotificationsPermissions"; +import { pushNotificationTokenUpload } from "../features/pushNotifications/sagas/pushNotificationTokenUpload"; +import { handlePendingMessageStateIfAllowed } from "../features/pushNotifications/sagas/common"; import { cancellAllLocalNotifications } from "../features/pushNotifications/utils"; import { handleApplicationStartupTransientError } from "../features/startup/sagas"; import { isBlockingScreenSelector } from "../features/ingress/store/selectors"; @@ -209,6 +208,9 @@ export function* initializeApplicationSaga( // clear cached downloads when the logged user changes yield* takeEvery(differentProfileLoggedIn, handleClearAllAttachments); + // Retrieve and listen for notification permissions status changes + yield* fork(notificationPermissionsListener); + // Get last logged in Profile from the state const lastLoggedInProfileState: ReturnType = yield* select(profileSelector); @@ -513,8 +515,9 @@ export function* initializeApplicationSaga( userProfile = (yield* call(checkEmailSaga)) ?? userProfile; - // check if the user must set preferences for push notifications (e.g. reminders) - yield* call(checkNotificationsPreferencesSaga, userProfile); + // Check for both profile notifications permissions (anonymous + // content && reminder) and system notifications permissions. + yield* call(profileAndSystemNotificationsPermissions, userProfile); const isFirstOnboarding = isProfileFirstOnBoarding(userProfile); yield* call(askServicesPreferencesModeOptin, isFirstOnboarding); @@ -527,9 +530,14 @@ export function* initializeApplicationSaga( // Stop the watchAbortOnboardingSaga yield* cancel(watchAbortOnboardingSagaTask); - // Start the notification installation update as early as - // possible to begin receiving push notifications - yield* call(updateInstallationSaga, backendClient.createOrUpdateInstallation); + // Fork the saga that uploads the push notification token to the backend. + // At this moment, the push notification token may not be available yet but + // the saga handles it internally. Make sure to fork it and not call it using + // a blocking call, since the saga will just hang, waiting for the token + yield* fork( + pushNotificationTokenUpload, + backendClient.createOrUpdateInstallation + ); // This saga is called before the startup status is set to authenticated to avoid flashing // the home screen when the user is taken to the alert screen in case of identities that don't match. @@ -671,7 +679,7 @@ export function* initializeApplicationSaga( yield* fork(watchEmailNotificationPreferencesSaga); // Check if we have a pending notification message - yield* call(handlePendingMessageStateIfAllowedSaga, true); + yield* call(handlePendingMessageStateIfAllowed, true); // This tells the security advice bottomsheet that it can be shown yield* put(setSecurityAdviceReadyToShow(true)); diff --git a/ts/sagas/startup/watchApplicationActivitySaga.ts b/ts/sagas/startup/watchApplicationActivitySaga.ts index f51e7673b42..a86888ba2e6 100644 --- a/ts/sagas/startup/watchApplicationActivitySaga.ts +++ b/ts/sagas/startup/watchApplicationActivitySaga.ts @@ -10,7 +10,7 @@ import { StartupStatusEnum, isStartupLoaded } from "../../store/reducers/startup"; -import { handlePendingMessageStateIfAllowedSaga } from "../../features/pushNotifications/sagas/notifications"; +import { handlePendingMessageStateIfAllowed } from "../../features/pushNotifications/sagas/common"; import { areTwoMinElapsedFromLastActivity } from "../../features/fastLogin/store/actions/sessionRefreshActions"; /** @@ -43,7 +43,7 @@ export function* watchApplicationActivitySaga(): IterableIterator = []; diff --git a/ts/store/actions/types.ts b/ts/store/actions/types.ts index 28449bd7682..f79c79fafde 100644 --- a/ts/store/actions/types.ts +++ b/ts/store/actions/types.ts @@ -25,7 +25,10 @@ import { PayPalOnboardingActions } from "../../features/wallet/onboarding/paypal import { ServicesActions } from "../../features/services/common/store/actions"; import { WhatsNewActions } from "../../features/whatsnew/store/actions"; import { ZendeskSupportActions } from "../../features/zendesk/store/actions"; -import { NotificationsActions } from "../../features/pushNotifications/store/actions/notifications"; +import { NotificationsActions } from "../../features/pushNotifications/store/actions/installation"; +import { NotificationPermissionsActions } from "../../features/pushNotifications/store/actions/permissions"; +import { PendingMessageActions } from "../../features/pushNotifications/store/actions/pendingMessage"; +import { ProfileNotificationPermissionsActions } from "../../features/pushNotifications/store/actions/profileNotificationPermissions"; import { GlobalState } from "../reducers/types"; import { CieLoginConfigActions } from "../../features/cieLogin/store/actions"; import { FimsActions } from "../../features/fims/common/store/actions"; @@ -70,6 +73,9 @@ export type Action = | MessagesActions | MixpanelActions | NotificationsActions + | NotificationPermissionsActions + | PendingMessageActions + | ProfileNotificationPermissionsActions | PinSetActions | OnboardingActions | PreferencesActions diff --git a/ts/store/middlewares/analytics.ts b/ts/store/middlewares/analytics.ts index a76de9f0a0f..89b4a23c108 100644 --- a/ts/store/middlewares/analytics.ts +++ b/ts/store/middlewares/analytics.ts @@ -97,11 +97,6 @@ import { updatePaymentStatus } from "../actions/wallet/wallets"; import { buildEventProperties } from "../../utils/analytics"; -import { - notificationsInstallationTokenRegistered, - updateNotificationInstallationFailure, - updateNotificationsInstallationToken -} from "../../features/pushNotifications/store/actions/notifications"; import { trackServicesAction } from "../../features/services/common/analytics"; import { trackMessagesActionsPostDispatch } from "../../features/messages/analytics"; import { trackContentAction } from "./contentAnalytics"; @@ -237,7 +232,6 @@ const trackAction = case getType(paymentDeletePayment.failure): case getType(paymentUpdateWalletPsp.failure): case getType(paymentExecuteStart.failure): - case getType(updateNotificationInstallationFailure): // Bonus vacanze case getType(loadAvailableBonuses.failure): return mp.track(action.type, { @@ -321,8 +315,6 @@ const trackAction = // profile First time Login case getType(profileFirstLogin): // other - case getType(updateNotificationsInstallationToken): - case getType(notificationsInstallationTokenRegistered): case getType(loadAvailableBonuses.success): case getType(loadAvailableBonuses.request): return mp.track(action.type); diff --git a/ts/store/reducers/index.ts b/ts/store/reducers/index.ts index d2302b45a89..698fb680bb2 100644 --- a/ts/store/reducers/index.ts +++ b/ts/store/reducers/index.ts @@ -31,7 +31,7 @@ import { whatsNewInitialState } from "../../features/whatsnew/store/reducers"; import { fastLoginOptInInitialState } from "../../features/fastLogin/store/reducers/optInReducer"; import { isDevEnv } from "../../utils/environment"; import { trialSystemActivationStatusReducer } from "../../features/trialSystem/store/reducers"; -import { notificationsReducer } from "../../features/pushNotifications/store/reducers"; +import { persistedNotificationsReducer } from "../../features/pushNotifications/store/reducers"; import { profileSettingsReducerInitialState } from "../../features/profileSettings/store/reducers"; import { itwIdentificationInitialState } from "../../features/itwallet/identification/store/reducers"; import { cieLoginInitialState } from "../../features/cieLogin/store/reducers"; @@ -154,7 +154,7 @@ export const appReducer: Reducer = combineReducers< ), features: featuresPersistor, onboarding: onboardingReducer, - notifications: notificationsReducer, + notifications: persistedNotificationsReducer, profile: profileReducer, userDataProcessing: userDataProcessingReducer, entities: persistReducer( @@ -272,7 +272,8 @@ export function createRootReducer( }, // notifications must be kept notifications: { - ...state.notifications + ...state.notifications, + _persist: state.notifications._persist }, // payments must be kept payments: { diff --git a/ts/store/reducers/types.ts b/ts/store/reducers/types.ts index 1d37f9a1049..76a3aa2bab6 100644 --- a/ts/store/reducers/types.ts +++ b/ts/store/reducers/types.ts @@ -5,7 +5,7 @@ import { BonusState } from "../../features/bonus/common/store/reducers"; import { PersistedFeaturesState } from "../../features/common/store/reducers"; import { PersistedLollipopState } from "../../features/lollipop/store"; import { TrialSystemState } from "../../features/trialSystem/store/reducers"; -import { NotificationsState } from "../../features/pushNotifications/store/reducers"; +import { PersistedNotificationsState } from "../../features/pushNotifications/store/reducers"; import { AppState } from "./appState"; import { AssistanceToolsState } from "./assistanceTools"; import { PersistedAuthenticationState } from "./authentication"; @@ -38,7 +38,7 @@ export type GlobalState = Readonly<{ versionInfo: VersionInfoState; entities: PersistedEntitiesState; backoffError: BackoffErrorState; - notifications: NotificationsState; + notifications: PersistedNotificationsState; onboarding: OnboardingState; profile: ProfileState; userDataProcessing: UserDataProcessingState; diff --git a/ts/utils/appSettings.ts b/ts/utils/appSettings.ts index 7fae8817a99..c6d8468504a 100644 --- a/ts/utils/appSettings.ts +++ b/ts/utils/appSettings.ts @@ -1,18 +1,11 @@ +import { constUndefined } from "fp-ts/lib/function"; import { Linking, Platform } from "react-native"; import AndroidOpenSettings from "react-native-android-open-settings"; export const openAppSettings = () => { if (Platform.OS === "ios") { - Linking.openURL("app-settings:").catch(_ => undefined); + Linking.openURL("app-settings:").catch(constUndefined); } else { AndroidOpenSettings.appDetailsSettings(); } }; - -export const openAppSecuritySettings = () => { - if (Platform.OS === "ios") { - Linking.openURL("App-prefs:root=General").catch(_ => undefined); - } else { - AndroidOpenSettings.securitySettings(); - } -}; diff --git a/yarn.lock b/yarn.lock index 43861a5be45..c86fa553156 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13472,6 +13472,11 @@ react-native-masked-text@^1.13.0: date-and-time "0.9.0" tinymask "1.0.2" +react-native-notifications-utils@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/react-native-notifications-utils/-/react-native-notifications-utils-0.3.0.tgz#a26ceabef2161dc8e9052badf36f09f6b3954832" + integrity sha512-URKVdNeDxlZopZ+F2X3qi9x6pjn+o+JXzSR82E9OfGlYfPKq4oMrYEYRSptIBQwwnloc4amMERcBH8x7DFIqdA== + react-native-pager-view@^6.2.3: version "6.2.3" resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"