Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug]: Expo notification listeners stop working when used alongside OneSignal #1742

Open
2 of 3 tasks
rshettyawaken opened this issue Sep 18, 2024 · 2 comments
Open
2 of 3 tasks

Comments

@rshettyawaken
Copy link

rshettyawaken commented Sep 18, 2024

What happened?

Here is my React Context Provider with OneSignal code:

import { useMutation } from '@tanstack/react-query';
import {
  setNotificationHandler,
  addNotificationReceivedListener,
  addNotificationResponseReceivedListener,
  removeNotificationSubscription,
  setNotificationChannelAsync,
  getExpoPushTokenAsync,
  Subscription,
  ExpoPushToken,
  Notification,
  AndroidImportance,
} from 'expo-notifications';
import React, {
  createContext,
  FC,
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react';
import { Platform } from 'react-native';
import { OneSignal } from 'react-native-onesignal';
import { v4 as uuidv4 } from 'uuid';

import { UserResponse } from '@app-types/user';
import { DEVICE_ID, HTTP_ENDPOINTS } from '@constants';
import { useAppState } from '@hooks/app-state';
import { useAuth } from '@hooks/auth';
import { useSyncStorage } from '@hooks/sync-storage';
import {
  captureException,
  EXCEPTION_TAG_TYPES,
  getEnvironmentConfig,
  getErrorMessage,
  httpClient,
} from '@utils';

import {
  PushNotificationContextType,
  PushNotificationProviderProps,
} from './push-notification.type';

// The below method is invoked when a user receives a push notification
setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

async function getExpoPushNotificationToken() {
  let token: ExpoPushToken | null = null;

  try {
    if (Platform.OS === 'android') {
      await setNotificationChannelAsync('default', {
        name: 'default',
        importance: AndroidImportance.MAX,
        vibrationPattern: [0, 250, 250, 250],
        lightColor: '#FF231F7C',
      });
    }

    token = await getExpoPushTokenAsync({
      projectId: getEnvironmentConfig().easProjectId,
    });
  } catch (error) {
    console.error('Failed to set notification channel', getErrorMessage(error));

    throw error;
  }

  return token;
}

const PushNotificationContext = createContext<PushNotificationContextType>({
  notification: null,
  notificationEnabled: false,
});

const PushNotificationProvider: FC<PushNotificationProviderProps> = ({
  children,
}) => {
  const [notification, setNotification] = useState<Notification | null>(null);
  const notificationListener = useRef<Subscription>();
  const responseListener = useRef<Subscription>();
  const { get, set } = useSyncStorage();
  const { setUser, setAdminUser, user, adminUser } = useAuth();
  const { appState } = useAppState();
  const userRef = useRef(adminUser ?? user);
  const previousAppState = useRef(appState);
  const [notificationEnabled, setNotificationEnabled] = useState(false);

  // Function to generate a unique device ID
  const generateDeviceId = async () => {
    let deviceId = get<string>(DEVICE_ID);

    if (!deviceId) {
      // Generate a new unique ID using device information and a random string
      deviceId = uuidv4();

      // Store the device ID in AsyncStorage for future use
      try {
        await set(DEVICE_ID, deviceId);
      } catch (error) {
        throw new Error('Failed to store device ID in AsyncStorage');
      }
    }

    return deviceId;
  };

  const { mutateAsync } = useMutation({
    mutationFn: async (token: string) => {
      const deviceId = await generateDeviceId();

      return await httpClient.post(HTTP_ENDPOINTS.notificationTokens, {
        notification_token: {
          token,
          platform: Platform.OS,
          provider: 'expo',
          device_id: deviceId,
        },
      });
    },
  });

  // Function to check if the device is already registered for push notification
  const isDeviceRegistered = useCallback(() => {
    const deviceId = get<string>(DEVICE_ID);

    const hasDeviceId = userRef.current?.notification_tokens?.find(
      token => token.device_id === deviceId,
    );

    return !!deviceId && !!hasDeviceId;
  }, [get]);

  // Function to save the push notification token to the server
  const savePushNotificationToken = useCallback(async () => {
    try {
      if (isDeviceRegistered()) {
        return;
      }

      const token = await getExpoPushNotificationToken();

      if (token?.data) {
        // Generate a new device ID if the device is not already registered
        // and save the push notification token to the server
        await mutateAsync(token.data);

        if (adminUser) {
          // Fetch the admin user profile after saving the push notification token
          const response = await httpClient.get<UserResponse>(
            HTTP_ENDPOINTS.userProfile,
            {
              headers: {
                'X-Use-Admin-Token': 'true',
              },
            },
          );
          setAdminUser(response.data.result);
        } else {
          // Fetch the user profile after saving the push notification token
          const response = await httpClient.get<UserResponse>(
            HTTP_ENDPOINTS.userProfile,
          );

          // Update the user context with the latest user profile
          setUser(response.data.result);
        }
      }
    } catch (error) {
      console.error(
        'Failed to initialise push notification!',
        getErrorMessage(error),
      );

      captureException(getErrorMessage(error), {
        extra: {
          context: 'expo-push-notification',
          method: 'savePushNotificationToken',
        },
        tags: {
          type: EXCEPTION_TAG_TYPES.DEVICE,
        },
      });
    }
  }, [adminUser, isDeviceRegistered, mutateAsync, setAdminUser, setUser]);

  // Function to handle the complete (OneSignal and Expo) notification permission flow
  const handleNotificationPermissionFlow = useCallback(async () => {
    try {
      // Check if the user has granted permission for push notification
      const hasGrantedPermission =
        await OneSignal.Notifications.getPermissionAsync();

      setNotificationEnabled(hasGrantedPermission);

      if (hasGrantedPermission) {
        return false;
      }

      // Check if the user can request permission for push notification
      const canRequestPermission =
        await OneSignal.Notifications.canRequestPermission();

      // If the user can request permission, then request for permission by showing the OneSignal prompt
      if (canRequestPermission) {
        return OneSignal.InAppMessages.addTrigger('user_logged_in', 'true');
      }
    } catch (error) {
      captureException(getErrorMessage(error), {
        extra: {
          context: 'push-notification',
          method: 'handleNotificationPermissionFlow',
        },
        tags: {
          type: EXCEPTION_TAG_TYPES.DEVICE,
        },
      });
    }
  }, []);

  // Function to handle the notification permission flow when the app state changes
  const onAppStateChange = useCallback(async () => {
    if (previousAppState.current === 'background' && appState === 'active') {
      void handleNotificationPermissionFlow();
    }

    previousAppState.current = appState;
  }, [appState, handleNotificationPermissionFlow]);

  // Function to handle the permission change event for push notification when
  // OneSignal.Notification.addEventListener('permissionChange') is triggered
  const onPermissionChange = useCallback(
    (enabled: boolean) => {
      // If the user has granted permission for push notification, then save the push notification token to the server
      if (enabled) {
        void savePushNotificationToken();
      }

      setNotificationEnabled(enabled);
    },
    [savePushNotificationToken],
  );

  /**
   * Add an event listener to listen for the permission change event
   */
  useEffect(() => {
    OneSignal.Notifications.addEventListener(
      'permissionChange',
      onPermissionChange,
    );

    return () => {
      OneSignal.Notifications.removeEventListener(
        'permissionChange',
        onPermissionChange,
      );
    };
  }, [onPermissionChange]);

  // Handle the notification permission flow when the component mounts
  useEffect(() => {
    void handleNotificationPermissionFlow();
  }, [handleNotificationPermissionFlow]);

  // Add event listeners to listen for notification and notification response events
  useEffect(() => {
    notificationListener.current = addNotificationReceivedListener(notif => {
      setNotification(notif);
    });

    responseListener.current = addNotificationResponseReceivedListener(
      response => {
        console.info('addNotificationResponseReceivedListener', response);
      },
    );

    return () => {
      if (notificationListener.current) {
        removeNotificationSubscription(notificationListener.current);
      }

      if (responseListener.current) {
        removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);

  // Update the user reference when the user context changes
  // We are using a ref to store the user context to avoid unnecessary re-renders and calls to the handleNotificationPermissionFlow function
  useEffect(() => {
    userRef.current = adminUser ?? user;
  }, [adminUser, user]);

  // Handle the notification permission flow when the app state changes
  useEffect(() => {
    void onAppStateChange();
  }, [onAppStateChange]);

  return (
    <PushNotificationContext.Provider
      value={{
        notification,
        notificationEnabled,
      }}
    >
      {children}
    </PushNotificationContext.Provider>
  );
};

export { PushNotificationContext, PushNotificationProvider };

With OneSignal configured, I receive the notifications from Expo Notification SDK and OneSignal as expected but on tapping the notification, the following lines would never execute -

  useEffect(() => {
    notificationListener.current = addNotificationReceivedListener(notif => {
      setNotification(notif);
    });

    responseListener.current = addNotificationResponseReceivedListener(
      response => {
        console.info('addNotificationResponseReceivedListener', response);
      },
    );

    return () => {
      if (notificationListener.current) {
        removeNotificationSubscription(notificationListener.current);
      }

      if (responseListener.current) {
        removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);

They are the listeners for Expo notifications so we could register the payload.

Now, I uninstalled OneSignal, removed the OneSignal config and changed my provider code to -

import { useMutation } from '@tanstack/react-query';
import {
  setNotificationHandler,
  addNotificationReceivedListener,
  addNotificationResponseReceivedListener,
  removeNotificationSubscription,
  setNotificationChannelAsync,
  getExpoPushTokenAsync,
  Subscription,
  ExpoPushToken,
  Notification,
  AndroidImportance,
  getPermissionsAsync,
  IosAuthorizationStatus,
  requestPermissionsAsync,
} from 'expo-notifications';
import React, {
  createContext,
  FC,
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react';
import { Platform } from 'react-native';
// import { OneSignal } from 'react-native-onesignal';
import { v4 as uuidv4 } from 'uuid';

import { UserResponse } from '@app-types/user';
import { DEVICE_ID, HTTP_ENDPOINTS } from '@constants';
import { useAppState } from '@hooks/app-state';
import { useAuth } from '@hooks/auth';
import { useSyncStorage } from '@hooks/sync-storage';
import {
  captureException,
  EXCEPTION_TAG_TYPES,
  getEnvironmentConfig,
  getErrorMessage,
  httpClient,
} from '@utils';

import {
  PushNotificationContextType,
  PushNotificationProviderProps,
} from './push-notification.type';

// The below method is invoked when a user receives a push notification
setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

async function getExpoPushNotificationToken() {
  let token: ExpoPushToken | null = null;

  try {
    if (Platform.OS === 'android') {
      await setNotificationChannelAsync('default', {
        name: 'default',
        importance: AndroidImportance.MAX,
        vibrationPattern: [0, 250, 250, 250],
        lightColor: '#FF231F7C',
      });
    }

    token = await getExpoPushTokenAsync({
      projectId: getEnvironmentConfig().easProjectId,
    });
  } catch (error) {
    console.error('Failed to set notification channel', getErrorMessage(error));

    throw error;
  }

  return token;
}

const PushNotificationContext = createContext<PushNotificationContextType>({
  notification: null,
  notificationEnabled: false,
});

const PushNotificationProvider: FC<PushNotificationProviderProps> = ({
  children,
}) => {
  const [notification, setNotification] = useState<Notification | null>(null);
  const notificationListener = useRef<Subscription>();
  const responseListener = useRef<Subscription>();
  const { get, set } = useSyncStorage();
  const { setUser, setAdminUser, user, adminUser } = useAuth();
  const { appState } = useAppState();
  const userRef = useRef(adminUser ?? user);
  const previousAppState = useRef(appState);
  const [notificationEnabled, setNotificationEnabled] = useState(false);

  // Function to generate a unique device ID
  const generateDeviceId = async () => {
    let deviceId = get<string>(DEVICE_ID);

    if (!deviceId) {
      // Generate a new unique ID using device information and a random string
      deviceId = uuidv4();

      // Store the device ID in AsyncStorage for future use
      try {
        await set(DEVICE_ID, deviceId);
      } catch (error) {
        throw new Error('Failed to store device ID in AsyncStorage');
      }
    }

    return deviceId;
  };

  const { mutateAsync } = useMutation({
    mutationFn: async (token: string) => {
      const deviceId = await generateDeviceId();

      return await httpClient.post(HTTP_ENDPOINTS.notificationTokens, {
        notification_token: {
          token,
          platform: Platform.OS,
          provider: 'expo',
          device_id: deviceId,
        },
      });
    },
  });

  // Function to check if the device is already registered for push notification
  const isDeviceRegistered = useCallback(() => {
    const deviceId = get<string>(DEVICE_ID);

    const hasDeviceId = userRef.current?.notification_tokens?.find(
      token => token.device_id === deviceId,
    );

    return !!deviceId && !!hasDeviceId;
  }, [get]);

  // Function to save the push notification token to the server
  const savePushNotificationToken = useCallback(async () => {
    try {
      if (isDeviceRegistered()) {
        return;
      }

      const token = await getExpoPushNotificationToken();

      if (token?.data) {
        // Generate a new device ID if the device is not already registered
        // and save the push notification token to the server
        await mutateAsync(token.data);

        if (adminUser) {
          // Fetch the admin user profile after saving the push notification token
          const response = await httpClient.get<UserResponse>(
            HTTP_ENDPOINTS.userProfile,
            {
              headers: {
                'X-Use-Admin-Token': 'true',
              },
            },
          );
          setAdminUser(response.data.result);
        } else {
          // Fetch the user profile after saving the push notification token
          const response = await httpClient.get<UserResponse>(
            HTTP_ENDPOINTS.userProfile,
          );

          // Update the user context with the latest user profile
          setUser(response.data.result);
        }
      }
    } catch (error) {
      console.error(
        'Failed to initialise push notification!',
        getErrorMessage(error),
      );

      captureException(getErrorMessage(error), {
        extra: {
          context: 'expo-push-notification',
          method: 'savePushNotificationToken',
        },
        tags: {
          type: EXCEPTION_TAG_TYPES.DEVICE,
        },
      });
    }
  }, [adminUser, isDeviceRegistered, mutateAsync, setAdminUser, setUser]);

  // Function to handle the complete (OneSignal and Expo) notification permission flow
  const handleNotificationPermissionFlow = useCallback(async () => {
    try {
      // Check if the user has granted permission for push notification
      // const hasGrantedPermission =
      //   await OneSignal.Notifications.getPermissionAsync();
      const permissionSettings = await getPermissionsAsync();
      const hasGrantedPermission =
        permissionSettings.granted ||
        permissionSettings.ios?.status === IosAuthorizationStatus.PROVISIONAL;

      setNotificationEnabled(hasGrantedPermission);

      if (hasGrantedPermission) {
        return false;
      }

      const { status } = await requestPermissionsAsync();

      if (status === 'granted') {
        setNotificationEnabled(true);

        await savePushNotificationToken();
      }

      // Check if the user can request permission for push notification
      // const canRequestPermission =
      //   await OneSignal.Notifications.canRequestPermission();

      // If the user can request permission, then request for permission by showing the OneSignal prompt
      // if (canRequestPermission) {
      //   return OneSignal.InAppMessages.addTrigger('user_logged_in', 'true');
      // }
    } catch (error) {
      captureException(getErrorMessage(error), {
        extra: {
          context: 'push-notification',
          method: 'handleNotificationPermissionFlow',
        },
        tags: {
          type: EXCEPTION_TAG_TYPES.DEVICE,
        },
      });
    }
  }, [savePushNotificationToken]);

  // Function to handle the notification permission flow when the app state changes
  const onAppStateChange = useCallback(async () => {
    if (previousAppState.current === 'background' && appState === 'active') {
      void handleNotificationPermissionFlow();
    }

    previousAppState.current = appState;
  }, [appState, handleNotificationPermissionFlow]);

  // Function to handle the permission change event for push notification when
  // OneSignal.Notification.addEventListener('permissionChange') is triggered
  // const onPermissionChange = useCallback(
  //   (enabled: boolean) => {
  //     // If the user has granted permission for push notification, then save the push notification token to the server
  //     if (enabled) {
  //       void savePushNotificationToken();
  //     }

  //     setNotificationEnabled(enabled);
  //   },
  //   [savePushNotificationToken],
  // );

  /**
   * Add an event listener to listen to OneSignal events
   */
  // useEffect(() => {
  //   OneSignal.Notifications.addEventListener(
  //     'permissionChange',
  //     onPermissionChange,
  //   );

  //   return () => {
  //     OneSignal.Notifications.removeEventListener(
  //       'permissionChange',
  //       onPermissionChange,
  //     );
  //   };
  // }, [onPermissionChange]);

  // Handle the notification permission flow when the component mounts
  useEffect(() => {
    void handleNotificationPermissionFlow();
  }, [handleNotificationPermissionFlow]);

  // Add event listeners to listen for notification and notification response events
  useEffect(() => {
    notificationListener.current = addNotificationReceivedListener(response => {
      console.info('addNotificationReceivedListener', response);
      setNotification(response);
    });

    responseListener.current = addNotificationResponseReceivedListener(
      response => {
        console.info('addNotificationResponseReceivedListener', response);
      },
    );

    return () => {
      if (notificationListener.current) {
        removeNotificationSubscription(notificationListener.current);
      }

      if (responseListener.current) {
        removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);

  // Update the user reference when the user context changes
  // We are using a ref to store the user context to avoid unnecessary re-renders and calls to the handleNotificationPermissionFlow function
  useEffect(() => {
    userRef.current = adminUser ?? user;
  }, [adminUser, user]);

  // Handle the notification permission flow when the app state changes
  useEffect(() => {
    void onAppStateChange();
  }, [onAppStateChange]);

  return (
    <PushNotificationContext.Provider
      value={{
        notification,
        notificationEnabled,
      }}
    >
      {children}
    </PushNotificationContext.Provider>
  );
};

export { PushNotificationContext, PushNotificationProvider };

And, now the listeners are working as expected and I receive the payload. Seems like the OneSignal package is blocking the payload from reaching the listeners.

Steps to reproduce?

1. Install and configure [Expo Notifications](https://docs.expo.dev/versions/latest/sdk/notifications/#installation) as per the instructions in the Expo documentation.
2. Install and configure OneSignal on Expo.
3. Send a push notification using [Expo Notifications Tool](https://expo.dev/notifications). You can get the Expo Push Token using `getExpoPushTokenAsync`.
4. Use the context provider shared above if you'd like to.
5. The Expo notification listeners should receive the payload, but they do not.
6. Now, uninstall and remove all the OneSignal code, and everything works as expected.
"react-native-onesignal": "^5.1.3",
"onesignal-expo-plugin": "^2.0.2",
"expo-notifications": "~0.27.7",
"expo": "~50.0.17"

What did you expect to happen?

The code below acts as a listener for Expo Push Notifications -

  // Add event listeners to listen for notification and notification response events
  useEffect(() => {
    notificationListener.current = addNotificationReceivedListener(response => {
      console.info('addNotificationReceivedListener', response);
      setNotification(response);
    });

    responseListener.current = addNotificationResponseReceivedListener(
      response => {
        console.info('addNotificationResponseReceivedListener', response);
      },
    );

    return () => {
      if (notificationListener.current) {
        removeNotificationSubscription(notificationListener.current);
      }

      if (responseListener.current) {
        removeNotificationSubscription(responseListener.current);
      }
    };
  }, []);

It should work seamlessly and listen to Expo Notifications with OneSignal congfigures. At present, OneSignal doesn't pass the payload to the listener.

React Native OneSignal SDK version

5.1.3

Which platform(s) are affected?

  • iOS
  • Android

Relevant log output

No response

Code of Conduct

  • I agree to follow this project's Code of Conduct
@rshettyawaken rshettyawaken changed the title [Bug]: Expo notification listeners stop working when I configure and start using OneSignal [Bug]: Expo notification listeners stop working when used alongside OneSignal Sep 18, 2024
@jennantilla
Copy link
Contributor

Hi there, apologies for the delay. Are you still having issues? Our Handling Multiple Push Providers documentation might be helpful in troubleshooting.

Let us know if you have any additional questions or concerns!

@rshettyawaken
Copy link
Author

rshettyawaken commented Dec 6, 2024

@jennantilla I think the documentation outlines the issue related to OneSignal notifications. My issue is more related to not being able to listen to Push Notifications sent by another package called Expo Notitications. I wanna use both the packages side by side but I can't because the OneSignal SDK Push Notification listener blocks Expo Notification from listening.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants