At Slack we use push notifications to let you know when someone sends you a direct message, or posts in a channel. As part of our Growth team efforts, we wanted to experiment with using push notifications to inform you of other things happening within your Slack team. While this sounds simple, there were a few technical challenges along the way.

The current push notification system is tightly coupled to the idea that a push notification is triggered by user-to-user interaction. As such, when the idea was proposed for all-purpose notifications — where we could send notifications for any event, not just user messages— we set about trying to decouple this architecture and add to it in a way that maximized extensibility and didn’t break backwards compatibility.

Being Backwards Compatible

One of the biggest challenges was making sure all our new code paths wouldn’t affect the functionality of the existing clients. On Android, this meant updating our existing payload data model in a way that allowed for flexibility and extensibility. This is what our notification payload model looked like after adding the fields needed to handle these new push notifications:

public class Notification {  
  String id;
  String deepLinkUri;
  MessageType type;
  // New fields.
  String teamId;
  String userId;
  Subtype subtype; // Used mostly for analytics.
}  
public enum MessageType {
  MENTION,
  INFO,
  CALL,
  BADGE,
  TEST;
}

To get the functionality required out of deep links, we created a “Trampoline” activity that received deep link intents and routed them correctly using a simple custom parser. Here is the flow:

  1. Server sends push
  2. NotificationHelper creates a PendingIntent that will open DeepLinkActivity, set the URI as the data on the intent, and fire the notification.
  3. User clicks notification, PendingIntent starts DeepLinkActivity, which parses the URI and routes the user to the appropriate place.

The biggest issue we faced while implementing this logic was that of backwards compatibility. For example, what if the server sends us a Notification Type we don’t yet know about, or doesn’t send one at all? There There are three ways we could handle this:

  • Throw an error and crash (put the onus on the server to never send us bad data, which could be a bad user experience if we do receive bad data)
  • Swallow the notification request completely (not as bad since the user isn’t affected but we’ll lose any analytics on tracking that push)
  • Make type less restrictive and allow any type to go through

We went with #2, so if we get a push of an unknown type, we simply throw it away. We could guarantee the types we knew about, so there was no real harm in this.

Next, we needed to modify our existing notification processing logic. Based on the type of the notification, the class has a few responsibilities:

  • Handle analytics tracking for when a push is received
  • Reconcile the different read and unread states, in order to display the correct UI to the user (i.e. when to badge a channel icon, etc.)
  • Determine type and assemble the bits needed to create an Android Notification.

The class logic branches based on the notification type (i.e. pre info type):

switch (messageNotification.getType()) {
  case mention:
    // Process the mention notification.
    break;
    ...
 default:
   // Got a push with no type, Bail out now!
   break;
}

After creating the notification processing logic, we thought about how the system should respond to info notifications.

  • What image should be displayed if the team has not set a team icon?
  • How should the notifications display? Should they wake up the phone, or come in silently?
  • What should happen if a user has a pending info notification and additional notifications (of any type) come in.

We have decided that info type pushes would be very simple:

  • We would use the team’s icon image if set, and no image if not set.
  • Info pushes will show silently (the device will not wake, flash, or vibrate)
  • If a user has an outstanding info push, and receives another, then the outstanding one should be replaced by the newest
  • If a user has an outstanding info push and receives a mention type notification, then the info push should clear completely

The following pseudocode shows our logic for handling these cases:

if (no notifications of any type) {
   // Show the next notification to come in.
} 
else if (user has mention notifications and receives an info push) { 
 
   // Display the `info` notification 
    ...but don’t make sounds, use lights, or wake up the device.
} 
else if (user has info notification and receives new info push) {
     // Replace the outstanding info notification with the new one.
      ...but don’t make sounds, use lights, or wake up the device.
} 
else if (user has info notification & receives new mention push) {
     // Cancel existing info notification and show the new Mention
       ...use lights, sounds, and wake up the device.
}

The rest of the notification design goes deeply into the weeds of the Android-specific system construct, NotificationCompat.Builder(), and we’ll omit that here. There is, however, one important thing to mention: You must have unique and consistent notification IDs. If you don’t, you’ll end up creating duplicate notifications and/or canceling notifications will have no effect.

Handling Payloads

On iOS, there were a couple of considerations that had to be taken into account when handling the new notification payloads. When the app is launched from a push notification, the delegate callbacks are operated on as follows:

  1. Track the notification (more on this below)
  2. Extract deep link information from payload and create our URL object
  3. Ask our DeepLinkHandler if it can handle our URL objects, and if so, handle it.

DeepLinkHandler is a singleton that is responsible for registering and maintaining our deep link routers. It has no awareness of the app state and only cares about the status of all the routers it handles; you can think of it as a traffic controller. When you login to a team, we set up a deep link router that defines behavior to handle deep links with the Slack scheme.

Our routers conform to a protocol that makes the following methods available to the DeepLinkHandler:

protocol DeepLinkRouterProtocol: NSObjectProtocol {
   var scheme: String { get }
   func canHandleURL(url: NSURL) -> Bool
   func handlePresentationForURL(url: NSURL) -> Bool
   func isReadyForPresentingURL(url: NSURL, completion: @escaping (isReady: Bool, needsDelay: Bool, waitForLaunch: Bool) -> Void)
}

When we ask the DeepLinkHandler if it can handle a URL, it checks to see whether the URL is structured correctly, if we have a matching router (given the structure of the URL), and if the deep link router can handle that URL. For example, our deep link router can handle actions like opening a channel, opening a direct message, or opening a file.

When asking the DeepLinkHandler to handle a URL, it ensures that the router is ready for presenting, and if not, it waits until it is. The router has the context of the app, and will do the necessary operations to get our app into an actionable state. Therefore, it is only ready for the presentation of a deep link when our data stack and UI have been created. This is especially important if we’re routing to a different team than the one you’re active on. The following pseudocode shows the logic our router goes through to determine what to tell the DeepLinkHandler

func isReadyForPresentingURL(url: NSURL, completion: @escaping (isReady: Bool, needsDelay: Bool, waitForLaunch: Bool) -> Void) {
    // Are we showing the loading view? This means we are setting up our data stack and UI, call completion with:
    isReady = false, needsDelay = true, waitForLaunch = true
    // If the target team is the current active team, call completion with:
    isReady = true, needsDelay = false, waitForLaunch = false
    // Else, we need to switch teams so call completion with:
    isReady = false, needsDelay = true, waitForLaunch = false
}

Once we’re given the go-ahead from the router that it is ready, the DeepLinkHandler calls upon the router to handle the presentation of the URL.

func handlePresentationForURL(url: URL) -> Bool {
 let action = DeepLinkVerb(stringValue: url.host)
 switch action {
     case .file:
         // Present any file modally
     case .invite: 
         // Show the user invitation form
     case .settings: 
         // Present the Settings view and automatically push to the    
 notifications section
     ...

Measuring our Impact

We wanted the ability to track the impact these push notifications had on our users. Each time a notification action occurs (e.g. received or tapped), we send over the pushId, type, and date of the notification to our analytics warehouse. By comparing these events with how many people have subscribed to our push notifications, we can start identifying trends and scrap notifications that users don’t find valuable.

For every push received, UNNotificationServiceExtension (iOS) and NotificationHelper.java (Android) are called; that means we can add a statement to the top of those classes that tracks when a push is received.

Initially, we assumed that we would be able to send the tracking request to the server as soon as a notification came in, but due to flakey network connections we saw some analytics requests get dropped. In the future, we plan to store push notification ids in local persistence until the analytics are caught up. This will allow us to piece together the full picture of whether our all-purpose push system actually had the desired effect.

Once we had a system to in place to deliver certain information to users, it was decided that our first test case would be sending a push notification when a team gained its first member

The first all-purpose push notification (iOS)

Looking at early data points, we were excited to see that all our work had paid off, and the experiment was a success! During our initial phased rollout we saw a 7.0% lift in users invited and a 1.5% lift in teams sending messages.

Up, up, and away!

This work was an exercise in forethought: Every new field that we added to the push payload had the potential to crash the app, every notification we sent had the potential to annoy our users, and every new piece of analytics gleaned had the potential to draw a clearer picture of the effectiveness of the project. By creating a flexible system, we’re well set up for new kinds of push notifications in the future.

If you’re interested in changing how people get work done from their mobile device, come check our mobile engineering job listings!