Skip to content

Commit

Permalink
[expo-notifications] Handling notifications (#6796)
Browse files Browse the repository at this point in the history
# Why

Next `expo-notifications` feature.

# How

- `NotificationsHandlerModule` registers at singleton for new notifications/messages
- for each message it _starts up_ a task which emits an event to JS
- in response to the JS event, delegate responds with the appropriate behavior (eg. `shouldShowAlert: true`)
- the behavior is pushed to native side using `NotificationsHandler.handleNotificationAsync` call
- which directs it to the appropriate task
- task handles the behavior (on iOS calls `completionHandler`, on Android it will show the notification once implemented) and finishes
- if for whatever reason delegate didn't respond in 3 seconds, `onTimeout` is called on task, which emits another event to JS (for debugging purposes) and the task finishes

![excalidraw-202012311929-7](https://user-images.githubusercontent.com/1151041/73078318-3ca14180-3ec2-11ea-9220-c7a2f3c1e558.png)

# Test Plan

Tested manually by sending notifications and logging messages that the scheme works both when the delegate responds and when it does not.
  • Loading branch information
sjchmiela committed Feb 12, 2020
1 parent beb8dff commit 03846fa
Show file tree
Hide file tree
Showing 28 changed files with 1,109 additions and 118 deletions.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

255 changes: 140 additions & 115 deletions apps/bare-expo/ios/Pods/EXNotifications.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

192 changes: 192 additions & 0 deletions apps/test-suite/tests/NewNotifications.js
Expand Up @@ -71,6 +71,198 @@ export async function test(t) {
t.expect(expoPushToken.type).toBe('expo');
t.expect(typeof expoPushToken.data).toBe('string');
});

// Not running those tests on web since Expo push notification doesn't yet support web.
const describeWithExpoPushToken = ['ios', 'android'].includes(Platform.OS)
? t.describe
: t.xdescribe;

describeWithExpoPushToken('when a push notification is sent', () => {
let notificationToHandle;
let handleSuccessEvent;
let handleErrorEvent;

let expoPushToken;

let handleFuncOverride;

t.beforeAll(async () => {
let experienceId = undefined;
if (!Constants.manifest) {
// Absence of manifest means we're running out of managed workflow
// in bare-expo. @exponent/bare-expo "experience" has been configured
// to use Apple Push Notification key that will work in bare-expo.
experienceId = '@exponent/bare-expo';
}
const pushToken = await Notifications.getExpoPushTokenAsync({
experienceId,
});
expoPushToken = pushToken.data;

Notifications.setNotificationHandler({
handleNotification: async notification => {
notificationToHandle = notification;
if (handleFuncOverride) {
return await handleFuncOverride(notification);
} else {
return {
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowAlert: true,
};
}
},
handleSuccess: event => {
handleSuccessEvent = event;
},
handleError: event => {
handleErrorEvent = event;
},
});
});

t.beforeEach(async () => {
handleErrorEvent = null;
handleSuccessEvent = null;
notificationToHandle = null;
await sendTestPushNotification(expoPushToken);
});

t.afterAll(() => {
Notifications.setNotificationHandler(null);
});

t.it('calls the `handleNotification` callback of the notification handler', async () => {
let iterations = 0;
while (iterations < 5) {
iterations += 1;
if (notificationToHandle) {
break;
}
await waitFor(1000);
}
t.expect(notificationToHandle).not.toBeNull();
});

t.describe('if handler responds in time', async () => {
t.beforeAll(() => {
// Overriding handler to return a no-effect behavior
// for Android not to reject the promise with
// "Notification presenting not implemented."
// TODO: Remove override when notification presenting
// is implemented.
handleFuncOverride = async () => {
return {
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowAlert: false,
};
};
});

t.afterAll(() => {
handleFuncOverride = null;
});

t.it(
'calls `handleSuccess` callback of the notification handler',
async () => {
let iterations = 0;
while (iterations < 5) {
iterations += 1;
if (handleSuccessEvent) {
break;
}
await waitFor(1000);
}
t.expect(handleSuccessEvent).not.toBeNull();
t.expect(handleErrorEvent).toBeNull();
},
10000
);
});

t.describe('if handler fails to respond in time', async () => {
t.beforeAll(() => {
handleFuncOverride = async () => {
await waitFor(3000);
return {
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowAlert: true,
};
};
});

t.afterAll(() => {
handleFuncOverride = null;
});

t.it(
'calls `handleError` callback of the notification handler',
async () => {
let iterations = 0;
while (iterations < 5) {
iterations += 1;
if (handleErrorEvent) {
break;
}
await waitFor(1000);
}
t.expect(handleErrorEvent).not.toBeNull();
t.expect(handleSuccessEvent).toBeNull();
},
10000
);
});
});
});
});
}

// In this test app we contact the Expo push service directly. You *never*
// should do this in a real app. You should always store the push tokens on your
// own server or use the local notification API if you want to notify this user.
const PUSH_ENDPOINT = 'https://expo.io/--/api/v2/push/send';

async function sendTestPushNotification(expoPushToken, notificationOverrides) {
// POST the token to the Expo push server
const response = await fetch(PUSH_ENDPOINT, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify([
{
to: expoPushToken,
title: 'Hello from Expo server!',
...notificationOverrides,
},
]),
});

const result = await response.json();
if (result.errors) {
for (const error of result.errors) {
console.warn(`API error sending push notification:`, error);
}
throw new Error('API error has occurred.');
}

const receipts = result.data;
if (receipts) {
const receipt = receipts[0];
if (receipt.status === 'error') {
if (receipt.details) {
console.warn(
`Expo push service reported an error sending a notification: ${receipt.details.error}`
);
}
if (receipt.__debug) {
console.warn(receipt.__debug);
}
throw new Error(`API error has occurred: ${receipt.details.error}`);
}
}
}
Expand Up @@ -10,9 +10,10 @@
import java.util.List;

import expo.modules.notifications.installationid.InstallationIdProvider;
import expo.modules.notifications.notifications.channels.ExpoNotificationChannelsManager;
import expo.modules.notifications.notifications.NotificationManager;
import expo.modules.notifications.notifications.channels.ExpoNotificationChannelsManager;
import expo.modules.notifications.notifications.emitting.NotificationsEmitter;
import expo.modules.notifications.notifications.handling.NotificationsHandler;
import expo.modules.notifications.tokens.PushTokenManager;
import expo.modules.notifications.tokens.PushTokenModule;

Expand All @@ -22,6 +23,7 @@ public List<ExportedModule> createExportedModules(Context context) {
return Arrays.asList(
new PushTokenModule(context),
new NotificationsEmitter(context),
new NotificationsHandler(context),
new InstallationIdProvider(context)
);
}
Expand Down
@@ -0,0 +1,152 @@
package expo.modules.notifications.notifications.handling;

import android.content.Context;

import com.google.firebase.messaging.RemoteMessage;

import org.unimodules.core.ExportedModule;
import org.unimodules.core.ModuleRegistry;
import org.unimodules.core.Promise;
import org.unimodules.core.arguments.ReadableArguments;
import org.unimodules.core.interfaces.ExpoMethod;

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

import expo.modules.notifications.notifications.emitting.NotificationsEmitter;
import expo.modules.notifications.notifications.interfaces.NotificationBehavior;
import expo.modules.notifications.notifications.interfaces.NotificationListener;
import expo.modules.notifications.notifications.interfaces.NotificationManager;

/**
* {@link NotificationListener} responsible for managing app's reaction to incoming
* notification.
* <p>
* It is responsible for managing lifecycles of {@link SingleNotificationHandlerTask}s
* which are responsible: one for each notification. This module serves as holder
* for all of them and a proxy through which app responds with the behavior.
*/
public class NotificationsHandler extends ExportedModule implements NotificationListener {
private final static String EXPORTED_NAME = "ExpoNotificationsHandlerModule";

private NotificationManager mNotificationManager;
private ModuleRegistry mModuleRegistry;

private Map<String, SingleNotificationHandlerTask> mTasksMap = new HashMap<>();

public NotificationsHandler(Context context) {
super(context);
}

@Override
public String getName() {
return EXPORTED_NAME;
}

@Override
public void onCreate(ModuleRegistry moduleRegistry) {
mModuleRegistry = moduleRegistry;

// Register the module as a listener in NotificationManager singleton module.
// Deregistration happens in onDestroy callback.
mNotificationManager = moduleRegistry.getSingletonModule("NotificationManager", NotificationManager.class);
mNotificationManager.addListener(this);
}

@Override
public void onDestroy() {
mNotificationManager.removeListener(this);
Collection<SingleNotificationHandlerTask> tasks = mTasksMap.values();
for (SingleNotificationHandlerTask task : tasks) {
task.stop();
}
}

/**
* Called by the app with {@link ReadableArguments} representing requested behavior
* that should be applied to the notification.
*
* @param identifier Identifier of the task which asked for behavior.
* @param behavior Behavior to apply to the notification.
* @param promise Promise to resolve once the notification is successfully presented
* or fails to be presented.
*/
@ExpoMethod
public void handleNotificationAsync(String identifier, final ReadableArguments behavior, Promise promise) {
SingleNotificationHandlerTask task = mTasksMap.get(identifier);
if (task == null) {
String message = String.format("Failed to handle notification %s, it has already been handled.", identifier);
promise.reject("ERR_NOTIFICATION_HANDLED", message);
return;
}
task.handleResponse(new ArgumentsNotificationBehavior(behavior), promise);
}

/**
* Callback called by {@link NotificationManager} to inform its listeners of new messages.
* Starts up a new {@link SingleNotificationHandlerTask} which will take it on from here.
*
* @param message Received message
*/
@Override
public void onMessage(RemoteMessage message) {
SingleNotificationHandlerTask task = new SingleNotificationHandlerTask(mModuleRegistry, message, this);
mTasksMap.put(task.getIdentifier(), task);
task.start();
}

/**
* Callback called by {@link NotificationManager} to inform that some push notifications
* haven't been delivered to the app. It doesn't make sense to react to this event in this class.
* Apps get notified of this event by {@link NotificationsEmitter}.
*/
@Override
public void onDeletedMessages() {
// do nothing
}

/**
* Callback called once {@link SingleNotificationHandlerTask} finishes.
* A cue for removal of the task.
*
* @param task Task that just fulfilled its responsibility.
*/
void onTaskFinished(SingleNotificationHandlerTask task) {
mTasksMap.remove(task.getIdentifier());
}

/**
* An implementation of {@link NotificationBehavior} capable of
* "deserialization" of behavior objects with which the app responds.
* <p>
* Used in {@link #handleNotificationAsync(String, ReadableArguments, Promise)}
* to pass the behavior to {@link SingleNotificationHandlerTask}.
*/
class ArgumentsNotificationBehavior extends NotificationBehavior {
private static final String SHOULD_SHOW_ALERT_KEY = "shouldShowAlert";
private static final String SHOULD_PLAY_SOUND_KEY = "shouldPlaySound";
private static final String SHOULD_SET_BADGE_KEY = "shouldSetBadge";

private ReadableArguments mArguments;

ArgumentsNotificationBehavior(ReadableArguments arguments) {
mArguments = arguments;
}

@Override
public boolean shouldShowAlert() {
return mArguments.getBoolean(SHOULD_SHOW_ALERT_KEY);
}

@Override
public boolean shouldPlaySound() {
return mArguments.getBoolean(SHOULD_PLAY_SOUND_KEY);
}

@Override
public boolean shouldSetBadge() {
return mArguments.getBoolean(SHOULD_SET_BADGE_KEY);
}
}
}

0 comments on commit 03846fa

Please sign in to comment.