Overview and Motivation
While SDKs from various push notification services - like Firebase and OneSignal - provide some level of visibility into push notification status, this data is often only available on the service’s databases. Integrating service-specific SDKs also limits the app’s functionality to the service's capabilities. If these capabilities change down the road, an app can lose vital functionality.
Luckily, it’s possible to capture and store push notification events for a React Native app on your own server. When an app is in the foreground, Javascript can be used to handle “Opened” and “Received” status on both iOS and Android devices. When an app is in the background, the Android FCM Service and iOS Push Notification Service Extension need to be used to handle the same statuses on the respective platforms.
Getting Started
To complete this tutorial, you will need a React Native that can send and receive push notifications, and an endpoint like PUT /api/notification/status that can accept a user ID.
For this tutorial, I’ve put together an example Github repo that leverages Firebase as a database, Firebase Cloud Messaging to handle notifications, and a simple React Native app to send and receive notifications. You will need to set up your own Firebase project and enable iOS push notifications using an Apple Developer account.
On iOS, a field called mutable-content must also be set. Additionally, notifications will need to be formatted so they will trigger the service extension. In Firebase Messaging, mutable-content is set with a separate options object. The firebase/functions/index.js file is an example. However, Firebase needs to send different payloads per platform:
/* ios needs the title body in the notification field *//* android needs everything in the data field in order to *//* handle the notification in the background */
const payload = user.type === 'ios' ? {
notification: {
title: `Hello ${id}`,
body: notification.message
},
data: {
notificationId: context.params.notificationId,
},} : {
data: {
title: `Hello ${id}`,
body: notification.message,
notificationId: context.params.notificationId,
}};
In the Github repo, we post the title, message and recipient to Firestore. A cloud function event handler is set up to send a Firebase Message any time a new document is added to the Notifications collection. Now we are able to set up devices to send push notifications back and forth.
The Firebase iOS/Android SDKs aren’t available in the background services but we can use Firebase Cloud Functions to create HTTP callable endpoints to update notification received status. The example repo includes these Firebase Functions that can be deployed to an empty Firebase project.
Responding to Notifications in the Foreground
When the app is in the foreground, service extensions can handle Received status, but we will need to handle the `Opened` case manually.
React Native Notifications
react-native-notifications allows us to catch the Opened event both when the notification triggers the app to open, and when the app is already in the foreground.
import Notifications, {
NotificationsAndroid,
PendingNotifications,} from 'react-native-notifications';
class MyComponent extends Pure Component { constructor(props) {
super(props);
if (Platform.OS === 'android') {
NotificationsAndroid.setNotificationOpenedListener(
this.onOpenedNotification,
);
} else {
Notifications.addEventListener(
'notificationOpened',
this.userOpenedNotification,
);
}
}
componentDidMount() {
/* On android we need to specifically check if */
/* the app launched via notification */
if (Platform.OS === 'android') {
PendingNotifications.getInitialNotification()
.then(notification => {
if (notification) {
this.userOpenedNotification(notification);
}
})
.catch(e => {
/* handle error if needed */
});
}
}
componentWillUnmount() {
Notifications.removeEventListener(
'notificationOpened',
this.userOpenedNotification,
);
}
onOpenedNotification = notification => {
const data = notification.getData();
const { notificationId: id } = data;
const url = `${statusEndpoint}/${id}?status=READ`;
try {
await fetch(url, { method: 'POST' });
} catch (e) {
console.log(e);
}
};}
iOS Notifications in the Background
Both iOS and Android require service extensions to capture received status before the user interacts with a notification sent by an app in the background. Implementing these service extensions requires native code for each platform, and doesn’t require the app to open to track notification data.
iOS Push Notification Service Extension
On iOS, tracking notifications for apps in the background is accomplished with the Push Notification Service Extension. Xcode provides a template for this. Follow the steps below:
- Go to File → New Target
- Choose Notification Service Extension
- Click Next and give it a name
Some important things to remember about the PN Service Extension:
- The version name and build number need to match that of your app when you upload to App Store Connect, otherwise you will get a warning.
- Set the target for the extension to match your app, with a minimum of iOS 10.0, which is when service extensions were added.
- If you sign your app manually, you will need to create a provisioning profile for the extension using the same certificate as the main app.
We should now have a new target in the project with the application name entered in the above steps, as well as a new folder with NotificationService.m/h and Info.plist files.
Open NotificationService.m
There will be two stubbed functions for us to implement. For this example, we are only going to modify the didReceiveNotificationRequest function. The other stubbed function, serviceExtensionTimeWillExpire , is used to handle the case when the extension is about to shut down, but the work is not finished.
Debugging
By default, the service extension is set up to modify the title of the push notification before it is displayed to the user. Leave this code as is and verify the title is altered when displayed on a device. This should happen even when the app has been force-closed.
Alternatively, you can set a breakpoint in Xcode. In order to hit a breakpoint in the service extension, we will need to start the extension instead of the app. Follow the steps below to set up this breakpoint.
- In XCode, select the PN service extension in the top left Run bar.
- Press Run/Debug.
- Select the main app for the project from the prompt. (This should launch the app from the device).
- Set a breakpoint in the didReceiveNotificationRequest file.
- Send a push notification as described in the Getting Started section.
App Groups
In order to tell the API which user has received the notification, we'll need some info from the main app. In React Native, information is likely stored in Redux or another statement management library, which is not accessible from the service extension. In order to share the user data, we'll need to write the data in a location both processes can access: UserDefaults.
The first step is to enable the App Groups Capability in Xcode. Add the App Group Capability in Xcode and give it a name. Xcode should add this Capability to the Provisioning Profiles, but sometimes it needs to be added in the Apple Developer certificates manager via a web browser.
Now, we need a way to write to UserDefaults with Javascript in React Native. We can use the react-native-default-preference library. In this example, we will assume our /api/notifications/status endpoint is public and that we are sending a notification to one user at a time. If we were to implement this functionality in the real world, we would use an authenticated endpoint with an auth token to keep the API secure. The below should be run every time the user signs up and logs in to keep up the app and API in sync.
import DefaultPreference from 'react-native-default-preference';
async function onSomeEventLikeLogin(data) {
await DefaultPreference.setName('group.myApp');
/* set any info needed for push notification services to phone home */
await DefaultPreference.setMultiple({
endpoint: `https:///notifications/status`,
/* other data here */
});}
Back in the service extension, the app needs to read the user data. In Objective C, this is done with NSUserDefaults
- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
self.contentHandler = contentHandler;
self.bestAttemptContent = [request.content mutableCopy];
/* new code */
NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.myApp"];
NSString *endpoint = [userDefaults stringForKey:@"endpoint"];
/* More new code will go below */
self.contentHandler(self.bestAttemptContent);}
The example application included in the Github repo allows a user to manually enter a mock user ID. To test this, follow the steps below.
- Run the app and sign in as a user.
- Set a breakpoint after the UserDefault read status.
- Send a notification.
The userId you signed in with should match the userId that is displayed.
Making the Network Request
Now we need to send the notification status data to the API. First, grab the notification ID out of the request content. The following works with firebase, but some services may use a different data structure.
NSString *notificationId = [request.content.userInfo valueForKey:@"notificationId"];
Then, we can use NSURLSession to make an http(s) request to our 'server'.
NSString *urlStr = [NSString stringWithFormat:@"%@/%@?status=RECEIVED", endpoint, notificationId];
NSURL *url = [NSURL URLWithString:urlStr];
NSMutableURLRequest *req = [[NSMutableURLRequest alloc] init];
[req setHTTPMethod:@"POST"];
[req setURL:url];
[[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:
^(NSData * _Nullable data,
NSURLResponse * _Nullable response,
NSError * _Nullable error) {
NSString *responseStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; NSLog(@"Data received: %@", responseStr);
}] resume];
We should now be ready for a 'Closed Loop' test. Run the app again and send a notification. The server should receive the expected received status for the notification we sent.
Android Notifications in the Background
Assuming the app is using the react-native-notifications we are leveraging to manage notifications in the foreground, the Android FCM Service is already handling notifications in the background. To access the notification data we need to override and reconfigure this service.
In manifest.xml, add the following inside the application tag:
android:name=".MyAppFcmListenerService"
android:exported="false">
<action android:name="com.google.firebase.MESSAGING_EVENT"></action>
Now we need to implement the custom service MyAppFcmListenerService named above. Create a file called MyAppFcmListenerService and override the one used by react-native-notifications. Remember to call super so that library continues to work properly:
import com.wix.reactnativenotifications.fcm.FcmInstanceIdListenerService;public classMyAppFcmListenerServiceextendsFcmInstanceIdListenerService {
@Override
public void onMessageReceived(RemoteMessage message){
super.onMessageReceived(message); /* call rn-notifications service */
/* the rest of the code will go here */
}}
At this point, you should be able to run the app in debug mode and set a breakpoint in the new service that will be hit when a push notification is sent. Now, we need to read the shared data and send it to the API.
If the app isn’t using react-native-notifications, extend FirebaseMessagingService instead.
Reading Shared Data
On Android, Activities and Services have helpers that allow the app to read shared data, among other features. For this we will use this.getSharedPreferences
SharedPreferences prefs = this.getSharedPreferences("group.myApp", MODE_PRIVATE);
String endpoint = prefs.getString("endpoint", null);
We also need to grab the notification ID:
Bundle bundle = message.toIntent().getExtras();
String notificationId = null;
if (bundle != null) {
notificationId = bundle.getString("notificationId");}
We will need to use the notification ID to send the status to the server.
Making a Network Request in Java
To keep things simple, we will leverage vanilla libraries for handling network requests and JSON objects. Use Http(s)URLConnection to send data to the server.
HttpURLConnection con = null;
try {
URL url = new URL(endpoint + "/" + notificationId + "?status=RECEIVED");
con = (HttpURLConnection)url.openConnection();
con.setDoOutput(true);
con.setRequestMethod("POST");
int code = con.getResponseCode();
Log.d(LOGTAG, "Completed Push Notification Phone Home with status: " + code);}catch (Exception e) {
/* handle error */
}
finally {
if (con != null) {
con.disconnect();
}}
Note that in the example above, we cast the opened connection to HttpURLConnection rather than HttpsURLConnection. This is because the latter is a subclass of the former, so we are actually using whichever one is returned by url.openConnection(), determined by the given URL protocol. For our local server, we will be using http.
You should now be able to send a Push Notification to the Android app and see the received callback in the API.
Checkout the Github repo to see a working setup with Firebase and react-native-notifications.
Conclusion
Maintaining control over push notification data on your server has many business benefits - like increased visibility into user engagement - and sets the stage for the implementation of rich notifications in the future. Hopefully you are able to use this tutorial to set up in-house push notification tracking for your next React Native project.
---
At FullStack Labs, we pride ourselves on our ability to push the capabilities of cutting-edge frameworks like React Native. Interested in staffing a React Native project? Contact us.