Expo Starter: Notifications
šŸ“±

Expo Starter: Notifications

This post is still a work in progressā€¦

Overview

Notifications are an important feature for many mobile apps. The Expo push notification service handles certificates and formatting but I don't think is worth the extra abstraction, therefore, we will be using SNS to handle our push notifications. Unfortunately, SNS doesn't support web push notifications so we will have to handle those with a separate Lambda function.
We will break this tutorial into two parts; the frontend and the backend. For the frontend, we will retrieve our device's push token, save it to our database, and setup handlers for processing received push notifications. For the backend, we will setup SNS to handle sending our iOS and Android push notifications as well as setup a lambda function that handles our web push notifications.

Configure Expo Mobile Notifications Frontend

Although we won't be using the Expo push notification service in the end, we will start with it to make sure we have the frontend setup right. Also, even when we switch away from the Expo push notification service, we will can still use their helpful notification library,Ā expo-notifications.

Install Dependencies

% expo install expo-notifications

Add App Configuration (Optional)

You can optionally specify your Android icon image and background color, custom notification sounds, and the iOS push notification environment. I'm going to downloadĀ this wav file, put it in my assets folder, and use it as a custom notification sound.
// app.config.js export default { expo: { plugins: [ ..., [ 'expo-notifications', { //icon: "./src/assets/myNotificationIcon.png", //color: "#ffffff", sounds: ["./src/assets/mySound.wav"], mode: process.env.NODE_ENV } ] } }

Add Environment Variables

In order to use push notifications with Expo Go, you have to pass an experience ID so let's set it as an environment variable.
// envs/.env.development ... EXPERIENCE_ID=@USERNAME/expo-starter ...

Add Token Storage Key

We are going to store our token locally so let's define a key in our constants file.
export const StorageKeys = { ... PUSH_TOKEN: 'push-token', };

Create Mobile Notification Service

Let's create a notification service that handles retrieving our device's push token, setting up notification listeners and handlers, and some notifation configurations. We will leave out the functionality for storing the push token in the database for now and add it later, after we setup our infrastructure.
// src/services/notifications.ts import { Platform } from 'react-native'; import Constants from 'expo-constants'; import * as Notifications from 'expo-notifications'; import { NotificationResponse, Notification } from 'expo-notifications'; import { Subscription } from '@unimodules/core'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { StorageKeys } from '@/utilities/constants'; import Alert from '@/utilities/alerts'; import LOGGER from '@/services/logger'; LOGGER.enable('NOTIFICATIONS'); const log = LOGGER.extend('NOTIFICATIONS'); export default class NotificationService { EXPERIENCE_ID = process.env.EXPERIENCE_ID; private addPushTokenListener: Subscription | null = null; private notificationListener: Subscription; private responseListener: Subscription; pushToken!: string; notification!: Notification; notificationResponse!: NotificationResponse; constructor() { // Set notification handler for handling notifications (foregrounded) Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: false, shouldSetBadge: false, }), }); // Set interactive notification categories Notifications.setNotificationCategoryAsync('YESNO', [ { identifier: 'YES', buttonTitle: 'Yes šŸ‘', options: { opensAppToForeground: false } }, { identifier: 'NO', buttonTitle: 'No šŸ‘Ž', options: { opensAppToForeground: false } }, ]); // Add notification listener (foregrounded) this.notificationListener = Notifications.addNotificationReceivedListener(notification => { this.notification = notification; }); // Add interacted notification listener (foregrounded, backgrounded, killed) this.responseListener = Notifications.addNotificationResponseReceivedListener(async response => { this.notificationResponse = response; console.log(`NOTIFICATION RESPONSE ACTION: ${response.actionIdentifier}`); // Dismiss notification Notifications.dismissNotificationAsync(response.notification.request.identifier); }); log.info('Notification Service initialized'); } async registerForPushNotifications(): Promise<void> { let token; if (Constants.isDevice) { const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; if (existingStatus !== 'granted') { const { status } = await Notifications.requestPermissionsAsync(); finalStatus = status; } if (finalStatus !== 'granted') { log.error('Permission not granted'); Alert('Notification Error', 'Failed to get push token for push notification'); } try { token = (await Notifications.getExpoPushTokenAsync({ experienceId: this.EXPERIENCE_ID })).data; log.debug(token); await this.savePostTokenToDB(token); } catch (error) { log.error(error); Alert('Notification Error', 'Failed to register push token'); } } else { log.error('Must use physical device for Push Notifications'); Alert('Notification Error', 'Must use physical device for Push Notifications'); } if (Platform.OS === 'android') { Notifications.setNotificationChannelAsync('default', { name: 'default', importance: Notifications.AndroidImportance.MAX, sound: 'mySound.wav', vibrationPattern: [0, 250, 250, 250], lightColor: '#FF231F7C', }); } this.pushToken = token || ''; // Setup token refresh listener this.addPushTokenListener = Notifications.addPushTokenListener(this.registerForPushNotifications); } private async savePostTokenToDB(token: string): Promise<void> { const type = Platform.OS; try { // Retrieve and parse token (if exists) const storedToken = await AsyncStorage.getItem(StorageKeys.PUSH_TOKEN); // First time saving token -> create record if (!storedToken) { // TODO: create database record and store token locally } // Stored token doesn't match current token -> update record if (storedToken !== token) { // TODO: update database record and store token locally } } catch (error) { log.error(error); Alert('Notification Error', 'Failed to update push token'); } } async scheduleNotification(seconds: number, title: string): Promise<void> { await Notifications.scheduleNotificationAsync({ content: { title, }, trigger: { seconds, channelId: 'default', }, }); } destructor(): void { if (this.addPushTokenListener) { this.addPushTokenListener.remove(); } this.notificationListener.remove(); this.responseListener.remove(); } }

Initialize & Test

Letā€™s initialize our notification service in our Home screen, schedule a local notification to make sure it's working, and grab our Expo token from the console output and test it with theĀ Expo push notification tool.

Initialize Notification Service

// src/screens/HomeScreen/HomeScreen ... import NotificationService from '@/services/notifications'; ... export default function HomeScreen({ navigation }: Props): JSX.Element { const [notifications, setNotifications] = useState(new NotificationService()); ... useEffect(() => { (async () => { await notifications.registerForPushNotifications(); ... })(); }, []); ... return ( <View style={styles.container}> ... <Button title="Schedule Local Notification" onPress={() => notifications.scheduleNotification(2, 'Hi!')} /> ... </View>); } ...

Test Local Notifications

To test locale notifications, simply click the 'Schedule Local Notification' button.
notion image

Test Push Notifications

To test push notifications, grab the Expo token from the console output and use it with theĀ Expo push notification tool.
notion image
notion image

Configure Web Notifications Frontend

Web notifications requires a service worker so I am going to build off of the PWA I configured in the previous post.If you haven't followed the PWA tutorial in this series, go read through that and then come back. I'll wait... You read it? Good, let's go!

Generate VAPID Keys

In order to encrypt our notifications we need to generate a pair of VAPID keys. You can useĀ this service. We will add the public key to our environment variables now and hold on to the private one for later.
// envs/.env.development ... VAPID_PUBLIC_KEY=BOjjoGGqzAi1MqYOD7Pz-MBfZEi7B39FA-OMHfZoMmlX8P-LHItto37pq7p0acZtZ1YoF36oUVBeePolqivWKYE

Add Notification Functionality to ServiceWorker

// src/service-worker.js

Deploy Production Backend

Since we are adding the notification functionality to our existing PWA, we need to run the app in production in order to test the funcitonality. You can either copy the environmental variables from your development file to your production one or you can deploy the production backend.
% yarn deploy:prod
and then copy the output from there toĀ envs/.dev.production.

Update ServiceWorker

Let's add notification functionality to our ServiceWorker.
// src/service-worker.js ... /* NOTIFICATIONS */ /* Listen for incoming Push events */ self.addEventListener('push', event => { console.log('[Service Worker] Push Received: ', event.data); if (!(self.Notification && self.Notification.permission === 'granted')) return; let data = {}; if (event.data) { data = event.data.json(); } const { title } = data; const { message } = data; const options = { body: message, vibrate: [100, 50, 100], }; event.waitUntil(self.registration.showNotification(title, options)); }); /* Handle a notification click */ self.addEventListener('notificationclick', event => { console.log('[Service Worker] Notification click: ', event); if (event.action !== '') { console.log('[Service Worker] Notification action: ', event.action); event.waitUntil( (async () => { const clients = await self.clients.matchAll({ type: 'window' }); console.log(clients); // eslint-disable-next-line no-restricted-syntax for (const client of clients) { console.log(`[Service Worker] Sending message to client: ${client}, `, { action: event.action, }); client.postMessage({ action: event.action, }); } })(), ); } event.notification.close(); }); /* Handle a notification close */ self.addEventListener('notificationclose', event => { console.log('[Service Worker] Notification close: ', event); });

Create Web Notification Service

Next, we will create a notification service for our web app. TheĀ FILE.web.EXTĀ signifies to the build system to use this file in our web build. We will save the subscription to the database later.
// src/services/notifications.web.tsx import { Platform } from 'react-native'; import Constants from 'expo-constants'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { StorageKeys } from '@/utilities/constants'; import Alert from '@/utilities/alerts'; import LOGGER from '@/services/logger'; LOGGER.enable('NOTIFICATIONS'); const log = LOGGER.extend('NOTIFICATIONS'); export default class NotificationService { config: { pushKey: string | undefined }; subscription: PushSubscription | null = null; constructor() { this.config = { pushKey: process.env.VAPID_PUBLIC_KEY }; log.debug('VAPID PUBLIC KEY: ', process.env.VAPID_PUBLIC_KEY); log.info('Notification Service initialized'); } async registerForPushNotifications(): Promise<void> { const permission = await this.askNotificationPermission(); if (permission === 'granted') { log.info('Notification permission granted'); if ('serviceWorker' in navigator) { try { const reg = await navigator.serviceWorker.ready; this.subscription = await reg.pushManager.getSubscription(); if (!this.subscription) { this.subscription = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlB64ToUint8Array(this.config.pushKey), }); } log.debug(this.subscription); // TODO: Save to database } catch (error) { log.error(error); } navigator.serviceWorker.addEventListener('message', event => console.log(event.data)); } } else { log.error('Permissions not granted'); } } async askNotificationPermission(): Promise<NotificationPermission> { /* Safari (and some older browsers) don't support the promise based check */ function checkNotificationPromise() { try { Notification.requestPermission().then(); } catch (e) { return false; } return true; } if (!('Notification' in window)) { log.error('This browser does not support notifications'); return 'default'; } if (checkNotificationPromise()) { return Notification.requestPermission(); } return new Promise(resolve => { Notification.requestPermission(permission => { resolve(permission); }); }); } private urlB64ToUint8Array = (base64String: any): Uint8Array => { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); // eslint-disable-next-line no-plusplus for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; }; async scheduleNotification(seconds: number, title: string): Promise<void> { log.error('Scheduled notifications are not available on web'); const reg = await navigator.serviceWorker.getRegistration(); if (reg) { const timestamp = new Date().getTime() + seconds * 1000; reg.showNotification('Test Notification', { tag: timestamp.toString(), body: 'Hi!', // showTrigger: new TimestampTrigger(timestamp), // Not supported yet vibrate: [100, 50, 100], actions: [ { action: 'yes', title: 'yes' }, { action: 'no', title: 'no' }, ], }); } } }

Push Notifications Backend

For the backend, we need to configure a SNS Platform Application for iOS and one for Android. To handle web push, we are also going to have to define a DynamoDB table to store the subscription and a Lambda that triggers from SNS, retrieves the relevant subscriptions and sends out the notifications.

Setup SNS Platform Applications

Unfortunately, it isn't possible to configure this from the CDK so we will be working in the console. Also, another bummer is that SNS doesn't natively support web push notifications so we will have to tackle that differently than iOS and Android.

iOS

In order to setup SNS to send push notifications to our iOS app, we need to generate a push notification certificate for our app and provide it to SNS. Let's head toĀ The Apple Developer PortalĀ and create our App ID.

Create APP ID

Add image appId.png
This should match yourĀ ios.bundleIdentifierĀ inĀ app.config.jsĀ (add it if it doesn't exist).

Create Apple Push Notification Certificate

Add image iosCert.png
https://help.apple.com/developer-account/#/devbfa00fef7 https://stackoverflow.com/a/28962937/6775987

Create iOS SNS Application

Add image createSNSiOS

Android

In order to setup SNS to send push notifications to our Android app, we have to create a Firebase project, retrieve the cloud messaging server key and provide it to SNS.

Retrieve Server Key

After you create aĀ Firebase project, navigate to Project Settings > Cloud Messaging and note your Server key.
Add fcmKey image

Create Android SNS Application

Add image createSNSAndroid

Web Push Notifications

For web push notifications, we have to create a DynamoDB table to store the subscription and a Lambda that, when triggered by a SNS topic, sends out the web push notifications.

Add Resources in the CDK

% cd backend % npm i @aws-cdk/aws-dynamodb@1.120.0 @aws-cdk/aws-appsync@1.120.0 @aws-cdk/aws-lambda-nodejs@1.120.0 --save-exact
Match the versions to the versions of the pre-existing dependencies.
/////// TODO ///////

Bringing it All Together

Okay, now we just have to modify our notification services to connect with our backend.

Update Mobile Notification Service

Now we have to replace the Expo token call with one to get the actual device token. Then we have to register the device with SNS.

Define Schema

Click to rocket boost to the top of the page!