facebook / react-native

A framework for building native applications using React
https://reactnative.dev
MIT License
119.25k stars 24.34k forks source link

`PushNotificationIOS.getInitialNotification` returns null for Local Notifications on Cold Starts. #8580

Closed joshuapinter closed 7 years ago

joshuapinter commented 8 years ago

Handling Local Push Notifications when the app is in the background or inactive state works just fine.

When your app is not running (cold start) and is launched from a Local Push Notification, the practice (according to the current docs) is to use PushNotificationIOS.getInitialNotification. However, this always returns null.

I spent a long time trying to get this to work with getInitialNotification but nothing worked. I had to essentially capture the notification in the launchOptions and then pass it in via appProperties.

My solution is captured on this StackOverflow Answer but here is the crux of it.

AppDelegate.m

// Inside of your didFinishLaunchingWithOptions method...

// Create a Mutable Dictionary to hold the appProperties to pass to React Native.
NSMutableDictionary *appProperties = [NSMutableDictionary dictionary];

if (launchOptions != nil) {
  // Get Local Notification used to launch application.
  UILocalNotification *notification = [launchOptions objectForKey:UIApplicationLaunchOptionsLocalNotificationKey];

  if (notification) {
    // Instead of passing the entire Notification, we'll pass the userInfo,
    // where a Record ID could be stored, for example.
    NSDictionary *notificationUserInfo = [notification userInfo];

    [ appProperties setObject:notificationUserInfo  forKey:@"initialNotificationUserInfo" ];
  }
}

// Your RCTRootView stuff...

rootView.appProperties = appProperties;

index.ios.js

componentDidMount() {
  if (this.props.initialNotificationUserInfo) {
    console.log("Launched from Notification from Cold State");
    // This is where you could get a Record ID from this.props.initialNotificationUserInfo
    // and redirect to the appropriate page, for example.
  }

  PushNotificationIOS.addEventListener('localNotification', this._onLocalNotification);
}

componentWillUnmount() {
  PushNotificationIOS.removeEventListener('localNotification', this._onLocalNotification);
}

_onLocalNotification( notification ) {
  if (AppState.currentState != 'active') {
    console.log("Launched from Notification from Background or Inactive state.");
  }
  else {
    console.log("Not Launched from Notification");
  }
}

Environment:

dwilt commented 8 years ago

I'm stuck with this at the moment but using remote notifications instead of local ones. I can't seem to capture an initial remote notification that starts up my app from a "cold start". Like everyone else in #2658, I can get my notification from my listener via PushNotificationIOS.addEventListener('notification', ... working perfectly once the app is running, but trying to get a notification from a cold start doesn't seem to be possible.

joshuapinter commented 8 years ago

@dwilt My solution works perfect for local notifications, I imagine it would work just as well with remote notifications. Have you tried the AppDelegate.m modification I suggested above?

dwilt commented 8 years ago

I did - but I can't seem to get it to work. I'm trying to get the prop value initialNotificationUserInfo off my class and it doesn't seem to have a value. Of course, debugging from a cold start - not starting through Xcode where I have a console to put output - is making it much 10x harder.

joshuapinter commented 8 years ago

@dwilt If you put a breakpoint right before rootView.appProperties = appProperties;, what is the value of appProperties? This will test to see if it's your Obj-c or your JS that is to blame here.

dwilt commented 8 years ago

@joshuapinter how can I put a breakpoint there if the app is going to be started up cold by a push notification and not by using Xcode? Know what I mean?

joshuapinter commented 8 years ago

Right. In Xcode you can set the Debugger to launch when the app launches (like from a notification). Just edit your Scheme and look for this:

screenshot 2016-07-22 13 24 15

And maybe instead of a breakpoint just NSLog out appProperties.

dwilt commented 8 years ago

@joshuapinter

To reiterate what I've done: I closed the app completely, and edited the scheme of the project to "Wait for executable to be launched" (this is awesome by the way for testing these remote notifications). I logged into my Urban Airship account to dispatch a notification to my device. Boom, saw the notification come in, tapped on it, and it fired up the app, and paused at this breakpoint:

image

Here's my AppDelegate.m:

/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

#import "AppDelegate.h"

#import "RCTBundleURLProvider.h"
#import "RCTRootView.h"

#import <AirshipKit/AirshipKit.h>
#import "RCTPushNotificationManager.h"

@implementation AppDelegate

// Required to register for notifications
- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings
{
  [RCTPushNotificationManager didRegisterUserNotificationSettings:notificationSettings];
}
// Required for the register event.
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  [RCTPushNotificationManager didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Required for the notification event.
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)notification
{
  [RCTPushNotificationManager didReceiveRemoteNotification:notification];
}
// Required for the localNotification event.
- (void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification
{
  [RCTPushNotificationManager didReceiveLocalNotification:notification];
}
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
  NSLog(@"%@", error);
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  NSURL *jsCodeLocation;

  // Set log level for debugging config loading (optional)
  // It will be set to the value in the loaded config upon takeOff
  [UAirship setLogLevel:UALogLevelTrace];

  // Populate AirshipConfig.plist with your app's info from https://go.urbanairship.com
  // or set runtime properties here.
  UAConfig *config = [UAConfig defaultConfig];

  // Call takeOff (which creates the UAirship singleton)
  [UAirship takeOff:config];

  // Print out the application configuration for debugging (optional)
  UA_LDEBUG(@"Config:\n%@", [config description]);

  // Set the icon badge to zero on startup (optional)
  [[UAirship push] resetBadge];

  // Set the notification types required for the app (optional). This value defaults
  // to badge, alert and sound, so it's only necessary to set it if you want
  // to add or remove types.
  [UAirship push].userNotificationTypes = (UIUserNotificationTypeAlert |
                                           UIUserNotificationTypeBadge |
                                           UIUserNotificationTypeSound);

  // User notifications will not be enabled until userPushNotificationsEnabled is
  // set YES on UAPush. Onced enabled, the setting will be persisted and the user
  // will be prompted to allow notifications. You should wait for a more appropriate
  // time to enable push to increase the likelihood that the user will accept
  // notifications. For troubleshooting, we will enable this at launch.
  [UAirship push].userPushNotificationsEnabled = YES;

// Useful to see available font families
//  for (NSString* family in [UIFont familyNames])
//  {
//    NSLog(@"%@", family);
//    for (NSString* name in [UIFont fontNamesForFamilyName: family])
//    {
//      NSLog(@" %@", name);
//    }
//  }

  // Create a Mutable Dictionary to hold the appProperties to pass to React Native.
  NSMutableDictionary *appProperties = [NSMutableDictionary dictionary];

  if (launchOptions != nil) {
    // Get Local Notification used to launch application.
    UILocalNotification *notification = [launchOptions objectForKey:UIApplicationLaunchOptionsLocalNotificationKey];

    if (notification) {
      // Instead of passing the entire Notification, we'll pass the userInfo,
      // where a Record ID could be stored, for example.
      NSDictionary *notificationUserInfo = [notification userInfo];

      [ appProperties setObject:notificationUserInfo  forKey:@"initialNotificationUserInfo" ];
    }
  }

  #ifdef DEBUG
    jsCodeLocation = [NSURL URLWithString:@"http://192.168.1.77:8081/index.ios.bundle?platform=ios&dev=true"];
  #else
    jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
  #endif

  [[RCTBundleURLProvider sharedSettings] setDefaults];
  jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index.ios" fallbackResource:nil];

  RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                      moduleName:@"GreatJonesStreet"
                                               initialProperties:nil
                                                   launchOptions:launchOptions];
  rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1];
  rootView.appProperties = appProperties;

  NSLog(@"Hello World!");

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  UIViewController *rootViewController = [UIViewController new];
  rootViewController.view = rootView;
  self.window.rootViewController = rootViewController;
  [self.window makeKeyAndVisible];
  return YES;
}

@end
joshuapinter commented 8 years ago

I was using this for local notifications.

Try changing UIApplicationLaunchOptionsLocalNotificationKey to UIApplicationLaunchOptionsRemoteNotificationKey.

dwilt commented 8 years ago

Got it. Whenever I change

// Get Local Notification used to launch application.
    UILocalNotification *notification = [launchOptions objectForKey:UIApplicationLaunchOptionsLocalNotificationKey];

to

// Get Local Notification used to launch application.
    UILocalNotification *notification = [launchOptions objectForKey: UIApplicationLaunchOptionsRemoteNotificationKey];

and run through the same flow, Xcode freezes here: image

When I try to hit play, it just rebuilds it instead of getting to my breakpoint. It also seems like it starts up another instance of the project (notice on the left how there are two - that's odd?)

That being said, when I had it as UIApplicationLaunchOptionsLocalNotificationKey, I inspected the LocalOptions variable at that breakpoint (which seems just like an array) and noticed it's first object with a key of UIApplicationLaunchOptionsRemoteNotificationKey:

image

Those values are indeed what I am passing down through my notification service. So at least we know they're there..

joshuapinter commented 8 years ago

Nice sleuthing! That was gonna be my next suggestion.

So, now that you know it's in there, you know you can grab it.

I haven't dealt with Remote Notifications at all but you'll be able to get it from here. One thing to double check is that UILocalNotification works as the class type. You might need to use the remote notification version of that as well.

Keep getting after it and make sure you post the successful code change here because I'll definitely be using remote notifications down the road.

dwilt commented 8 years ago

Problem is, I'm a JS dude - not an Objective C guy. I'm fucked haha

dwilt commented 8 years ago

That being said, thanks for all your help in getting me to this point man. Much appreciated. Would be happy to paypal you a beer.

joshuapinter commented 8 years ago

Hahaha. I'm not an Obj-C guy as well. I'm a Ruby guy. I'll do some research here too. Let's get this solved.

joshuapinter commented 8 years ago

@dwilt Try using this instead:

if (launchOptions != nil) {
    // Get Local Notification used to launch application.
    NSDictionary *notification = [launchOptions objectForKey:UIApplicationLaunchOptionsRemoteNotificationKey];

    if (notification) {
      [ appProperties setObject:notification forKey:@"initialNotification" ];
    }
}

And see how that works.

And thanks for the offer but helping others is its own reward. Besides, I've gotten so much help in the past, I still have a deficit balance. ;)

dwilt commented 8 years ago

Bingo! That worked. For some reason though, Xcode doesn't spit out the console stuff at all when using this delayed startup. Really fucking annoying. Had to stringify the object just to see it's value. Anyway, it's now on my root app component's props, as initialNotification:

class GreatJonesStreet extends Component {

    componentDidMount() {
        var { initialNotification } = this.props;

        // initialNotification === {
        //         aps: {
        //             alert: "Check out our new story: \"Dan's Magical Mystery Tour\"!",
        //         },
        //     ^d: "greatjonesstreet://story-detail",
        //     storyId: "2",
        //     storyTitle: "Dan's Magical Mystery Tour",
        //     _: "knsUgFJKlEeaqhNS-2a-UJg"
        // }

        if (initialNotification) {
            AlertIOS.alert(JSON.stringify(initialNotification));

            this._handleNotification(initialNotification);
        }

        PushNotificationIOS.addEventListener('notification', notification => {
            var data = notification.getData();

            if (data) {
                this._handleNotification(data);
            }
        });
    }

    componentWillUnmount() {
        PushNotificationIOS.removeEventListener('notification', this._notificationListener.bind(this));
    }

    _notificationListener(notification) {
        var data = notification.getData();

        if (data) {
            this._handleNotification(data);
        }
    }

    _handleNotification(data) {
        var action = data['^d'] ? data['^d'].split('greatjonesstreet://')[1] : null;

        switch (action) {
            case 'story-detail':
                var title = data.storyTitle,
                    id = parseFloat(data.storyId);

                if (title && id) {
                    this._goToStory(id, title);
                }

                break;

            default:
                break;
        }
    }

    _goToStory(id, title) {
        Actions.StoryDetail({
            id,
            title
        });
    }

    render() {

        const store = configureStore(getInitialState());

        //Connect w/ the Router
        const ConnectedRouter = connect()(Router);

        store.dispatch(setPlatform(platform));
        store.dispatch(setVersion(VERSION));

        // setup the router table with App selected as the initial component
        return (
            <Provider store={store}>
                <ConnectedRouter hideNavBar={true}>
                    <Scene key="root">
                        <Scene key="Home" component={Home} initial={true}/>
                        <Scene key="CollectionDetail" component={CollectionDetail}/>
                        <Scene key="StoryDetail" component={StoryDetail}/>
                    </Scene>
                </ConnectedRouter>
            </Provider>
        );
    }
}

Something to note: The initialNotification doesn't have the methods getData() and getAlert() like the notification object we get from the PushNotificationIOS.addEventListener method. It's just a raw object with the data. What would be ideal is if it was the same kind of object so I could just pass both the initial and listener notification objects to the same function. Thanks so much @joshuapinter. Great team work. You've increased your karma balance! Now I'm more in debt :(

joshuapinter commented 8 years ago

đź‘Ť Whoop whoop!

Awesome to hear. And, yeah, I totally agree that React Native should treat these like their other Notifications with getData(), etc.

That's really what this issue is all about. I'll gladly make a pull request to get React Native properly fixed but I want to hear from somebody on their core team first to make sure they are on board.

chandlervdw commented 8 years ago

@dwilt @joshuapinter this thread has helped me a lot but I'm curious how RCTPushNotificationManager and UAirship link up. I don't want to use Urban Airship's [UAirship push].userPushNotificationsEnabled = YES; in AppDelegate.m because I don't have control of when the request modal will show up.

Using PushNotificationIOS.requestPermissions() is really what I want but I'm not sure how to get UAirship to send deviceTokens in the callback. I don't know anything about it but is this "automatically" handled via method swizzling?

Any ideas?

dwilt commented 8 years ago

When you say "the request modal" - which modal are you talking about? I have [UAirship push].userPushNotificationsEnabled = YES set but I don't have any modal's showing.

I am also using PushNotificationIOS.requestPermissions() in my app root file where I register my listeners for react router flux.

Also, which callback are you talking about - the one that would fire when a notification is received from PushNotificationIOS.addEventListener('notification'? Give me a little more code and context so I can help

chandlervdw commented 8 years ago

@dwilt according to Urban Airship, [UAirship push].userPushNotificationsEnabled = YES shows an alert to get permissions (like this) when the app starts up. If you've already given permission, you won't see this again.

PushNotificationIOS.requestPermissions() can also show that alert whenever you want instead of on app start. According to the RN Docs,

This method returns a promise that will resolve when the user accepts, rejects, or if the permissions were previously rejected. The promise resolves to the current state of the permission.

dwilt commented 8 years ago

Got it - yes, I'm seeing this alert on initial load of my app too. I've got both of those settings set so I'm not sure which is doing it though. What's your question?

chandlervdw commented 8 years ago

So, I don't want to show the notification at startup — I can show it when I want to via PushNotificationIOS.requestPermissions(). I just don't know how to tie it up with Urban Airship.

chandlervdw commented 8 years ago

Nevermind, apparently this IS handled with method swizzling!

dwilt commented 8 years ago

Ok, glad you got it figured out. Sorry I couldn't be more of a help

tomprogers commented 8 years ago

@dwilt's approach worked for me, but I've noticed that the notification object is shaped very differently depending on whether it's provided by one of the PushNotificationIOS events vs. this custom Obj-C pathway.

See the third panel here: prop names are different, object is structured differently. I've highlighted the custom JS payload that the RN app consumes.

screen shot 2016-07-29 at 9 52 03 am

dwilt commented 8 years ago

@tomprogers yep, I mentioned that above too. Ran into the same thing and am normalizing the data before passing it into my reusable function :( Not ideal, but it works. What's frustrating is you don't get the getData(), getMessage(), etc methods that are exposed via RN when it's a cold start (coming straight from Obj-C)

chandlervdw commented 8 years ago

@dwilt actually, I didn't figure it out, and I'm guessing it's due to the notification object shape mentioned by @tomprogers above. How did you guys normalize the data?

Concerning requestPermissions(), the RN Docs say

This method returns a promise that will resolve when the user accepts, rejects, or if the permissions were previously rejected. The promise resolves to the current state of the permission.

But when I try:

PushNotificationsIOS.requestPermissions().then((permissions) => console.log(permissions));

I never hit the console.log(). What am I missing?

dwilt commented 8 years ago

@chandlervdw so the requestPermissions.then callback not firing is not something I have any experience with.

Regarding the normalizing the data, here's how I'm doing it in my app:

 class GreatJonesStreet extends Component {

    componentDidMount() {
        var { initialNotification } = this.props;

        if (initialNotification) {

            // TODO: BS solution until this is resolved: https://github.com/aksonov/react-native-router-flux/issues/686
            setTimeout(() => {
                this._handleNotification(initialNotification.custom.a);
            }, 1000);
        }

        PushNotificationIOS.addEventListener('notification', this._onNotification.bind(this));
    }

    _onNotification(notification) {
        var data = notification.getData();

        this._handleNotification(data.additionalData);
    }

    _handleNotification(notification = {}) {
        switch(notification.action) {
            case 'story-detail':
                let { storyId, storyTitle } = notification;

                storyId = parseFloat(storyId);

                this._goToStory(storyId, storyTitle);
                break;

            case 'collection-detail':
                let { collectionId, collectionTitle } = notification;

                collectionId = parseFloat(collectionId);

                this._goToCollection(collectionId, collectionTitle);
                break;

            default:
                break;
        }
    }

    _goToStory(id, title) {
        Actions.StoryDetail({
            id,
            title
        });
    }

    _goToCollection(id, title) {
        Actions.CollectionDetail({
            id,
            title
        });
    }

    render() {

        const store = configureStore(getInitialState());

        //Connect w/ the Router
        const ConnectedRouter = connect()(Router);

        store.dispatch(setPlatform(platform));
        store.dispatch(setVersion(VERSION));

        // setup the router table with App selected as the initial component
        return (
            <Provider store={store}>
                <ConnectedRouter hideNavBar={true}>
                    <Scene key="root">
                        <Scene key="Home" component={Home} initial={true}/>
                        <Scene key="CollectionDetail" component={CollectionDetail}/>
                        <Scene key="StoryDetail" component={StoryDetail}/>
                    </Scene>
                </ConnectedRouter>
            </Provider>
        );
    }
}

I'm handling notifications from 2 areas:

  1. When the root component (GreatJonesStreet) mounts, I check to see if an initialNotification object is available on the props. This is the "cold start" scenario that @tomprogers referenced above. Because I'm using OneSignal the structure of their push notification is a bit odd and grabbing from the object what I need (the custom data from the push notification to reroute the user to a deeper page) via this._handleNotification(initialNotification.custom.a);.
  2. If the app is already open and receives a notification, I have listeners via PushNotificationIOS.addEventListener('notification', this._onNotification.bind(this));. Then in _onNotification, I'm using getData() which is available here and only here and passing the data into _onNotification: this._handleNotification(data.additionalData);.

Let me know if this makes sense. Again, not ideal, but it works.

TimonSotiropoulos commented 8 years ago

Just wanted to leave a +1 for @dwilt & @joshuapinter . Helped me immensely!

dwilt commented 8 years ago

Just wanted to come back here and note: I'm using OneSignal and it was a fuck ton easier to setup and it has the same objects passed to their onNotification handler so you don't need custom code for each scenario.

ericnakagawa commented 7 years ago

Are you folks still seeing this issue? @dwilt @joshuapinter

dwilt commented 7 years ago

@ericnakagawa we have since moved over to OneSignal for PushNotifications and it's been a lot easier since so I'm using their guides: https://github.com/geektimecoil/react-native-onesignal

Notice how they're using pendingNotifications though: https://github.com/geektimecoil/react-native-onesignal#ios-usage

joshuapinter commented 7 years ago

@ericnakagawa Will test again when I get a chance.

hramos commented 7 years ago

Closing this issue because it has been inactive for a while. If you think it should still be opened let us know why.

joshuapinter commented 7 years ago

@hramos I think we should keep this open until React Native handles this properly in its core. We're essentially having to use a hack to handle cold start notifications.

Like I mentioned earlier I wouldn't mind creating a PR to handle this better but wanted to get some input from the React Native core team before spending the effort to do it.

Let me know what you think.

hramos commented 7 years ago

I think the best approach here would be to send a PR. We can loop in the core team then.