Android O / Notification

Oreo Notifications: Channels – Part 2

In what seems to be an annual traditions for Styling Android, we’er going to look at the changes to Notifications in the latest version of Android which is, at the time of writing, Oreo 8.1 (API 27). While there aren’t widespread changes to Notifications as there have been in previous Android versions, there are some significant one, and we’ll start off by looking at Notification Channels.

In the previous article we discussed what channels are, and why they are important to the user, and in this article we’ll actually implement them. Let’s start with the deprecated Builder constructors which we mentioned in the previous article. For both the Notification and summary Notification we were using NotificationCompat.Builder(Context context), and this has been deprecated in Oreo V8.0 (API 26). It has been replaced by a constructor which takes an additional argument representing the ID of the channel that the Notification should be added to:

class NotificationBuilder(
        private val context: Context,
        private val safeContext: Context = context.safeContext(),
        private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(safeContext),
        private val channelBuilder: NotificationChannelBuilder = NotificationChannelBuilder(context, CHANNEL_IDS),
        private val random: Random = Random()
) {

    private var notificationId: Int by bindSharedPreference(context, KEY_NOTIFICATION_ID, 0)

    fun sendBundledNotification(message: Message) =
            with(notificationManager) {
                channelBuilder.ensureChannelsExist(createChannel)
                randomChannelId.also {
                    notify(notificationId++, buildNotification(message, it))
                    notify(SUMMARY_ID, buildSummary(message, it))
                }
            }

    private val randomChannelId
        get() = CHANNEL_IDS[random.nextInt(CHANNEL_IDS.size)]

    @TargetApi(Build.VERSION_CODES.O)
    private val createChannel: (channelId: String) -> NotificationChannel? = { channelId ->
        when (channelId) {
            IMPORTANT_CHANNEL_ID -> NotificationChannel(channelId,
                    context.getString(R.string.important_channel_name),
                    NotificationManager.IMPORTANCE_HIGH).apply {
                description = context.getString(R.string.important_channel_description)
            }
            NORMAL_CHANNEL_ID -> NotificationChannel(channelId,
                    context.getString(R.string.normal_channel_name),
                    NotificationManager.IMPORTANCE_DEFAULT).apply {
                description = context.getString(R.string.normal_channel_description)
            }
            LOW_CHANNEL_ID -> NotificationChannel(channelId,
                    context.getString(R.string.low_channel_name),
                    NotificationManager.IMPORTANCE_LOW).apply {
                description = context.getString(R.string.low_channel_description)
            }
            else -> null
        }
    }

    private fun buildNotification(message: Message, channelId: String): Notification =
            with(NotificationCompat.Builder(context, channelId)) {
                message.apply {
                    setContentTitle(sender)
                    setContentText(text)
                    setWhen(timestamp.toEpochMilli())
                }
                setSmallIcon(getIconId(channelId))
                setShowWhen(true)
                setGroup(GROUP_KEY)
                build()
            }

    private fun getIconId(channelId: String) =
            when (channelId) {
                IMPORTANT_CHANNEL_ID -> R.drawable.ic_important
                LOW_CHANNEL_ID -> R.drawable.ic_low
                else -> R.drawable.ic_message
            }

    private fun buildSummary(message: Message, channelId: String): Notification =
            with(NotificationCompat.Builder(context, channelId)) {
                setContentTitle(SUMMARY_TITLE)
                setContentText(SUMMARY_TEXT)
                setWhen(message.timestamp.toEpochMilli())
                setSmallIcon(R.drawable.ic_message)
                setShowWhen(true)
                setGroup(GROUP_KEY)
                setGroupSummary(true)
                build()
            }

    companion object {
        private const val KEY_NOTIFICATION_ID = "KEY_NOTIFICATION_ID"
        private const val GROUP_KEY = "Messenger"
        private const val SUMMARY_ID = 0
        private const val SUMMARY_TITLE = "Nougat Messenger"
        private const val SUMMARY_TEXT = "You have unread messages"
        private const val IMPORTANT_CHANNEL_ID = "IMPORTANT_CHANNEL_ID"
        private const val NORMAL_CHANNEL_ID = "NORMAL_CHANNEL_ID"
        private const val LOW_CHANNEL_ID = "LOW_CHANNEL_ID"
        private val CHANNEL_IDS =
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    listOf(IMPORTANT_CHANNEL_ID, NORMAL_CHANNEL_ID, LOW_CHANNEL_ID)
                } else {
                    listOf(NORMAL_CHANNEL_ID)
                }
    }
}

The changes that we’ve made seem to be quite a lot, but it’s actually pretty straightforward once we break things down. The first thing that we’re doing is specifying a channelId to the Builder constructors on lines 46 & 66. That gets rid of the deprecation warnings that we saw previously. The channelID strings are defined in the companion object (lines 83-92). It is worthy of note that we create all of the IDs for the supported channels on Oreo, but only create a single channel ID for previous API versions. The reason for this is that I saw that if I used multiple channel IDs on a Nougat device then I would get a notification stating that I had unread notifications, but did not see the notifications themselves. This problem went away if I restricted things to a single channel ID, hence this check.

In the main Notification creation we use a different icon depending on the channel (line 52). I did this so that we had a visual indication of the channel in each notification. This logic is implemented in getIconId() (lines 58-63).

In sendBundledNotifications() we create a random channel ID (line 14), and specify this id for the function calls to create the Notification and summary Notification (lines 15-16).

The final thing in here is the call to ChannelBuilder.ensureChannelsExist() on line 13, and this will require a little explanation. So far we have switched to the correct Builder constructors which accept a channel ID argument, and this will work perfectly well on pre API 26 devices. However, for Oreo 8.0 (API 26) an later we need to actually create the channels corresponding to these IDs. The classes that we require to do this are only available in API 26 and later, so we need to add some OS version checking to ensure that we only attempt this on devices which have those APIs. I have implemented all of the logic to perform the OS version check and create the channels if necessary in NotificationChannelBuilder (which we’ll look at in a moment). NotificationChannelBuilder is completely agnostic about the actual channels and IDs, so is completely reusable. NotificationBuilder itself knows about these, so passes in the list of channel IDs in the NotificationChannelBuilder constructor, and provides a NotificationChannel builder as a function reference to the ensureChannelsExist() function.

createChannel() is this builder function, and it takes a channelId String as an argument and builds the appropriate NotificationChannel. In this example I have used pretty standard defaults based around the levels of Notification importance that are pre-defined, with just a custom description being added to each. However, it is possible to completely customise the default behaviour of the channel using the various properties of https://developer.android.com/reference/android/app/NotificationChannel.html but I’ve kept it fairly simple in order to keep the code more compact an understandable.

One important thing to note is that we have to annotate createChannel() with @TargetApi(Build.VERSION_CODES.O) in order to suppress any OS version warnings (we can omit this if we’re minSdkVersion="26" or higher). The logic inside NotificationChannelBuilder will ensure that this only gets call on API 26 and later devices.

So let’s take a look at NotificationChannelBuilder:

class NotificationChannelBuilder(
        context: Context,
        private val channelIds: List<String>,
        private val notificationManager: NotificationManager =
            context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
) {

    fun ensureChannelsExist(createChannel: (channelId: String) -> NotificationChannel?) =
            ifAtLeast(Build.VERSION_CODES.O) {
                notificationManager.ensureChannelsExist(createChannel)
            }

    @TargetApi(Build.VERSION_CODES.O)
    private fun NotificationManager.ensureChannelsExist(createChannel: (channelId: String) -> NotificationChannel?) {
        channelIds
                .filter { !notificationChannelIds().contains(it) }
                .forEach {
                    createChannel(it)?.also {
                        notificationManager.createNotificationChannel(it)
                    }
                }
    }

    @TargetApi(Build.VERSION_CODES.O)
    private fun NotificationManager.notificationChannelIds() =
            notificationChannels.map { it.id }

}

The only public function is ensureChannelsExist() and it is this which initially performs the OS version check and only calls NotificationManager.ensureChannelsExist() if the OS version is API 26 or higher.

The NotificationManager.notificationChannelIds() function returns a list of the channel IDs of channels which already exist for the app, and this is used by NotificationManager.ensureChannelsExist() to filter the list of channelIds that were passed in in to the constructor to obtain a list of those which do not already exist. The forEach block is executed for each of those. In that block the builder function (that we saw defined in NotificationBuilder) will be called to create a NotificationChannel instance for the channel ID, and then we call NotificationManager to actually create the channel.

Only once the channel has been created by NotificationManager can we actually send a notification to that channel – that’s why we need to call ensureChannelsExist() before we attempt to send the notification.

If we run this for a while we’ll get a number of notifications generated. The high priority ones will always be displayed above the normal priority which will always be displayed above the low priority ones. I’ve used different icons so the priority can easily be seen:

This now provides the user with all of the control that we looked at in the previous article.

That’s it for the important notification changes in Oreo. However, we’re not quite done with notifications just yet. In the next article we’ll look at another issue with notifications, but this is more of a legacy one rather than being specific to Oreo.

The source code for this article is available here.

© 2017, Mark Allison. All rights reserved.

Copyright © 2017 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

1 Comment

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.