expo / expo

An open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.
https://docs.expo.dev
MIT License
30.98k stars 4.91k forks source link

[unimodules][android][notifications] Event listener can miss `emit()` calls made soon after app launch #9866

Closed cruzach closed 3 years ago

cruzach commented 3 years ago

🐛 Bug Report

Summary of Issue

If you call Notifications.addResponseReceivedListener in useEffect for example, the listener will not be triggered when opening an app from a completely killed state via a notification. This can also be demonstrated by adding a timeout to componentDidMount as shown here. Without 1 second timeout- events come through; with 1 second timeout- event's do not come through.

I should note that the emit method is getting called in this case.

Environment - output of expo diagnostics & the platform(s) you're targeting

Reproducible Demo

https://snack.expo.io/@charliecruzan/push-notifications

Steps to Reproduce

Need to run the app as a published experience or standalone app (or bare debug build) to see the behavior. Press "show notification" and kill the app, tap the notification, observe that response listener isn't triggered.

Workaround

The workaround (and possibly long-term solution) is to add the listener outside of any component, then it works as expected (presumably bc of how much sooner it's getting called?). I changed the docs to suggest this so fewer people run into this issue

dmitri-wm commented 3 years ago

Hello. I'm sorry to bug, but your solutions doesn't work for killed app on android. I've tried both solutions: 1) add eventListener in componentDidMount method

import { AppLoading } from 'expo'
import AppNavigation from './navigation/AppNavigation'
import { cacheFonts, cacheImages } from './helpers/AssetsCaching'
import vectorFonts from './helpers/vector-fonts';
import { Provider, rootStore, useMst } from './models/RootStore'
import { ThemeProvider } from 'react-native-elements'
import NetInfo from '@react-native-community/netinfo';
import Theme from './constants/styles/Theme'
import * as firebase from 'firebase'
import './config/YellowboxConfig'
import './config/moment'
import { FIREBASE_CONFIG } from './config/config'
import { subscribeNotification } from './services/PushNotifiations/PushNotifications'
// import './config/ReactotronConfig'

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {assetsLoaded: false}
  }

  componentDidMount() {
    subscribeNotification()
  }

  async loadAssetsAsync ()  {
    console.log('here')
    const fontAssets = cacheFonts([
      ...vectorFonts,
      { georgia: require('./assets/fonts/Georgia.ttf') },
      { regular: require('./assets/fonts/Montserrat-Regular.ttf') },
      { light: require('./assets/fonts/Montserrat-Light.ttf') },
      { bold: require('./assets/fonts/Montserrat-Bold.ttf') },
      { UbuntuLight: require('./assets/fonts/Ubuntu-Light.ttf') },
      { UbuntuBold: require('./assets/fonts/Ubuntu-Bold.ttf') },
      { UbuntuLightItalic: require('./assets/fonts/Ubuntu-Light-Italic.ttf') },
    ]);
    await Promise.all([...fontAssets]);
  }

  render(){
    if (!this.state.assetsLoaded) {
      return (
        <AppLoading
          startAsync={this.loadAssetsAsync}
          onFinish={() => this.setState({assetsLoaded: true })}
        />
      );
    } else {
      return (
        <ThemeProvider theme={Theme}>
          <Provider value={rootStore}>
            <AppNavigation/>
          </Provider>
        </ThemeProvider>
      )
    }
  }
}

2) add eventListener in global scope

import React, { useEffect, useState } from 'react'
import { AppLoading } from 'expo'
import AppNavigation from './navigation/AppNavigation'
import { cacheFonts, cacheImages } from './helpers/AssetsCaching'
import vectorFonts from './helpers/vector-fonts';
import { Provider, rootStore, useMst } from './models/RootStore'
import { ThemeProvider } from 'react-native-elements'
import NetInfo from '@react-native-community/netinfo';
import Theme from './constants/styles/Theme'
import * as firebase from 'firebase'
import './config/YellowboxConfig'
import './config/moment'
import { FIREBASE_CONFIG } from './config/config'
import { subscribeNotification } from './services/PushNotifiations/PushNotifications'
// import './config/ReactotronConfig'

subscribeNotification()

export default function App() {
  const [assetsLoaded, setAssetsLoaded] = useState(false);

  const loadAssetsAsync = async () => {
    const fontAssets = cacheFonts([
      ...vectorFonts,
      { georgia: require('./assets/fonts/Georgia.ttf') },
      { regular: require('./assets/fonts/Montserrat-Regular.ttf') },
      { light: require('./assets/fonts/Montserrat-Light.ttf') },
      { bold: require('./assets/fonts/Montserrat-Bold.ttf') },
      { UbuntuLight: require('./assets/fonts/Ubuntu-Light.ttf') },
      { UbuntuBold: require('./assets/fonts/Ubuntu-Bold.ttf') },
      { UbuntuLightItalic: require('./assets/fonts/Ubuntu-Light-Italic.ttf') },
    ]);

    await Promise.all([...fontAssets]);
  };

  if (!assetsLoaded) {
    return (
      <AppLoading
        startAsync={loadAssetsAsync}
        onFinish={() => setAssetsLoaded(true) }
      />
    );
  }

  return (
    <ThemeProvider theme={Theme}>
      <Provider value={rootStore}>
        <AppNavigation/>
      </Provider>
    </ThemeProvider>
  );
}

Where './services/PushNotifiations/PushNotifications' is

import * as Notifications from 'expo-notifications';

export function subscribeNotification() {
  console.log('subscribing')
  Notifications.addNotificationReceivedListener((response)=>console.log('foreground'))
  Notifications.addNotificationResponseReceivedListener((response)=>console.log('background'))
}

Im app logs I see

Finished building JavaScript bundle in 74ms.
subscribing // <------ this is called from subscribeNotification()
Running application on SM-G955F.

package.json

{
  "main": "node_modules/expo/AppEntry.js",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "eject": "expo eject"
  },
  "dependencies": {
    "@expo/vector-icons": "^10.0.0",
    "@react-native-community/masked-view": "0.1.10",
    "@react-native-community/netinfo": "5.9.2",
    "@react-navigation/drawer": "^5.0.0",
    "@react-navigation/native": "^5.0.0",
    "@react-navigation/stack": "^5.0.0",
    "expo": "^38.0.0",
    "expo-blur": "~8.1.2",
    "expo-font": "~8.2.1",
    "expo-image-picker": "~8.3.0",
    "expo-notifications": "^0.5.0",
    "firebase": "7.9.0",
    "formik": "^2.1.4",
    "frisbee": "^3.1.2",
    "lodash": "^4.17.15",
    "md5": "^2.3.0",
    "mobx": "^5.15.4",
    "mobx-react-lite": "^1.5.2",
    "mobx-state-tree": "^3.15.0",
    "moment": "^2.24.0",
    "mst-persist": "^0.1.3",
    "react": "16.11.0",
    "react-dom": "16.11.0",
    "react-native": "https://github.com/expo/react-native/archive/sdk-38.0.2.tar.gz",
    "react-native-collapsible": "^1.5.1",
    "react-native-elements": "^2.2.1",
    "react-native-expo-image-cache": "^4.1.0",
    "react-native-gesture-handler": "~1.6.0",
    "react-native-keyboard-accessory": "^0.1.10",
    "react-native-masked-text": "^1.13.0",
    "react-native-modal-dropdown": "^0.7.0",
    "react-native-reanimated": "~1.9.0",
    "react-native-safe-area-context": "~3.0.7",
    "react-native-screens": "~2.9.0",
    "react-native-snap-carousel": "^3.8.4",
    "react-native-timeago": "^0.5.0",
    "react-native-tiny-toast": "^1.0.6",
    "react-native-web": "~0.11.7",
    "reactotron-react-native": "^4.0.3",
    "yup": "^0.28.1"
  },
  "devDependencies": {
    "@babel/core": "^7.0.0",
    "babel-preset-expo": "^8.2.3"
  },
  "private": true
}

Testing on android device, SDK 38, Managed flow.

  1. App is hidden but not closed - notification works properly (when tapped on it - notification data passed to callback).
  2. App is opened - notification works properly.
  3. App is killed - tapping on notification app is opened but data not passed to callback.

Any more suggestions ? :)

cruzach commented 3 years ago

If you're testing this while running locally in the Expo client, e.g. with expo start, notification responses don't come through when the app is killed. You should build your app with expo build:android

Aryk commented 3 years ago

Hi @cruzach - You wrote on the other thread:

If you're seeing this issue on iOS in the managed workflow, as I mentioned above, this was a bug on iOS and has since been solved (see #9478). You'll need to wait for SDK 39 for a fix (we plan on releasing at the end of Q3, so not far away). On Android, whether you're in the bare or managed workflow, you can get the correct behavior by calling addNotificationResponseReceivedListener outside of any component, i.e. the same place you're probably calling setNotificationHandler. (I'll update the docs example to include this, but here's an example provided in other bug report concerning the same behavior)

1) You mentioned "the same place you're probably calling setNotificationHandler"...Does setNotificationHandler need to be outside of the component as well? It's currently inside for me and working on android/managed workflow. I'm curious if maybe it breaks in certain cases if I don't put it outside?

2) If you run the addNotificationResponseReceivedListener outside of the component, this implies that the components have not rendered yet which means we may not have access to our navigation library and other things only available until milliseconds later. Is there a recommended way to approach this case? I guess we have to store the responses somewhere and then check when the components load in?

cruzach commented 3 years ago
  1. No, there's no problem setting it inside a component so you should be fine

  2. I'm assuming you're using notification responses for deep linking somehow, in which case one way to handle that is by calling Linking.openUrl(<scheme://path/to/screen/with?query=param>) inside the response listener, that way you won’t need access to the navigation props

giautm commented 3 years ago

Hi @Aryk , I want to share how I workaround this issue on both iOS and Android.

In App.tsx, I receive exp.notification from props and save it into a global variable.

import { _setInitialNotification } from 'src/notificationHandler'
// ....

export default function App({ exp }) {
  const didInitialize = React.useRef(false)
  if (!didInitialize.current) {
    if (exp.notification) {
      // NOTE(giautm): Workaround for initial notification issue until SDK39 release.
      // https://github.com/expo/expo/issues/6943#issuecomment-677901678
      _setInitialNotification(exp.notification)
    }

    didInitialize.current = true
  }
// ....

In notificationHandler.ts

let _initialNotification = null
let _called = false

export function _setInitialNotification(notification: Notification) {
  _initialNotification = notification
}

export function _handleInitialOnce(listener: (data: any) => void) {
  if (!_called) {
    _called = true
    if (_initialNotification) {
      listener(_initialNotification.data)
    }
  }
}

In my Navigation (react-navigation@v5), I call _handleInitialOnce with listener to process initial notification.

type ScreenOpener = (screen: string, params: { [key: string]: any }) => void

function useRemoteNotification(opener: ScreenOpener) {
  useEffect(() => {
    const listener = (data: any) => {
      Analytics.track('NOTIFICATION_HANDLE', data)
      if (data?.screen) {
        const { params = {} } = data
        opener(data.screen as string, params)
      }
    }

    _handleInitialOnce(listener)

    const subscription = Notifications.addNotificationResponseReceivedListener(
      response => listener(response.notification.request.content.data?.body),
    )
    return () => subscription.remove()
  }, [opener])
}
Aryk commented 3 years ago

Good stuff, thanks for sharing. I'm going to give it a try but still seems like it won't always work on android. According to this guy, the exp.notification will not come in on android:

https://github.com/expo/expo/issues/6943#issuecomment-666899620

Maybe it does on some phones, but not all of them.

Seems like you have to combine your approach with putting the addNotificationResponseReceivedListener outside as well.

Has the exp.notification consistently come through for you on various android phones?

======

I don't have deep linking working yet, but I should. I will try the Linking.openUrl(<scheme://path/to/screen/with?query=param>) approach.

@cruzach, can you comment on the exp.notification approach for iOS, should I be able to get it working using the approach above and reading the notification through exp.notification?

Hi @Aryk , I want to share how I workaround this issue on both iOS and Android.

In App.tsx, I receive exp.notification from props and save it into a global variable.

import { _setInitialNotification } from 'src/notificationHandler'
// ....

export default function App({ exp }) {
  const didInitialize = React.useRef(false)
  if (!didInitialize.current) {
    if (exp.notification) {
      // NOTE(giautm): Workaround for initial notification issue until SDK39 release.
      // https://github.com/expo/expo/issues/6943#issuecomment-677901678
      _setInitialNotification(exp.notification)
    }

    didInitialize.current = true
  }
// ....

In notificationHandler.ts

let _initialNotification = null
let _called = false

export function _setInitialNotification(notification: Notification) {
  _initialNotification = notification
}

export function _handleInitialOnce(listener: (data: any) => void) {
  if (!_called) {
    _called = true
    if (_initialNotification) {
      listener(_initialNotification.data)
    }
  }
}

In my Navigation (react-navigation@v5), I call _handleInitialOnce with listener to process initial notification.

type ScreenOpener = (screen: string, params: { [key: string]: any }) => void

function useRemoteNotification(opener: ScreenOpener) {
  useEffect(() => {
    const listener = (data: any) => {
      Analytics.track('NOTIFICATION_HANDLE', data)
      if (data?.screen) {
        const { params = {} } = data
        opener(data.screen as string, params)
      }
    }

    _handleInitialOnce(listener)

    const subscription = Notifications.addNotificationResponseReceivedListener(
      response => listener(response.notification.request.content.data?.body),
    )
    return () => subscription.remove()
  }, [opener])
}
cruzach commented 3 years ago

exp.notification is not documented and not explicitly supported, so relying on that feature isn't recommended

Aryk commented 3 years ago

exp.notification is not documented and not explicitly supported, so relying on that feature isn't recommended

Got it, so for ios is there any work around at the moment (besides ejecting)? I suppose checking for exp.notification and using that is better than nothing.

cruzach commented 3 years ago

Using the legacy notifications API listener works, as well. Other than that there's no workaround besides waiting for SDK 39 🙁 sorry that you pretty much just have to wait

dmitri-wm commented 3 years ago

If you're testing this while running locally in the Expo client, e.g. with expo start, notification responses don't come through when the app is killed. You should build your app with expo build:android

Thank you for reply, yes this works. Just to clarify - to test any changes related to handling notifications when app is killed I have 2 options 1) eject from expo and test locally 2) create expo build. No other options, correct?

Thanks again for your help

cruzach commented 3 years ago

You can also run expo publish and test in the Expo client app through the published project

arveenkumar55 commented 3 years ago

@cruzach For me addNotificationResponseReceivedListener doesn't trigger only when the app is closed already or user force closes it, it triggers fine when the app is foregrounded or backgrounded, even it works in expo-client if its foregrounded or backgrounded, I am testing on Expo SDK 38 and the physical Android device. Here is the code:

`import React from 'react' import { StyleSheet, Dimensions, ScrollView, ActivityIndicator } from 'react-native' import { Block, theme } from 'galio-framework' import { connect } from 'react-redux' import { bindActionCreators } from 'redux' import as Notifications from 'expo-notifications'; import as Permissions from 'expo-permissions' import Constants from 'expo-constants'; const { width } = Dimensions.get('screen')

Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: false, shouldSetBadge: false, }), }); class Dashboard extends React.Component { constructor (props, context) { super(props, context) state = { expoPushToken: '', notification: {}, } } componentDidMount () { this.props.getAutomatedOptions() this.registerForPushNotificationsAsync() this._notificationSubscription = Notifications.addNotificationResponseReceivedListener(this._handleNotification) this._notificationListener = Notifications.addNotificationReceivedListener(notification => { // console.log('received_notification', notification) });

}

_handleNotification = notification => { this.setState({ notification: notification.notification }) console.log('notification.origin', notification.notification.request.content.data) };`

xLesterGG commented 3 years ago
  1. I'm assuming you're using notification responses for deep linking somehow, in which case one way to handle that is by calling Linking.openUrl(<scheme://path/to/screen/with?query=param>) inside the response listener, that way you won’t need access to the navigation props

@cruzach is there anything about linking not working when it's running via addResponseReceivedListener? As linking (opening a particular page via Linking.openURL) seems to work when the app is not killed (routes to the correct page) but only opens the app when the app is killed.

cruzach commented 3 years ago

I opened this new issue to try and keep the conversation focused around the root issue, and this thread is turning into troubleshooting one of the resulting problems, which I've already shared the answer for in the original issue- https://github.com/expo/expo/issues/6943. Any comments posted here that are answered in the other issue will be marked as off-topic (sorry to do this, but too many off-topic comments makes discussing the original issue I posted much more difficult)

@arveenkumar55 please refer to this comment

@xLesterGG as far as I know there's no problem running Linking.openUrl from addResponseReceivedListener. However, maybe it's easier for you to follow this react navigation guide on navigating outside of your component, rather than using Linking.openUrl

ozerty commented 3 years ago

Hi @cruzach, Works when app is in background, but not when the app is killed. The issue still persist on Android with both SDK 38 and SDK 39 in a managed workflow. I try to move out addNotificationResponseReceivedListener outside of my component as suggested, but it doesn't work.

Thanks

JamieS1211 commented 3 years ago

It is noted in the React documentation that useEffect is called after paint and useLayoutEffect is called 'synchronously after all DOM mutations' hence 'useLayoutEffect fires in the same phase as componentDidMount and componentDidUpdate'.

https://reactjs.org/docs/hooks-reference.html#timing-of-effects https://reactjs.org/docs/hooks-reference.html#uselayouteffect

As such I have tested useLayoutEffect and still had no success.

Further to that, my specific implementation to navigate on notification was rather inconvenient when being placed outside the navigation container. For any others who have this issue and use ReactNavigation, my specific workaround here was to make a navigation container ref with a promise version, allowing await for the navigation to be mounted before completing the notification handling.

Setup the ref for the navigation container (ignore the typescript typings if relevant), the navigation ref promise only resolves after items have been mounted inside the navigation container (this is reason for checking "getRootState())".

import { createRef } from "react"

import { NavigationContainerRef } from "@react-navigation/core"

const navigationRef = createRef<NavigationContainerRef>()

export const navigationRefPromise: Promise<typeof navigationRef> = new Promise((resolve) => {
    const checkValue = () => {
        if (navigationRef.current && navigationRef.current.getRootState()) {
            return resolve(navigationRef)
        }

        setTimeout(checkValue, 50)
    }

    checkValue()
})

export default navigationRef

When processing the notification, if navigation data is present and results in a valid navigation action (based on navigation linking config), complete the navigation dispatch after the navigation ref promise has resolved.

addNotificationResponseReceivedListener((notificationResponse) => {
    const notificationData = notificationResponse.notification.request.content.data

    if (hasKeyGuard(notificationData, "navigationLink") && typeof notificationData.navigationLink === "string") {
        try {
            const state = getStateFromPath(notificationData.navigationLink, linking.config)

            if (state) {
                const action = getActionFromState(state)

                navigationRefPromise.then((navRef) => {
                    if (action && navRef.current) {
                        navRef.current.dispatch(action)
                    }
                })
            }
        } catch (error) {
            alert(error)
        }
    }
})
KlasRobin commented 3 years ago

Hi @cruzach, Works when app is in background, but not when the app is killed. The issue still persist on Android with both SDK 38 and SDK 39 in a managed workflow. I try to move out addNotificationResponseReceivedListener outside of my component as suggested, but it doesn't work.

Thanks

Same problem here. Have also tried moving addNotificationResponseReceivedListener outside of App.js with no luck. Background and foreground works fine. Edit: Running SDK 39 in managed workflow.

antwaven commented 3 years ago

Hi @cruzach, Works when app is in background, but not when the app is killed. The issue still persist on Android with both SDK 38 and SDK 39 in a managed workflow. I try to move out addNotificationResponseReceivedListener outside of my component as suggested, but it doesn't work. Thanks

Same problem here. Have also tried moving addNotificationResponseReceivedListener outside of App.js with no luck. Background and foreground works fine. Edit: Running SDK 39 in managed workflow.

Hi, I too have the exact same issue. SDK 39 managed workflow. Tried it on standalone Android and iOS, inside useEffect or in the same place as Notifications.setNotificationHandler with no luck. If I can provide any other information to help find this issue please let me know.

coopbri commented 3 years ago

Confirming same problem as @KlasRobin, @antwaven, and @ozerty. Background/foreground work fine, just not when app is killed/not running. Also managed workflow, SDK 39.

beDenz commented 3 years ago

Yes. Same thing. Doesn`t trigger when app is fully closed. Expo 39, android. iOs works perfect.

andreas-arkulpa commented 3 years ago

I can also confirm the same issue. Also moving addNotificationResponseReceivedListener to App.js doesn't worked.

abdymm commented 3 years ago

same issue here as @coopbri, @KlasRobin, @antwaven, and @ozerty. Background/foreground work fine, just not when app is killed/not running. Tested in Android managed workflow, also in standalone apps, SDK 38.

uchamb commented 3 years ago

Same issue here. Tried all the suggested workarounds, nothing works. Also moving addNotificationResponseReceivedListener to App.js doesn't work. Updated to SDK 39 but the same issue.

There is no problem on iOS

andreas-arkulpa commented 3 years ago

when using the old notifications-api, there is also the same issue on android. Have you got any suggestions?

franamu commented 3 years ago

Same issue, I follow documentation https://docs.expo.io/versions/latest/sdk/notifications/ but it doesn't work, addNotificationResponseReceivedListener but also doesn't work:

notificationListener.current = Notifications.addNotificationReceivedListener(notification => { setNotification(notification); });

the listener doesn`t trigger on Android using Expo SDK 39.

coopbri commented 3 years ago

@franamu Are you saying addNotificationReceivedListener doesn't work for you? That method is only supposed to work when the app is foregrounded or backgrounded. addNotificationResponseReceivedListener is the only method between the two that is supposed to work even when the app is closed.

franamu commented 3 years ago

@coopbri yep the two listeners do not work at all on Android.

I can receive the push notification but I can´t read the data or execute a function.. I just only see the notification.

coopbri commented 3 years ago

That is strange. addNotificationReceivedListener works for me inside of a useEffect in the App component:

const App = () => {
  useEffect(() => {
    const sub = Notifications.addNotificationReceivedListener((res) => { ... });

    return () => {
      sub.remove();
    };
  }, []);

   return (...);
};

EDIT: Android, SDK 39

franamu commented 3 years ago

@coopbri yes my bad, addNotificationReceivedListener it is working as expected i forgot to specify the channelId for Android Device.

The request should be:

With Axios:

var axios = require('axios');
var data = JSON.stringify({"to":"ExponentPushToken[...]","channelId":"1","sound":"default","title":"Title Example","body":"And here is the body!","data":{"data":"goes here"}});

var config = {
  method: 'post',
  url: 'https://exp.host/--/api/v2/push/send',
  headers: { 
    'Content-Type': 'application/json'
  },
  data : data
};

axios(config)
.then(function (response) {
  console.log(JSON.stringify(response.data));
})
.catch(function (error) {
  console.log(error);
});
andreas-arkulpa commented 3 years ago

@franamu does that work with a cold-start on android as well? Does the channelId do something on ios? did you create the channeld 1 on the device or is it default?

coopbri commented 3 years ago

@franamu Awesome! Glad that works at least.

franamu commented 3 years ago

@andreas-arkulpa nop if I close the app doesn't work but we can try generating the apk and test it. channelId is only for Android. The 1 it just an example but works fine to test it (is not the default). I will create the apk and see if it works.

andreas-arkulpa commented 3 years ago

@franamu thx for the information! Would be nice if you can let me know it it works at startup with the generated apk. :)

sjchmiela commented 3 years ago

Me, trying to reproduce the issue

Hey all, I have been trying to reproduce this issue and the best I have come up with was failing to get the notification response when the listener is being registered in useEffect. I was not able to not get the response when registering the listener in global scope or useLayoutEffect.

When it comes to timing, I have been consistently getting similar outputs:

14:00:26.571: Listener registered in global scope
14:00:26.783: Listener registered in useLayoutEffect
14:00:26.798: Global scope listener triggered
14:00:26.808: useLayoutEffect listener triggered
14:00:26.822: Listener registered in useEffect

There is a possibility there's a bug in expo-notifications that will be solved by #10773 soon (pending response being consumed by non-emitter, thus never being emitted to JS), but from my testing usually listeners registered in global scope or useLayoutEffect should work.

An example of a super simple app that looks like it works

My suggestion is to always register notification response listener in the global scope (useLayoutEffect probably works here because I'm not doing any fancy stuff like preloading assets, loading state from AsyncStorage etc. before rendering the application).

import * as Notifications from "expo-notifications";
import React, { useState, useEffect, useRef, useLayoutEffect } from "react";

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

Notifications.addNotificationResponseReceivedListener(
  () => console.log("Global scope listener triggered")
);
console.log("Listener registered in global scope");

export default function App() {
  useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener(
      () => console.log("useEffect listener triggered")
    );
    console.log("Listener registered in useEffect");

    return () => {
      subscription.remove();
    };
  }, []);

  useLayoutEffect(() => {
    const subscription =  Notifications.addNotificationResponseReceivedListener(
      () => console.log("useLayoutEffect listener triggered")
    );
    console.log("Listener registered in useLayoutEffect");

    return () => {
      subscription.remove();
    };
  }, []);

  return (/* ...doesn't matter... */);
}

A should-work solution that may end up in expo-notifications if it works for all of you

I would like to provide some sensible solution for all problems you may face… I've come up with something along the lines of:

import { Subscription } from "@unimodules/core";
import * as Notifications from "expo-notifications";
import { NotificationResponse } from "expo-notifications";
import { useEffect, useLayoutEffect, useState } from "react";
import { NativeEventEmitter } from "react-native";

// Event emitter used solely for the purpose
// of distributing initial notification response
// to useInitialNotificationResponse hook
const eventEmitter = new NativeEventEmitter();

// Initial notification response caught by
// global subscription
let globalInitialNotificationResponse:
  | NotificationResponse
  | undefined = undefined;

// A subscription for initial notification response,
// cleared immediately once we believe we have caught
// the initial notification response or there will be none
// (by useInitialNotificationResponse hook).
let globalSubscription: Subscription | null = Notifications.addNotificationResponseReceivedListener(
  (response) => {
    // If useInitialNotificationResponse is already registered we want to
    // notify it
    eventEmitter.emit("response", response);
    // If useInitialNotificationResponse isn't registered yet, we'll provide it
    // with good initial value.
    globalInitialNotificationResponse = response;
    console.log("Global scope listener triggered");
    ensureGlobalSubscriptionIsCleared();
  }
);
console.log("Listener registered in global scope");

function ensureGlobalSubscriptionIsCleared() {
  if (globalSubscription) {
    globalSubscription.remove();
    globalSubscription = null;
    console.log("Global scope listener removed");
  }
}

/**
 * Returns an initial notification response if the app
 * was opened as a result of tapping on a notification,
 * null if the app doesn't seem to be opened as a result
 * of tapping on a notification, undefined until we are sure
 * of which to return.
 */
export function useInitialNotificationResponse() {
  const [
    initialNotificationResponse,
    setInitialNotificationResponse,
  ] = useState<NotificationResponse | null | undefined>(
    globalInitialNotificationResponse
  );

  useLayoutEffect(() => {
    // Register for internal initial notification response events
    const subscription = eventEmitter.addListener("response", (response) => {
      console.log("Inner listener triggered");
      setInitialNotificationResponse(response);
    });
    console.log("Inner listener registered");
    // In case global subscription has already triggered
    // and we missed the eventEmitter notification reset the value
    setInitialNotificationResponse(
      (currentResponse) => currentResponse ?? globalInitialNotificationResponse
    );
    // Clear the subscription as hook cleanup
    return () => subscription.remove();
  }, []);

  useEffect(() => {
    // If there was an "initial notification response"
    // it has already been delivered.
    ensureGlobalSubscriptionIsCleared();
    // Ensure the value is not undefined (if by this time
    // it's still undefined there was no "initial notification response").
    setInitialNotificationResponse((currentResponse) => currentResponse ?? null);
  }, []);

  return initialNotificationResponse;
}

could you please try and use it in your project and see if it allows you to get the initial notification response if there should be one? The useInitialNotificationResponse hook should be able to replace any global scope listeners you may have registered for this purpose.

Note I have only tested it on Android.

How to help me debug the may-be solution if it doesn't work for you

If it doesn't work for you, could you please let me know and attach logs from the device? An example would be:

2020-10-23 19:45:25.053 15941-15970/? I/ReactNativeJS: Listener registered in global scope
2020-10-23 19:45:25.057 15941-15970/? I/ReactNativeJS: Running "main" with {"rootTag":1}
2020-10-23 19:45:25.093 15941-15970/? I/ReactNativeJS: { initialNotificationResponse: undefined } (from console.log inside the app)
2020-10-23 19:45:25.239 15941-15970/? I/ReactNativeJS: Inner listener registered
2020-10-23 19:45:25.245 15941-15970/? I/ReactNativeJS: Inner listener triggered
2020-10-23 19:45:25.247 15941-15970/? I/ReactNativeJS: Global scope listener removed
2020-10-23 19:45:25.262 15941-15970/? I/ReactNativeJS: { initialNotificationResponse: … } (from console.log inside the app)
ozerty commented 3 years ago

Thanks @sjchmiela I will give a try !

uchamb commented 3 years ago

I just fixed the problem by adding "useNextNotificationsApi": true in the config file. Looks like I totally missed this part when reading the docs. It solved all of my problems. Now, addNotificationResponseReceivedListener works fine everywhere.

sjchmiela commented 3 years ago

@ozerty — thanks! 🙇

@uchamb — oh shoot, sorry! Should have thought of this when reading works on iOS, doesn't work on Android instead of blaming the Platform immediately. 😅 Happy to hear everything works ok now!

coopbri commented 3 years ago

Awesome @sjchmiela! I can tell you spent a lot of time on that. Unfortunately I already have useNextNotificationsApi: true set in my config unlike @uchamb without luck, but I will give your solution a try and report back. Thanks for all of your hard work.

milennaj commented 3 years ago

Any news on this issue? I have also used useNextNotificationsApi: true but still no notification response when the listener is being registered in useEffect of the component. I was not able to get the response when registering the listener in global scope or useLayoutEffect. For us it is important to use navigation when notification is taped so to avoid complications we need it in components, not App.tsx.

Thanks

coopbri commented 3 years ago

Sorry it took me so long! Trying your solution, @sjchmiela:

Both of the above are while the app was not running, and I tested multiple times with consistency.

Like @milennaj, my use case involves navigation (with React Navigation) and I am trying to trigger the listener inside of a deeper component as well (specifically, the first screen in the navigator).

EDIT: I noticed also that the same applies to the first screen (component) in my navigator, the useEffect/useLayoutEffect consistently trigger after a notification is pressed, however the addNotificationResponseReceivedListener code does not.

Maoravitan commented 3 years ago

any news regarding this bug? i tried everything and as @coopbri already said nothing is work for the case when app was killed (Android) 🙁, addNotificationResponseReceivedListener is not fired when pressing on he push notification.

somecatdad commented 3 years ago

@Maoravitan have you tried using addNotificationResponseReceivedListener inside a class component as opposed to a functional component? This was the approach that worked for me.

Aryk commented 3 years ago

@sealedHuman - Could you show us a quick example? Do you put the Notifications.addNotificationResponseReceivedListener call in the componentDidMount or componentWillMount?

Aryk commented 3 years ago

I tried:

class Test extends React.Component {

  componentDidMount() {
    Notifications.addNotificationResponseReceivedListener(
      response => Alert.alert(undefined, "first")
    )
  }

  render () {
    return null;
  }
}

And put that as the first element that rendered in my app and it also didn't work.

I also tried putting this at the very top of my app.tsx as the basically the first thing that gets called.

Notifications.addNotificationResponseReceivedListener(
  response => Alert.alert(undefined, "first")
)

I could not get it to work consistently. Sometimes it would work from a dead start, then I rebuild the app, try again, and it doesn't work.

Running Galaxy S8 on Managed 39 SDK.

I have a large app and Galaxy S8 is a slow phone, could that have something to do with it? It's strange because I install the listener as literally the first thing in the app and it still doesn't work.

I've tried also putting it in useLayoutEffect on the top level component and that also didn't work.

I'm completely out of ideas.

@sjchmiela, how exactly does your solution work in the case where the addNotificationResponseReceivedListener is not getting fired at all?

somecatdad commented 3 years ago

@Aryk - I am basically doing what you are doing in that code snippet you posted. I did have an issue where I had to move some asynchronous font loading I was doing in order to ensure that the app was rendering my notification handler component straight away and not being delayed in any way.

In your example code, where is Test component being rendered?

Also, you probably have this set, but just checking. Do you have have "useNextNotificationsApi": true, set in app.json?

Aryk commented 3 years ago

@sealedHuman, yes I have useNextNotificationsApi: true in my app.json. Thx for checking.

I literally put the as the very first component in the app , even before the loading screen and rest of the app. At this point, I'm just trying to get the thing triggered consistently from a dead-start on Android...I've been up all night literally building and rebuilding, and trying different configurations but the best I could get was to have it work 3-4 times out of about 25 times and not even consistently work on the same build.

I mean, if I'm literally calling this:

Notifications.addNotificationResponseReceivedListener(
  response => Alert.alert(undefined, "first")
)

at the very top of my app, what else can I possible do better than that? Is it "too early" in the execution maybe?

Aryk commented 3 years ago

The workaround (and possibly long-term solution) is to add the listener outside of any component, then it works as expected (presumably bc of how much sooner it's getting called?). I changed the docs to suggest this so fewer people run into this issue

@cruzach - I'm not sure it has to do with how soon it's getting called because I put the addNotificationResponseReceivedListener call at the very beginning of my app just to see if I can get the listener to trigger and I cannot get it to trigger from a dead-start on Android more than 5% of the time.

Could it be that the event that is triggering the listener could potentially not be getting the notification on a dead start? So therefore, despite where I'm installing the listener, it will never get called?

somecatdad commented 3 years ago

@Aryk - That is a head-scratcher, for sure. Do you happen to have a full snack I could look at?

Aryk commented 3 years ago

That's the problem, I can't reproduce it, but I will try today.

@coopbri @Maoravitan @ozerty @milennaj @beDenz @andreas-arkulpa @abdymm @uchamb

Are you guys still experiencing this issue? Anyone of you have been able to get it working by putting this call first in your app:

Notifications.addNotificationResponseReceivedListener(
  response => Alert.alert(undefined, "first")
)
milennaj commented 3 years ago

Yes, no matter if it is in the component or outside of component at the beginning of App.tsx this event never fires when app is closed. Tested on Motorola - moto g pro device.