diff --git a/apps/mobile/.easignore b/apps/mobile/.easignore
new file mode 100644
index 0000000000..91a9f56902
--- /dev/null
+++ b/apps/mobile/.easignore
@@ -0,0 +1,63 @@
+# Auto generated storybook file
+.storybook/storybook.requires.ts
+
+# From jest
+html
+coverage
+
+# macOS
+.DS_Store
+
+/.idea
+# Tamagui UI generates a lot of cache files
+.tamagui
+
+*storybook.log
+/storybook-static
+
+# Android and iOS build files
+/android/*
+/ios/*
+
+# @generated expo-cli sync-8d4afeec25ea8a192358fae2f8e2fc766bdce4ec
+# The following patterns were generated by expo-cli
+
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+# Native
+*.orig.*
+*.
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+# @end expo-cli
\ No newline at end of file
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
index 91a9f56902..5b3c4fb198 100644
--- a/apps/mobile/.gitignore
+++ b/apps/mobile/.gitignore
@@ -18,6 +18,8 @@ coverage
# Android and iOS build files
/android/*
/ios/*
+google-services.json
+GoogleService-Info.plist
# @generated expo-cli sync-8d4afeec25ea8a192358fae2f8e2fc766bdce4ec
# The following patterns were generated by expo-cli
diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js
index 43af1a1361..b1441353cf 100644
--- a/apps/mobile/app.config.js
+++ b/apps/mobile/app.config.js
@@ -24,10 +24,15 @@ export default {
},
infoPlist: {
NSFaceIDUsageDescription: 'Enabling Face ID allows you to create/access secure keys.',
+ UIBackgroundModes: ['remote-notification'],
},
supportsTablet: true,
appleTeamId: 'MXRS32BBL4',
bundleIdentifier: IS_DEV ? 'global.safe.mobileapp.dev' : 'global.safe.mobileapp',
+ entitlements: {
+ 'aps-environment': 'production',
+ },
+ googleServicesFile: process.env.GOOGLE_SERVICES_PLIST ?? './GoogleService-Info.plist',
},
android: {
adaptiveIcon: {
@@ -36,6 +41,7 @@ export default {
monochromeImage: './assets/images/monochrome-icon.png',
},
package: IS_DEV ? 'global.safe.mobileapp.dev' : 'global.safe.mobileapp',
+ googleServicesFile: process.env.GOOGLE_SERVICES_JSON ?? './google-services.json',
},
web: {
bundler: 'metro',
@@ -63,6 +69,16 @@ export default {
},
],
['./expo-plugins/withDrawableAssets.js', './assets/android/drawable'],
+ [
+ 'expo-build-properties',
+ {
+ ios: {
+ useFrameworks: 'static',
+ },
+ },
+ ],
+ '@react-native-firebase/app',
+ '@react-native-firebase/messaging',
],
experiments: {
typedRoutes: true,
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index d430524923..a481a5ab47 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -10,6 +10,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { HeaderBackButton } from '@react-navigation/elements'
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
import { PortalProvider } from '@tamagui/portal'
+import { NotificationsProvider } from '@/src/context/NotificationsContext'
import { SafeToastProvider } from '@/src/theme/provider/toastProvider'
import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated'
import { OnboardingHeader } from '@/src/features/Onboarding/components/OnboardingHeader'
@@ -28,43 +29,45 @@ function RootLayout() {
return (
-
-
-
-
-
- ({
- headerBackButtonDisplayMode: 'minimal',
- headerShadowVisible: false,
- headerLeft: (props) => (
-
- ),
- })}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ ({
+ headerBackButtonDisplayMode: 'minimal',
+ headerShadowVisible: false,
+ headerLeft: (props) => (
+
+ ),
+ })}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
index a4ad6d5441..10f852016f 100644
--- a/apps/mobile/eas.json
+++ b/apps/mobile/eas.json
@@ -19,6 +19,9 @@
"developmentClient": true,
"env": {
"APP_VARIANT": "development"
+ },
+ "android": {
+ "image": "ubuntu-18.04-jdk-11-ndk-r19c"
}
},
"preview-ios-simulator": {
@@ -42,6 +45,16 @@
"extends": "base",
"environment": "production",
"autoIncrement": true,
+ "ios": {
+ "env": {
+ "GOOGLE_SERVICES_FILE": "./GoogleService-Info.plist"
+ }
+ },
+ "android": {
+ "env": {
+ "GOOGLE_SERVICES_FILE": "./google-services.json"
+ }
+ },
"env": {
"APP_VARIANT": "production"
}
diff --git a/apps/mobile/firebase.json b/apps/mobile/firebase.json
new file mode 100644
index 0000000000..97ec0198ef
--- /dev/null
+++ b/apps/mobile/firebase.json
@@ -0,0 +1,9 @@
+{
+ "react-native": {
+ "analytics_auto_collection_enabled": false,
+ "messaging_auto_init_enabled": true,
+ "messaging_ios_auto_register_for_remote_messages": false,
+ "android_task_executor_maximum_pool_size": 10,
+ "android_task_executor_keep_alive_seconds": 3
+ }
+}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 10c3ffde1f..48e7c9c1c1 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -41,8 +41,11 @@
"@ethersproject/shims": "^5.7.0",
"@expo/config-plugins": "^9.0.10",
"@expo/vector-icons": "^14.0.2",
+ "@notifee/react-native": "^9.1.8",
"@react-native-clipboard/clipboard": "^1.15.0",
"@react-native-community/blur": "^4.4.1",
+ "@react-native-firebase/app": "^21.7.1",
+ "@react-native-firebase/messaging": "^21.7.1",
"@react-native-menu/menu": "^1.1.6",
"@react-native/babel-preset": "^0.76.2",
"@react-navigation/material-top-tabs": "^7.1.0",
@@ -64,6 +67,7 @@
"ethers": "^6.13.4",
"expo": "~52.0.14",
"expo-blur": "~14.0.1",
+ "expo-build-properties": "^0.13.2",
"expo-constants": "~17.0.4",
"expo-dev-client": "~5.0.5",
"expo-font": "~13.0.3",
diff --git a/apps/mobile/src/context/NotificationsContext.tsx b/apps/mobile/src/context/NotificationsContext.tsx
new file mode 100644
index 0000000000..e002d79ba8
--- /dev/null
+++ b/apps/mobile/src/context/NotificationsContext.tsx
@@ -0,0 +1,37 @@
+import React, { createContext, useContext, ReactNode } from 'react'
+
+import useNotifications from '@/src/hooks/useNotifications'
+import { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
+
+interface NotificationContextType {
+ isAppNotificationEnabled: boolean
+ fcmToken: string | null
+ remoteMessages: FirebaseMessagingTypes.RemoteMessage[] | []
+}
+
+const NotificationContext = createContext(undefined)
+
+export const useNotification = () => {
+ const context = useContext(NotificationContext)
+ if (!context) {
+ throw new Error('useNotification must be used within a NotificationProvider')
+ }
+ return context
+}
+
+interface NotificationProviderProps {
+ children: ReactNode
+}
+
+export const NotificationsProvider: React.FC = ({ children }) => {
+ /**
+ * Enables notifications for the app if the user has enabled them
+ */
+ const { isAppNotificationEnabled, fcmToken, remoteMessages } = useNotifications()
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/mobile/src/hooks/useNotifications.ts b/apps/mobile/src/hooks/useNotifications.ts
new file mode 100644
index 0000000000..2dd494d011
--- /dev/null
+++ b/apps/mobile/src/hooks/useNotifications.ts
@@ -0,0 +1,89 @@
+import { useEffect } from 'react'
+import FCMService from '@/src/services/notifications/FCMService'
+import { useAppSelector, useAppDispatch } from '@/src/store/hooks'
+import {
+ selectAppNotificationStatus,
+ selectFCMToken,
+ selectPromptAttempts,
+ selectLastTimePromptAttempted,
+ selectRemoteMessages,
+ toggleAppNotifications,
+} from '@/src/store/notificationsSlice'
+import NotificationsService from '@/src/services/notifications/NotificationService'
+import { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
+import Logger from '@/src/utils/logger'
+
+interface NotificationsProps {
+ isAppNotificationEnabled: boolean
+ fcmToken: string | null
+ remoteMessages: FirebaseMessagingTypes.RemoteMessage[]
+}
+
+const useNotifications = (): NotificationsProps => {
+ const dispatch = useAppDispatch()
+ /**
+ * We need to check if the user has enabled notifications for the device in order to keep listening for messages
+ * since the user can disable notifications at any time on their device, we need to handle app behavior accordingly
+ * if device notifications are disabled, the user has been prompt more than 3 times within a month to enable the app notifications
+ * we should only ask the user to enable notifications again after a month has passed
+ *
+ * If the user has disabled notifications for the app, we should disable app notifications
+ */
+ const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus)
+ const fcmToken = useAppSelector(selectFCMToken)
+ const remoteMessages = useAppSelector(selectRemoteMessages)
+
+ const promptAttempts = useAppSelector(selectPromptAttempts)
+ const lastTimePromptAttempted = useAppSelector(selectLastTimePromptAttempted)
+
+ useEffect(() => {
+ const checkNotifications = async () => {
+ const isDeviceNotificationEnabled = await NotificationsService.isDeviceNotificationEnabled()
+ if (!isDeviceNotificationEnabled) {
+ /**
+ * If the user has been prompt more than 3 times within a month to enable the device notifications
+ * we should only ask the user to enable it again after a month has passed
+ *
+ * This also disables app notifications if the user has disabled device notifications and denied to re-enabled it after 3 attempts
+ */
+ if (
+ promptAttempts &&
+ promptAttempts >= 3 &&
+ lastTimePromptAttempted &&
+ new Date().getTime() - new Date(lastTimePromptAttempted).getTime() < 2592000000
+ ) {
+ if (isAppNotificationEnabled) {
+ dispatch(toggleAppNotifications(false))
+ }
+ return
+ }
+
+ const { permission } = await NotificationsService.getAllPermissions()
+
+ if (permission !== 'authorized') {
+ return
+ }
+ }
+
+ try {
+ // Firebase Cloud Messaging
+ await FCMService.registerAppWithFCM()
+ await FCMService.saveFCMToken()
+ FCMService.listenForMessagesBackground()
+ } catch (error) {
+ Logger.error('FCM Registration or Token Save failed', error)
+ return
+ }
+
+ return () => {
+ FCMService.listenForMessagesForeground()()
+ }
+ }
+
+ checkNotifications()
+ }, [isAppNotificationEnabled])
+
+ return { isAppNotificationEnabled, fcmToken, remoteMessages }
+}
+
+export default useNotifications
diff --git a/apps/mobile/src/services/notifications/FCMService.ts b/apps/mobile/src/services/notifications/FCMService.ts
new file mode 100644
index 0000000000..00bcf4ddc6
--- /dev/null
+++ b/apps/mobile/src/services/notifications/FCMService.ts
@@ -0,0 +1,67 @@
+import messaging, { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
+import Logger from '@/src/utils/logger'
+import NotificationsService from './NotificationService'
+import { ChannelId } from '@/src/utils/notifications'
+import { store } from '@/src/store'
+import { savePushToken } from '@/src/store/notificationsSlice'
+
+type UnsubscribeFunc = () => void
+
+class FCMService {
+ async getFCMToken(): Promise {
+ const { fcmToken } = store.getState().notifications
+ const token = fcmToken || undefined
+ if (!token) {
+ Logger.info('getFCMToken: No FCM token found')
+ }
+ return token
+ }
+
+ async saveFCMToken(): Promise {
+ try {
+ const fcmToken = await messaging().getToken()
+ if (fcmToken) {
+ store.dispatch(savePushToken(fcmToken))
+ }
+ } catch (error) {
+ Logger.info('FCMService :: error saving', error)
+ }
+ }
+
+ listenForMessagesForeground = (): UnsubscribeFunc =>
+ messaging().onMessage(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
+ NotificationsService.displayNotification({
+ channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID,
+ title: remoteMessage.notification?.title || '',
+ body: remoteMessage.notification?.body || '',
+ data: remoteMessage.data,
+ })
+ Logger.trace('listenForMessagesForeground: listening for messages in Foreground', remoteMessage)
+ })
+
+ listenForMessagesBackground = (): void => {
+ messaging().setBackgroundMessageHandler(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
+ NotificationsService.displayNotification({
+ channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID,
+ title: remoteMessage.notification?.title || '',
+ body: remoteMessage.notification?.body || '',
+ data: remoteMessage.data,
+ })
+ Logger.trace('listenForMessagesBackground :: listening for messages in background', remoteMessage)
+ })
+ }
+
+ async registerAppWithFCM(): Promise {
+ if (!messaging().registerDeviceForRemoteMessages) {
+ await messaging()
+ .registerDeviceForRemoteMessages()
+ .then((status: unknown) => {
+ Logger.info('registerDeviceForRemoteMessages status', status)
+ })
+ .catch((error) => {
+ Logger.error('registerAppWithFCM: Something went wrong', error)
+ })
+ }
+ }
+}
+export default new FCMService()
diff --git a/apps/mobile/src/services/notifications/NotificationService.ts b/apps/mobile/src/services/notifications/NotificationService.ts
new file mode 100644
index 0000000000..2c4f4e8707
--- /dev/null
+++ b/apps/mobile/src/services/notifications/NotificationService.ts
@@ -0,0 +1,283 @@
+import notifee, {
+ AuthorizationStatus,
+ Event as NotifeeEvent,
+ EventType,
+ EventDetail,
+ AndroidChannel,
+} from '@notifee/react-native'
+import { Linking, Platform, Alert as NativeAlert } from 'react-native'
+import { store } from '@/src/store'
+import { updatePromptAttempts, updateLastTimePromptAttempted } from '@/src/store/notificationsSlice'
+import { toggleAppNotifications, toggleDeviceNotifications } from '@/src/store/notificationsSlice'
+
+import { HandleNotificationCallback, LAUNCH_ACTIVITY, PressActionId } from '@/src/store/constants'
+
+import { ChannelId, notificationChannels, withTimeout } from '@/src/utils/notifications'
+import Logger from '@/src/utils/logger'
+
+import { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
+
+interface AlertButton {
+ text: string
+ onPress: () => void | Promise
+}
+
+class NotificationsService {
+ async getBlockedNotifications(): Promise