0%
February 20, 2024

Push-Notification in Android and iOS via Expo

expo

nodejs

react-native

To Receive Push Notification

Android (Configure Firebase Project)
  • Firebase Console

  • 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
  • Expo Account Dashboard

  • 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
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!