Notifications on bare (ejected) app appear to be broken on iOS

🐛 Bug Report

I don’t even know where to start – this is as indeterministic as it gets, and I’ve literally run out of devices to test 🙂

A few observations:

  • Android works just fine
  • Fails on iOS for local builds, TestFlight and published builds (which are somewhat crippled without push notifications).
  • All phones do get push notification tokens at startup.
  • I have two physical iPhones here to test with. On both, I can use the expo push notifications tool to set the a badge number on the phones, but not to send a message (badge number changes, but no message is displayed). I don’t know what that says exactly, but I guess it indicates that XCode and certificates etc. are set up properly (I do have “Push Notifications” and “Background Modes -> Remote notifications” enabled in XCode).
  • When requesting the NOTIFICATIONS permission, I don’t get prompted, but the returned status immediately resolves to “granted”. That’s probably because I’ve granted it on earlier installs. However, I tried the “uninstall, restart, change system date, restart” trick to simulate a completely fresh install, and the app is still not showing a prompt and immediately returns “granted”.
  • Despite that “granted” status, there is no “Notifications” permission if I go to Settings -> My App, only “Siri & Search” and “Background App Refresh”. I assume that’s already a cause for concern? See screenshot below.
  • I actually do have the “Notifications” setting on a simulator phone (see screenshot)
  • In the logs, I’ve seen devices of friends that got push notifications when foregrounded (I log everything into a central system), but then stopped receiving them. Also a message was never displayed for them.
  • Local notifications also don’t work on iOS (work just fine on Android as well).

Left hand side: iPhone which resolves to NOTIFICATIONS permission with “granted” status, but no “Notifications” settings, while the simulator has it:

image

Environment – output of expo diagnostics & the platform(s) you’re targeting

Target phones:

  • iPhone 6, iOS 12
  • iPhone SE (2020), iOS 14
Expo CLI 4.0.17 environment info:
    System:
      OS: macOS 11.1
      Shell: 5.8 - /bin/zsh
    Binaries:
      Node: 14.5.0 - /usr/local/bin/node
      Yarn: 1.22.4 - /usr/local/bin/yarn
      npm: 6.14.5 - /usr/local/bin/npm
      Watchman: 4.9.0 - /usr/local/bin/watchman
    Managers:
      CocoaPods: 1.10.0 - /usr/local/bin/pod
    SDKs:
      iOS SDK:
        Platforms: iOS 14.3, DriverKit 20.2, macOS 11.1, tvOS 14.3, watchOS 7.2
      Android SDK:
        API Levels: 26, 29, 30
        Build Tools: 28.0.3, 29.0.2, 30.0.0
        System Images: android-29 | Intel x86 Atom_64, android-29 | Google APIs Intel x86 Atom, android-30 | Google APIs Intel x86 Atom
    IDEs:
      Android Studio: 4.0 AI-193.6911.18.40.6514223
      Xcode: 12.3/12C33 - /usr/bin/xcodebuild
    npmPackages:
      expo: ^40.0.0 => 40.0.0 
      react: 16.13.1 => 16.13.1 
      react-dom: 16.13.1 => 16.13.1 
      react-native: 0.63.4 => 0.63.4 
      react-native-web: ~0.13.12 => 0.13.18 
    npmGlobalPackages:
      expo-cli: 4.0.17
    Expo Workflow: bare

Reproducible Demo

This is pretty much just the setup snippets from the docs:

// do not show an alert when foregrounded
Notifications.setNotificationHandler({
    handleNotification: async () => ({
      shouldShowAlert: true,
      shouldPlaySound: true,
      shouldSetBadge: false,
    }),
});

async init() {

    try {
        await this.registerNotifications();

        if(!Env.isSimulator) {
            await this.resolvePushNotificationToken();
        }

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

        Notifications.addNotificationReceivedListener(this.handleNotificationResponse);
        Notifications.addNotificationResponseReceivedListener(this.handleNotificationResponse);
    } catch (e) {
        this.logger.error("Push notification store initialization failed with exception.", e);
    }
}

async registerNotifications(): Promise<void> {
    try {
        // check if we already have push permission
        const permission = await Permissions.getAsync(Permissions.NOTIFICATIONS);
        let finalStatus = permission.status;

        if (permission.status !== "granted") {
            const {status} = await Permissions.askAsync(Permissions.NOTIFICATIONS);
            finalStatus = status;
        }

        if (finalStatus !== "granted") {
            this.logger.warn(`Could not get notification permission - final status: ${finalStatus}.`);
            return;
        }

        if (Platform.OS === "android") {
            await Notifications.setNotificationChannelAsync("default", {
                name: "default",
                importance: Notifications.AndroidImportance.MAX,
                vibrationPattern: [0, 250, 250, 250],
                lightColor: Color.primary,
            });
        }
    } catch (e) {
        this.logger.error("Notification registration failed with exception.", e);
    }
}

private async resolvePushNotificationToken() {
    // for bare workflows, we need to hardcode the expo experience ID
    let experienceId: string = "@hardcodet/pssn";

    const tokenData: ExpoPushToken = Notifications.getExpoPushTokenAsync({experienceId});
    this.pushToken = tokenData.data;
}

1 possible answer(s) on “Notifications on bare (ejected) app appear to be broken on iOS

  1. TLDR:
    When asking for the notifications permission, Permissions.xxx doesn’t work, Notifications.xxx does work and finally results in the OS actually asking me for permissions.

    Long version
    Ok, I think I found a hack. As documented above, I had the issue that asking for the Notifications permissions didn’t yield any effect:

    • Permissions.getAsync(Permissions.NOTIFICATIONS) and Permissions.askAsync(Permissions.NOTIFICATIONS) for some reasons don’t work for me but always return “granted” (as document above).
    • Notifications.getPermissionsAsync(Permissions.NOTIFICATIONS) and Notifications.requestPermissionsAsync(Permissions.NOTIFICATIONS) do work properly and result in an OS prompt and the permission being added (visible in the phone’s settings)

    I added this snippet to my code:

          const permission = await Permissions.getAsync(Permissions.NOTIFICATIONS);
          const hackPermission = await Notifications.getPermissionsAsync(); // .requestPermissionsAsync();
          console.log(`status 1: ${permission.status} / status 2: ${hackPermission.status}`)
    

    This results in the following output in the console:

    status 1: granted / status 2: undetermined
    

    After calling Notifications.requestPermissionsAsync();, I get the OS prompt, and from that point on, both options return “granted”.

    I only discovered the second option after scouring through other issues and PRs in the notifications package. I would assume that both methods would be the same (and one just being syntactic sugar around the other one), also because only Permissions.xxx is used in the docs.

    Going forward: Is it safe to simply replace the Permission.xxx calls with Notifications.xxx calls?

    EDIT: I published an update that replaces usages of the Permission package with the Notifications package and push notifications are received now, so it’s a confirmed fix.