To Receive Push Notification
Android (Configure Firebase Project)
-
For now since we don't have a google account for all mobile application, the following credentials are generated by using
james.lee@wonderbricks.com
. -
The generation steps are simple to replicate:
-
We can create one project for four environments.
-
Repeatedly Click Plus Sign
-
Repeatedly Click
-
Repeatedly Fill in the package name
-
-
We create 4
package
name with only characters and underscores (android don't allow-
in package name):com.XXX.XXXX_dev
com.XXX.XXXX_uat
com.XXX.XXXX_poc
com.XXX.XXXX_prod
-
Project Setting
-
Manage API in Google Cloud Console:
-
Enable
-
After we enabled successfully:
AAAA95L1NUI:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
-
Click Credentials:
-
Add a FCM (Legacy) server key:
iOS (Nothong)
- Nothing to add as we are using EAS-build.
Page to Create Access Token
By default everyone who owns the device's expo-push-notification-token
can send push-notification to that device. Expo provides one additional layer to prevent malacious use of a notification token (just in case someone gets the token for some reason)
You can use this token in pushNotificationUtil.ts section.
Personal Access Tokens
vWXyXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Create Robot Users
i-QtXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Toggle the Enhanced Security for Push Notifications (Optional)
After that any naked POST
request cannot send notification to your user even they own the push-notification-token
of that user.
Receive Notification in Mobile
Frontend
Create a PushNotification Token at Entrypoint
We use the following hook
// hooks/usePushNotification.ts import { useState, useEffect, useRef } from 'react'; import { Text, View, Button, Platform } from 'react-native'; import * as Device from 'expo-device'; import * as Notifications from 'expo-notifications'; import Constants from 'expo-constants'; import msgUtil from '../util/msgUtil'; import { useAppDispatch, useAppSelector } from '../redux/app/hooks'; import appSlice, { appThunkAction } from '../redux/slices/appSlice'; import { PushNotificationData } from '../dto/dto'; import { useRouter } from 'expo-router'; import projectAndChannelSlice from '../redux/slices/projectAndChannelSlice'; const configNotificationHandler = () => { Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: true, shouldSetBadge: false, }), }); } async function getNotificationTokenAsync(userId: string) { if (Platform.OS === 'android') { Notifications.setNotificationChannelAsync('default', { name: 'default', importance: Notifications.AndroidImportance.MAX, vibrationPattern: [0, 250, 250, 250], lightColor: '#FF231F7C', }); } if (Device.isDevice) { const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; console.log("finalStatusfinalStatusfinalStatus", finalStatus); if (existingStatus !== 'granted') { const { status } = await Notifications.requestPermissionsAsync(); finalStatus = status; } if (finalStatus !== 'granted') { alert('Failed to get push token for push notification!'); return; } const token = await Notifications.getExpoPushTokenAsync({ projectId: Constants?.expoConfig?.extra?.eas.projectId || "", }); return token.data; } else { throw new Error("Must use physical device for Push Notifications"); } } export default function usePushNotification() { const dispatch = useAppDispatch(); const router = useRouter(); configNotificationHandler(); const notificationListener = useRef<Notifications.Subscription>(); const responseListener = useRef<Notifications.Subscription>(); const subscribeNotification = async (userId: string) => { try { const token = await getNotificationTokenAsync(userId); console.log("tokentokentokentoken", token); if (token) { await dispatch(appThunkAction.providePushNotificationToken({ pushNotificationToken: token })).unwrap(); } } catch (err) { msgUtil.persistedError(JSON.stringify(err)); } notificationListener.current = Notifications.addNotificationResponseReceivedListener(response => { const { actionIdentifier, notification } = response; const { date, request } = response; // actionA }); responseListener.current = Notifications.addNotificationResponseReceivedListener(response => { const { notification } = response; const data = notification.request.content.data as PushNotificationData; // action B }); return () => { if (notificationListener?.current) { Notifications.removeNotificationSubscription(notificationListener?.current); } if (responseListener?.current) { Notifications.removeNotificationSubscription(responseListener?.current); } }; } return { subscribeNotification } }
We will call subscribeNotification()
whenever we try to login.
For example, in the entrypoint of our application:
export default () => { const [_, setHydrated] = useState(false); // force rerendering only const { accessToken, userId } = useAppSelector(s => s.auth); const dispatch = useAppDispatch(); const { subscribeNotification } = usePushNotification(); ... useEffect(() => { if (!accessToken) { router.push("/login") } else { subscribeNotification(userId); } }, [accessToken]); useEffect(() => { setTimeout(() => { setHydrated(true); }, 1); }, []) return ( ... ) }
This effect will be executed everytime we login or launch the application.
Note that action A
and action B
inside subscribeNotification
have different behaviour:
Control the User's Behaviour (User being Passive)
- Look at the hook ahove,
actionA
will be executed only when user is in-app, moreover, the action will take place without user's consent.
User Controles the Behaviour (User being Active)
- Look at the hook above,
actionB
will be executed when user tap into the application.
Backend (Receive Push Notification Token via Upsert)
// controller: const providePushNotificationToken = async (req: Request, res: Response) => { const userEmail = req.user?.email || ""; const { pushNotificationToken } = req.body as { pushNotificationToken: string }; await db.insertInto("PushNotification") .values({ userEmail, token: pushNotificationToken }) .onConflict(oc => oc // `userEmail` is the only unique-key constraint .columns(["userEmail"]) .doUpdateSet(eb => ({ token: eb.ref("excluded.token") })) ) .execute(); res.json({ success: true }); }
Send Notification via Backend
Nodejs Library to Send Notification
-
Resource: send-push-notifications-using-a-server
-
We use this npm package.
-
Note that the npm package also do the Limit Concurrent Connections for us.
pushNotificationUtil.ts
import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk'; type PushNotificationData = { projectId: string, channelId: string, } const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN }); const forwardMessages = async (params: { tokens: string[], message: { title: string, body: string, data: PushNotificationData } }) => { const { message, tokens } = params; const { body, data, title } = message; const messages: ExpoPushMessage[] = [] for (const token of tokens) { messages.push({ to: token, sound: 'default', body: body, data, title }) } const chunks = expo.chunkPushNotifications(messages); const tickets: ExpoPushTicket[] = [] for (const chunk of chunks) { try { const ticketChunk = await expo.sendPushNotificationsAsync(chunk); console.log(ticketChunk); tickets.push(...ticketChunk); // NOTE: If a ticket contains an error code in ticket.details.error, you // must handle it appropriately. The error codes are listed in the Expo // documentation: // https://docs.expo.io/push-notifications/sending-notifications/#individual-errors } catch (error) { console.error(error); } } const successReceiptIds = []; for (let ticket of tickets) { // NOTE: Not all tickets have IDs; for example, tickets for notifications // that could not be enqueued will have error information and no receipt ID. if (ticket.status === "ok" && ticket?.id) { successReceiptIds.push(ticket.id); } } let receiptIdChunks = expo.chunkPushNotificationReceiptIds(successReceiptIds); (async () => { // Like sending notifications, there are different strategies you could use // to retrieve batches of receipts from the Expo service. for (let chunk of receiptIdChunks) { try { let receipts = await expo.getPushNotificationReceiptsAsync(chunk); console.log(receipts); // The receipts specify whether Apple or Google successfully received the // notification and information about an error, if one occurred. for (let receiptId in receipts) { const receipt = receipts[receiptId]; let { status, details } = receipt if (status === 'ok') { continue; } else if (status === 'error') { console.error( `There was an error sending a notification: ${JSON.stringify(details)}` ); } } } catch (error) { console.error(error); } } })(); } export default { forwardMessages }
notificationService.ts
Here the backend logic differs from the businesss, for us we push notification for different channels, and each user has their own token in the PushNotification
Table:
import { channel } from "diagnostics_channel"; import { db } from "../db/kysely/database"; import pushNotificationUtil from "../util/pushNotificationUtil"; const notifyChannel = async (params: { excludeUserEmails: string[], channelId: string, title?: string, body?: string }) => { const { excludeUserEmails, channelId, title, body } = params; const targetChannel = await db.selectFrom("Channel") .select(["Channel.name", "Channel.projectId"]) .where("Channel.id", "=", channelId) .executeTakeFirst(); if (!targetChannel) { throw new Error("Channel does not exist"); } const allTokenExceptMeInsideChannel = await db.selectFrom("UserToChannel") .leftJoin("User", "User.companyEmail", "UserToChannel.userEmail") .leftJoin("PushNotification", "PushNotification.userEmail", "User.companyEmail") .select("PushNotification.token") .where("PushNotification.userEmail", "not in", excludeUserEmails) .where("UserToChannel.channelId", "=", channelId) .execute(); const tokens = allTokenExceptMeInsideChannel.filter(t => t.token).map(t => t.token!); pushNotificationUtil.forwardMessages({ tokens, message: { title: title || "Issue Update", data: { channelId, projectId: targetChannel.projectId || "", }, body: body || `New Issue in Channel ${targetChannel.name}`, } }) } export default { notifyChannel }
Of course you can build more reusable notification strategy in this file!