Share

Custom Notification Sounds for Android Oreo and Beyond

The software engineering world is moving quickly; libraries and frameworks are always changing. This is especially true on Android where Google only recently solidified its standards for application architecture and are still refining features like permissions, split-screen, and Do Not Disturb. The Mobile team at PagerDuty was put in a sticky situation from one such change: the introduction of notification channels with Android Oreo.

What’s the issue?

When an app switches its targetSdkVersion to Android Oreo, its developers are now required to assign every single notification to a channel. These channels give users the granularity to set different behaviour for each type of notification. For example, the Dropbox app has four notification channels: Camera Uploads, Default, File Transfer, and Uncategorised. If I don’t want to see or hear any notifications telling me that my selfies are being uploaded to the cloud, I have the option of completely turning off the Camera Uploads channel.

The issue for PagerDuty was that Google’s deadline to bump our targetSdkVersion was coming up and if we didn’t take action, we wouldn’t be able to update our app anymore. For Oreo and up, the sound and vibrate settings can no longer be set from within the PagerDuty app; they must be set inside the Settings app on a per-channel basis where the user only has access to the stock ringtones. Moreover, notification channels are immutable from the perspective of the developer, so we only have one shot – one opportunity – to configure the channel. While we had the option to delete and re-create the channel with different notification sounds, the user may have already customized their preferences, and we didn’t want to wipe that away. Thus, our users wouldn’t be able to lose themselves in the beautiful, yet heartbreaking barbershop harmonies of “Server’s on Fire”, nor have the ambient “Railroad Crossing” faintly in the backdrop, in reminisce of the bumpy 8-mile ride home to the familiar comforts of mom’s spaghetti once more.

Our proposed solution was to create a background service for Oreo and above that handles playing and stopping our custom notification sounds. The challenge for us was to mimic the system’s notification sound behaviour as closely as we could.

Making noise

The first order of business was to prototype the service that would simply play a sound when it was started, which we aptly named NotificationSoundService. We considered two options for mechanisms to actually play the sound: RingtoneManager and MediaPlayer. We tested our options and they both worked as expected, but we decided to go with MediaPlayer since our developers were more familiar with its APIs and we thought it was more future-proof with its customizability and error handling.

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
  val mediaPlayer = MediaPlayer().apply {
    setOnPreparedListener { it -> it.start() }

    try {
      it.setDataSource(applicationContext, Uri.parse(intent.getStringExtra(SOUND_URI_KEY)))
      it.prepareAsync()
    } catch (e: Exception) {
    // log exceptions
      stopSelf()
    }
  }

  // service will not be recreated if abnormally terminated
  return Service.START_NOT_STICKY
}

As an aside: there are new background execution limits introduced in Oreo that restrict apps from starting background services from Google/Firebase Cloud Messaging notifications that are not marked with priority = high. However, due to the nature of our app, all notifications from PagerDuty that are associated with incidents are priority = high, so this wasn’t an issue for us.

Learning to behave

After we achieved the basic functionality of playing a sound, we needed to figure out how normal notifications behave in terms of when they play, when they stop playing, and when system settings cause them to not play at all (like in Do Not Disturb mode). In our testing, we observed that there were three ways of stopping a notification sound once it has been started: clicking it, swiping it, and invoking an action on it. (Unfortunately, twisting it and bopping it didn’t have any effect.)

It was quite simple to stop the sound in response to a notification click or action. We created a static method that takes in the applicationContext (since we always create the service with that particular context), which stops NotificationSoundService and frees up the MediaPlayer resources.

fun stopNotificationSound(applicationContext: Context) {
    // check for Android Oreo and up
    if (useNotificationSoundService()) {
      val stopSoundIntent = Intent(applicationContext, NotificationSoundService::class.java)
      try {
        applicationContext.stopService(stopSoundIntent)
      } catch (exception: Exception) {
        // log exceptions
      }
    }
  }

We were able to use this method in the same place where our content Intent from notificationBuilder.setContentIntent(…) was being handled. This dealt with the situation of the user clicking on the notification. We also used the method where we were handling notification actions that were set with notificationBuilder.addAction(…) – an example of a PagerDuty notification action is when you have the option to Acknowledge an incident directly from the heads-up notification.

For the case where the user swipes away the notification, it was less convenient to use the stopNotificationSound() since we weren’t setting notificationBuilder.setDeleteIntent(…). We decided to send the delete Intent directly to NotificationSoundService and have it stop itself. So now, the service would be receiving a play action and a stop action, which looks like this:

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
  // check action
  val action = intent?.action
  when (action) {
    START_PLAYBACK_KEY -> startSound(intent.getStringExtra(SOUND_URI_KEY))
    STOP_PLAYBACK_KEY -> stopSound()
  }
  // log exceptions
  return Service.START_NOT_STICKY
}

With the playing and stopping behaviour all tidied up, we started looking into how Do Not Disturb mode and notification channel settings affected whether or not the notification sound played. Though it was a bit complicated to test and keep track of what we had tested, we came up with two simple conditions that had to be true for the sound to play.

Firstly, either the user has allowed PagerDuty to bypass DND or DND needs to be off.
Secondly, the notification channel importance must not be NotificationManager.IMPORTANCE_NONE.

val isDoNotDisturbOn = notificationManager.getCurrentInterruptionFilter() > NotificationManager.INTERRUPTION_FILTER_ALL
  if ((notificationChannel.canBypassDnd() || !isDoNotDisturbOn) && notificationChannel.getImportance() !== NotificationManager.IMPORTANCE_NONE) {
    startService(notificationSoundServiceIntent)
  }

Yay! Now we’re able to stop the notification sound from playing as we expect and our notification channel and system settings are correctly deciding whether or not to play the sound in the first place. That’s it …right?

Testing

Google introduced the concept of Doze and App Standby to Android with Marshmallow, which are mechanisms of decreasing battery drain and background network usage. Doze activates when the device is unused for long periods of time and App Standby starts when the user hasn’t recently interacted with a specific app. However, PagerDuty notifications are most critical when our users are asleep (and their phones are likely in Doze & App standby mode), so we needed to confirm that users would still receive their notifications and hear the sound in this scenario. Luckily, Google has built commands into adb that allow developers to force their app into both of these modes so we were able to trigger incidents on our PagerDuty test accounts and see the notifications come in on our devices.

Conclusion

In the end, we just want to keep our users happy and we’d go the extra mile to make that happen. We’re passionate about keeping the PagerDuty service reliable, especially when you’re away from your computer. If this sounds like something you’d be interested in, we’re hiring!

Get Social