Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(Mobile): add FCM and bootstrap usage #4823

Merged
merged 15 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/mobile/.easignore
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions apps/mobile/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 40 additions & 37 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -28,43 +29,45 @@ function RootLayout() {
return (
<GestureHandlerRootView>
<Provider store={store}>
<PortalProvider shouldAddRootHost>
<BottomSheetModalProvider>
<PersistGate loading={null} persistor={persistor}>
<SafeThemeProvider>
<SafeToastProvider>
<Stack
screenOptions={({ navigation }) => ({
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerLeft: (props) => (
<HeaderBackButton
{...props}
testID={'go-back'}
onPress={navigation.goBack}
displayMode={'minimal'}
/>
),
})}
>
<Stack.Screen
name="index"
options={{
header: OnboardingHeader,
}}
/>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="pending-transactions" options={{ headerShown: true, title: '' }} />
<Stack.Screen name="signers" options={{ headerShown: true, title: 'Signers' }} />
<Stack.Screen name="notifications" options={{ headerShown: true, title: 'Notifications' }} />
<Stack.Screen name="app-settings" options={{ headerShown: true, title: 'Settings' }} />
<Stack.Screen name="+not-found" />
</Stack>
</SafeToastProvider>
</SafeThemeProvider>
</PersistGate>
</BottomSheetModalProvider>
</PortalProvider>
<NotificationsProvider>
<PortalProvider shouldAddRootHost>
<BottomSheetModalProvider>
<PersistGate loading={null} persistor={persistor}>
<SafeThemeProvider>
<SafeToastProvider>
<Stack
screenOptions={({ navigation }) => ({
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerLeft: (props) => (
<HeaderBackButton
{...props}
testID={'go-back'}
onPress={navigation.goBack}
displayMode={'minimal'}
/>
),
})}
>
<Stack.Screen
name="index"
options={{
header: OnboardingHeader,
}}
/>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="pending-transactions" options={{ headerShown: true, title: '' }} />
<Stack.Screen name="signers" options={{ headerShown: true, title: 'Signers' }} />
<Stack.Screen name="notifications" options={{ headerShown: true, title: 'Notifications' }} />
<Stack.Screen name="app-settings" options={{ headerShown: true, title: 'Settings' }} />
<Stack.Screen name="+not-found" />
</Stack>
</SafeToastProvider>
</SafeThemeProvider>
</PersistGate>
</BottomSheetModalProvider>
</PortalProvider>
</NotificationsProvider>
</Provider>
</GestureHandlerRootView>
)
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"developmentClient": true,
"env": {
"APP_VARIANT": "development"
},
"android": {
"image": "ubuntu-18.04-jdk-11-ndk-r19c"
}
},
"preview-ios-simulator": {
Expand All @@ -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"
}
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/firebase.json
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 4 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions apps/mobile/src/context/NotificationsContext.tsx
Original file line number Diff line number Diff line change
@@ -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<NotificationContextType | undefined>(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<NotificationProviderProps> = ({ children }) => {
/**
* Enables notifications for the app if the user has enabled them
*/
const { isAppNotificationEnabled, fcmToken, remoteMessages } = useNotifications()

return (
<NotificationContext.Provider value={{ isAppNotificationEnabled, fcmToken, remoteMessages }}>
{children}
</NotificationContext.Provider>
)
}
89 changes: 89 additions & 0 deletions apps/mobile/src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading