[Expo] Android notifications are not received in-app when it's closed

Since Expo has released SDK 40, we are obligate to migrate to submodule expo-notifications. It’s the right way of development, but this library seems to have some problems, which we have to face up to.

New notification’s payload

First of all: the authors changed the notification payload. In the new version, there are two listeners for receiving notification and receiving responses from notifications. Both get a bit different attributes, but they have much more meta-information about notification than in the previous solution (Notifications exported directly from expo package). It’s obviously good, but be prepared for this and in some cases, you’ll have to write mappers from the old payload to the new one.

Android notification problem

This lib has also one issue (confirmed on bare workflow). When your standalone app is killed on Android device, your app is not informed about new notifications. You can tap on system notification, but your application won’t know that anything received. It’s annoying if you want to react somehow on the notification (e.g. chat message notification should hotlink to this particular conversation).

It’s a known issue, still in progress, but devs found some dirty workaround, how to handle this problem, by moving the listener to the global scope. Here you can find a sample implementation of it.

In the beginning, we’ll create some functions to handle the notification stack for Android platform

import { Subscription } from '@unimodules/core'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'

const androidOffNotificationStack: Notifications.Notification[] = []
let androidOffNotificationListener: Subscription | null = null

export const enableAndroidOffNotificationListener = () => {
  if (Platform.OS !== 'android') {
    return
  }

  androidOffNotificationListener = Notifications.addNotificationResponseReceivedListener(({ notification }) => {
    androidOffNotificationStack.push(notification)
  })
}

export const disableAndroidOffNotificationListener = () => {
  androidOffNotificationListener && androidOffNotificationListener.remove()
}

export const getAndroidOffNotificationFromStack = () => androidOffNotificationStack.shift()

export const androidOffNotificationStackLength = () => androidOffNotificationStack.length

As you may see - we gathered all notifications in one array before the app is started. We have also some helpers to manage this array (we don’t allow any changes without our control).

Let’s run our listener. This is the ugly part: we have to do this in a global scope. So, in App.ts, we run our enableAndroidOffNotificationListener() at the very beginning of the file and we wait for any new notifications. They will be emitted and gathered when the app is running.

import { enableAndroidOffNotificationListener } from '../notification'
import * as React from 'react'

enableAndroidOffNotificationListener()

export class App extends React.Component<{}, State> {
...

The last thing we have to do is to process all gathered notifications when the app is ready. We can do this when we are sure that our permissions are set and where we set our “normal” listeners. I like to put all this logic in one NotificationListener.tsx component like this:

import {
  androidOffNotificationStackLength,
  disableAndroidOffNotificationListener,
  getAndroidOffNotificationFromStack,
  NotificationAction,
} from '../notification'
import * as Notifications from 'expo-notifications'
import * as Permissions from 'expo-permissions'
import * as React from 'react'
import { useDispatch } from 'react-redux'

export const NotificationListener = () => {
  const dispatch = useDispatch()

  React.useEffect(() => {
    void getNotificationPermission().then(granted => {
      granted && dispatch(NotificationAction.getExpoToken())
    })

    while (androidOffNotificationStackLength() > 0) {
      const notification = getAndroidOffNotificationFromStack()
      notification && dispatch(NotificationAction.receivedPushNotification(notification))
    }
    disableAndroidOffNotificationListener()

    const notificationResponseReceived = Notifications.addNotificationResponseReceivedListener(({ notification }) => {
      dispatch(NotificationAction.receivedPushNotification(notification))
    })

    const notificationReceived = Notifications.addNotificationReceivedListener(notification => {
      dispatch(NotificationAction.receivedPushNotification(notification))
    })

    return () => {
      Notifications.removeNotificationSubscription(notificationResponseReceived)
      Notifications.removeNotificationSubscription(notificationReceived)
    }
  }, [])

  return null
}

async function getNotificationPermission(): Promise<boolean> {
  const { status: existingStatus } = await Permissions.getAsync(Permissions.NOTIFICATIONS)

  if (existingStatus !== Permissions.PermissionStatus.GRANTED) {
    const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS)

    if (status !== Permissions.PermissionStatus.GRANTED) {
      return false
    }
  }

  return true
}

We run while loop and check if there are any notifications in our stack - if yes: we dispatch them. After that we unsubscribe our android-off-listener and register new once, with common rules.

Summary

expo-notification is the right step in Expo development, but we have to keep in mind that this is still a fresh solution and it could have some issues like this above. Anyway - we should keep our apps up to date to avoid problems with store review or new device compatibility.