Advanced Notifications

Notifications are those icons that appear in the status bar (or system bar on tablets), typically to alert the user of something that is going on in the background or has completed in the background. Many apps use them, to let the user know of new email messages, calendar reminders, and so on. Foreground services, such as music players, also use notifications, to tell the OS that they are part of the foreground user experience and to let the user rapidly return to the apps to turn the music off.

There are other tricks available with the Notification object beyond those originally discussed in an earlier chapter.

Prerequisites

Understanding this chapter requires that you have read the chapter on basic notifications and the section on RemoteViews in the chapter on basic app widgets.

Being a Good Citizen

Users have a love/hate relationship with apps that use notifications:

And so on.

Users’ discomfort with how apps handle notifications is why Android allows users to disable notifications.

Some of the items in this chapter, particularly those surrounding Wear OS, can help you improve user satisfaction with your notification strategy and tactics. Yet, at the same time, misuse of notifications is magnified by Wear, as Wear takes extra steps to get the user to pay attention to the notifications, with possibly disastrous results for your Play Store reviews.

In short, your objective with notifications is to be a good citizen:

More About Channels

Android 8.0 moved a lot of notification configuration out of NotificationCompat.Builder and into a new NotificationChannel class, and moving their effects from being per-notification to per-channel. When you configure a channel, all notifications in that channel will exhibit the behavior defined for the channel.

What You Do with Channels

Many configuration options formerly set on a Notification are now set on a NotificationChannel:

You Used to Call This on Notification You Now Call This on NotificationChannel
setDefaults() no real analogue
setLights() enableLights() and setLightColor()
setPriority() setImportance()
setSound() setSound()
setVibration() enableVibration() and setVibrationPattern()
setVisibility() setLockscreenVisibility()

The “enable” methods (enableLights(), enableVibration()) opt you into whatever the device default behaviors are for those features.

As a result of these things moving to NotificationChannel, the user can control the behavior. You might request to enable vibration, but the user will be able to override your choice, for example.

In addition to these options, a NotificationChannel also configures:

Channels can also be associated into channel groups. This is purely for organizational purposes; groups show up on the screen that the user sees to help cluster related channels together. Beyond that, channels in groups behave identically to channels not in groups.

Creating Channel Groups

A NotificationChannelGroup is the Java class embodiment of a channel group. A NotificationChannelGroup consists purely of a String unique identifier and a CharSequence (e.g., String) display name to show the user. In most cases, you will want the display name to come from a string resource, for translation purposes.

You need to register your channel groups with Android. To do this, you will need to call one of the following methods on NotificationManager:

The Notifications/Channels sample project demonstrates the use of notification channels and channel groups. The MainActivity has an initGroups() method that defines two channel groups:

  private void initGroups() {
    ArrayList<NotificationChannelGroup> groups=new ArrayList<>();

    groups.add(new NotificationChannelGroup(GROUP_UPDATES,
      getString(R.string.group_name_updates)));
    groups.add(new NotificationChannelGroup(GROUP_PROMO,
      getString(R.string.group_name_promo)));

    mgr.createNotificationChannelGroups(groups);
  }
(from Notifications/Channels/app/src/main/java/com/commonsware/android/notify/channel/MainActivity.java)

Here, the display names come from string resources, and the channel group IDs are string constants defined on MainActivity itself.

Creating Channels

Creating channels, in turn, involves configuring NotificationChannel objects and calling createNotificationChannel() on NotificationManager for each channel.

MainActivity has three methods for defining three separate channels, in those two channel groups:

  private void initContentChannel() {
    NotificationChannel channel=
      new NotificationChannel(CHANNEL_CONTENT,
        getString(R.string.channel_name_content),
        NotificationManager.IMPORTANCE_LOW);

    channel.setGroup(GROUP_UPDATES);
    mgr.createNotificationChannel(channel);
  }

  private void initBattleChannel() {
    NotificationChannel channel=
      new NotificationChannel(CHANNEL_BATTLE,
        getString(R.string.channel_name_battle),
        NotificationManager.IMPORTANCE_HIGH);

    channel.setGroup(GROUP_UPDATES);
    channel.setShowBadge(true);
    mgr.createNotificationChannel(channel);
  }

  private void initCoinsChannel() {
    NotificationChannel channel=
      new NotificationChannel(CHANNEL_COINS,
        getString(R.string.channel_name_coins),
        NotificationManager.IMPORTANCE_DEFAULT);

    channel.setGroup(GROUP_PROMO);
    mgr.createNotificationChannel(channel);
  }
(from Notifications/Channels/app/src/main/java/com/commonsware/android/notify/channel/MainActivity.java)

The constructor takes a unique String identifier for the channel, a user-facing display name for the channel, and the importance of the notifications in that channel. Again, typically you will want to use a string resource for the display name, as the sample code demonstrates.

Between creating the NotificationChannel and registering it with createNotificationChannel() on NotificationManager, you can call various setter methods to configure the way that this channel behaves, beyond the importance. For example, if you are using channel groups, you will need to call setGroup() on the NotificationChannel, passing in the channel group ID.

All of these methods are invoked from onCreate() of MainActivity:

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    mgr=getSystemService(NotificationManager.class);

    if (mgr.getNotificationChannel(CHANNEL_CONTENT)==null) {
      initGroups();
      initContentChannel();
      initBattleChannel();
      initCoinsChannel();
    }

    setContentView(R.layout.activity_main);
  }
(from Notifications/Channels/app/src/main/java/com/commonsware/android/notify/channel/MainActivity.java)

In particular, we only call those methods if we do not already have the CHANNEL_CONTENT channel defined, as determined via a call to getNotificationChannel() on NotificationManager. This if check serves two purposes:

  1. We avoid running through a bunch of initialization code for things that we have already initialized
  2. We cannot change notification channels or channel groups once we create them, as we will examine more later in this chapter

What the User Sees

When the user visits the notification settings for this app, all three channels appear, clustered into the two groups:

Notification Settings, Showing Three Channels in Two Groups
Figure 375: Notification Settings, Showing Three Channels in Two Groups

The groups appear to be sorted alphabetically, not in the order that they are defined.

Tapping on the channel itself brings up a screen for configuring the details of that channel:

Notification Settings, Showing Channel Configuration
Figure 376: Notification Settings, Showing Channel Configuration

Of note, if the importance calls for a sound, and you did not supply a Uri to some ringtone via setSound() on the NotificationChannel, the user will see that the “Default notification sound” is “Unknown”, though this really means that the default notification sound will be used.

You can bring up the activity for configuring channel settings yourself, via an ACTION_CHANNEL_NOTIFICATION_SETTINGS Intent and a call to startActivity():

  @Override
  public boolean onOptionsItemSelected(MenuItem item) {
    if (item.getItemId()==R.id.settings) {
      Intent i=new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS);

      i.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_BATTLE);
      i.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
      startActivity(i);
    }

    return super.onOptionsItemSelected(item);
  }
(from Notifications/Channels/app/src/main/java/com/commonsware/android/notify/channel/MainActivity.java)

This requires two extras:

Once and Done

Once you create a notification channel, control over the channel settings resides with the user. You cannot modify the settings of that channel. Consider your settings to be defaults; the user is welcome to modify the channel from those defaults as the user sees fit.

To help prevent developers from deleting and re-creating channels, while you can call deleteNotificationChannel() on NotificationManager, that channel will still be visible to the user (“Deleted channels remain visible in notification settings, as a spam prevention mechanism.”)

Backwards Compatibility

There is no backport of notification channels.

NotificationCompat.Builder takes a channel ID, but you will need to use the native NotificationChannel class to define the channel. And since that class only exists on Android 8.0+, unless your minSdkVersion is 26 or higher, you will need to make sure that you use NotificationChannel only on suitable devices, using the recipe that we saw in the original chapter on notifications:


if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
  mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
  mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
    "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
}

Wear? There!

The humble Notification has been steadily advancing over the past few years, with “big” styles and the like adding new capabilities for newer devices.

Wear OS takes notifications to a new level, by having the notification not only appear on the user’s device, but also on wearables connected to that device.

The good news is that this works “out of the box”. There is nothing you absolutely need to do in your app to get your notifications to appear on a Wear device.

The bad news is that the “out of the box” experience may be poor, as a Notification approach that is fine for devices that reside in pockets and backpacks might be inappropriate for wrists and eyes.

With that in mind, let’s see what some notification samples from earlier in the book behave like when they are run on a phone connected to a Wear device.

NOTE: For this section, and the rest of this chapter, “primary device” will refer to the user’s phone or tablet that the “Wear device” will be connected to.

Simple Notification

The Notifications/DownloadNotify sample project allows the user to download a PDF file, raising a Notification when that download is complete.

With a Wear device paired with the phone, the Notification also appears on the device, first as a “mini card”:

Simple Notification on Wear, As Originally Displayed, On Samsung Galaxy Gear
Figure 377: Simple Notification on Wear, As Originally Displayed, On Samsung Galaxy Gear

Swiping up on that will bring up the full card:

Simple Notification on Wear, Full, On Samsung Galaxy Gear
Figure 378: Simple Notification on Wear, Full, On Samsung Galaxy Gear

Swiping to the right will bring up the action associated with setContentIntent() on the NotificationCompat.Builder:

Simple Notification on Wear, Default Action, On Samsung Galaxy Gear
Figure 379: Simple Notification on Wear, Default Action, On Samsung Galaxy Gear

Tapping on that dismisses the Notification on the Wear device and the primary device, plus it invokes the PendingIntent on the phone itself (in this case, opening up the PDF file).

This is a fine example of a Notification that perhaps should not appear on the Wear device. The fact that the download completed is interesting but not all that important. Furthermore, the user cannot do anything about this download other than to pull out the primary device to see the PDF. Low-priority primary-device-centric notifications generally should be shown on the primary device alone, not on the Wear device. We will see how to do that later in this chapter.

“Big” Style and Action Button

The Notifications/BigNotify sample application wrapped a regular Notification in a NotificationCompat.InboxStyle “big” Notification, one with both a regular action and a separate “Play” action button.

As before, with a Wear device paired with the phone, the Notification also appears on the device, first as a “mini card”:

Big Notification on Wear, As Originally Displayed, On Samsung Galaxy Gear
Figure 380: Big Notification on Wear, As Originally Displayed, On Samsung Galaxy Gear

However, this time, when the user swipes up to show the full card, it is the InboxStyle version that appears, albeit without the summary text:

Big Notification on Wear, Full, On Samsung Galaxy Gear
Figure 381: Big Notification on Wear, Full, On Samsung Galaxy Gear

Swiping to the right shows our actions, starting with the custom “Play” action:

Big Notification on Wear, Play Action, On Samsung Galaxy Gear
Figure 382: Big Notification on Wear, Play Action, On Samsung Galaxy Gear

…followed by the default action:

Big Notification on Wear, Default Action, On Samsung Galaxy Gear
Figure 383: Big Notification on Wear, Default Action, On Samsung Galaxy Gear

Tapping on either action will cause the primary device to invoke its PendingIntent, but only the default action dismisses the Notification from both devices. The custom “Play” action does not.

Foreground Service

The Notifications/Foreground sample project is another version of the download-the-file sample, but this time uses a Notification and startForeground() to mark the service as a foreground service while it is downloading things.

This particular sample does not spend much time in the foreground state, so for testing purposes, you may want to add a SystemClock.sleep() call to the service, between the startForeground() and stopForeground() calls, to better examine the behavior while the foreground service Notification is around.

However, in truth, that modification is probably not necessary… as the foreground service Notification is not displayed on the Wear device, only on the primary device. This is by design. The expectation is that you would use a Wear app to control your service from the Wear device, not some un-dismissable card.

Stacking Notifications

If you are writing an email client, and you want to use a Notification to let the user know about new email messages, you do not want to raise a separate Notification for each email. Users will come to your home with pitchforks and torches… and not to help you with farming.

Instead, the vision is that you update an existing Notification with new content. For example, you might start with a regular Notification for the first received email. Then, when the second one comes in, you replace that Notification with one that has a simple summary (“2 messages are in your inbox!”), plus perhaps an InboxStyle “big” Notification variant that could show the subject lines for both of those messages.

Wear OS devices, however, add an interesting wrinkle: you want the Notification to be informative about the event itself. You want the user to be able to make an informed decision about whether they should pull out their primary device to read the new messages, and that decision is only partly based on how many messages there are. Users will want to know more about the outstanding messages (sender and/or subject line) to help them make that decision… at least to a point. If there are 57 unread messages, users may get frustrated dealing with all of those as individual items on the wearable itself.

The pattern here, then, takes advantage of some “group” capabilities added to NotificationCompat:

This can be seen in action in the Notifications/Stacked sample project.

The setup is reminiscent of the “big” style one from the original chapter on Notification. However, this time, there are a total of three Notification objects created: two for individual events for the Wear device, and one summary one for the primary device.

However, to make this work, we need a new version of the support-v13 library from the Android Support package: 20.0.0 (or higher), as it is where the extra compatibility smarts were added to support this whole group-and-summary construct. Hence, in build.gradle, we have implementation 'com.android.support:support-v13:20.0.0'.

Similarly, while we will still use NotificationCompat for creating the Notification objects, we will not use NotificationManager for displaying them. Instead, we need to use NotificationManagerCompat from the Android Support package. While the NotificationManager API has not changed to support the group-and-summary pattern, the implementation has, and NotificationManagerCompat gives us a version of that implementation that can work on compatible devices and gracefully degrade on older ones. However, since the API did not change, it is easy to miss this requirement, use NotificationManager, and not quite get the desired results. Notably, the primary device will wind up showing all three notifications, not just the summary as we want.

Hence, our MainActivity will hold onto a NotificationManagerCompat as a data member, initialized in onCreate():

  private NotificationManagerCompat mgrCompat=null;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    NotificationManager mgr=
      (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
    }

    mgrCompat=NotificationManagerCompat.from(this);

    showWearOne();
    showWearTwo();
    showSummary();

    finish();
  }
(from Notifications/Stacked/app/src/main/java/com/commonsware/android/stacked/MainActivity.java)

The three show...() methods are each responsible for raising one Notification: showWearOne() and showWearTwo() are ones that will wind up on the Wear device, and showSummary() will show the summary Notification for use on the primary device.

Beyond using NotificationManagerCompat instead of NotificationManager, the only substantial difference is the use of setGroup() and setGroupSummary() methods on the NotificationCompat.Builder.

setGroup() associates the Notification with a group, identified by a String key. On a Wear device, notifications that are part of a group will be shown stacked as part of a single card by default. So, the two showWear...() methods call setGroup() as part of building the Notification:

  private void showWearOne() {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);

    b.setAutoCancel(true)
        .setDefaults(Notification.DEFAULT_ALL)
        .setContentTitle(getString(R.string.entry))
        .setContentIntent(buildPendingIntent(Settings.ACTION_SECURITY_SETTINGS))
        .setSmallIcon(android.R.drawable.stat_sys_download_done)
        .setGroup(GROUP_SAMPLE);

    mgrCompat.notify(NOTIFY_ID2, b.build());
  }

  private void showWearTwo() {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);

    b.setAutoCancel(true)
        .setDefaults(Notification.DEFAULT_ALL)
        .setContentTitle(getString(R.string.another_entry))
        .setContentIntent(buildPendingIntent(Settings.ACTION_SECURITY_SETTINGS))
        .setSmallIcon(android.R.drawable.stat_sys_download_done)
        .setGroup(GROUP_SAMPLE);

    mgrCompat.notify(NOTIFY_ID3, b.build());
  }
(from Notifications/Stacked/app/src/main/java/com/commonsware/android/stacked/MainActivity.java)

setGroupSummary() indicates a particular Notification that should serve as the summary for its group. This Notification will not be passed to the Wear device, and it replaces all other notifications for this group on the primary device. Hence, showSummary() (or, more accurately, the buildNormal() method that creates the base Notification for the summary) uses setGroupSummary():

  private void showSummary() {
    NotificationCompat.Builder normal=buildNormal();
    NotificationCompat.InboxStyle big=
        new NotificationCompat.InboxStyle();

    big.setSummaryText(getString(R.string.summary))
        .addLine(getString(R.string.entry))
        .addLine(getString(R.string.another_entry));

    mgrCompat.notify(NOTIFY_ID, normal.setStyle(big).build());
  }

  private NotificationCompat.Builder buildNormal() {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);

    b.setAutoCancel(true)
        .setDefaults(Notification.DEFAULT_ALL)
        .setContentTitle(getString(R.string.download_complete))
        .setContentText(getString(R.string.fun))
        .setContentIntent(buildPendingIntent(Settings.ACTION_SECURITY_SETTINGS))
        .setSmallIcon(android.R.drawable.stat_sys_download_done)
        .setGroup(GROUP_SAMPLE)
        .setGroupSummary(true);

    return(b);
  }
(from Notifications/Stacked/app/src/main/java/com/commonsware/android/stacked/MainActivity.java)

Note that you need to use setGroupSummary() on a NotificationCompat.Builder on which you have also called setGroup(), to identify the group for which this Notification is a summary.

When you run this, the primary device shows the summary Notification:

Stacked Notifications, Summary on Primary Device
Figure 384: Stacked Notifications, Summary on Primary Device

On the Wear device, you will see the two original notifications as part of a single card at the outset:

Stacked Notifications, Stacked on Wear Device
Figure 385: Stacked Notifications, Stacked on Wear Device

Tapping on the stack brings up separate mini cards for each individual Notification:

Stacked Notifications, Expanded Stack on Wear Device
Figure 386: Stacked Notifications, Expanded Stack on Wear Device

…And the Passage of Time

Of course, this sample is artificially simple, like most of the samples in this book.

In the sample, we are raising all three notifications all at once. That is certainly conceivable, but it is not especially likely. A more likely scenario is that the mix of notifications needs to change over time, based upon continuing events, such as a trickle of new unread email messages for an email client.

This adds a few complexities to what you need to implement all of this properly.

The big thing is that your persistent data model (e.g., database) needs to have enough information for you to know how to notify the user about the next event, when that event occurs. Using the email client as an example:

This gets even more complex when events “stack themselves” (e.g., one poll of the mail server results in two unread messages), in addition to having to deal with user input (e.g., user clears the notification stack from either device, yet does not read the messages).

Among other things, you cannot rely upon static data members as being the sole source of your Notification-related data, as your process may be terminated in between events. You are welcome to use it as a cache, in case your process does happen to survive long enough to process more than one event, but you will need to also save this data to a persistent store, so that you can properly handle new events requiring Notification changes with your process having been terminated since the last Notification-related event.

Avoiding Wear

Sometimes, you will want to raise a Notification that does not make sense to show on a Wear device, only on the primary device. In the case of the group summary for the stacked notifications, this primary-only behavior happens automatically. In other cases, though, you will need to call setLocalOnly() on the NotificationCompat.Builder to tell the framework that this Notification should only be displayed on the current device.

The Notifications/BigLocal sample project demonstrates this, through a clone of the Notifications/BigNotify sample that calls setLocalOnly(true) as part of configuring the Notification:

  private NotificationCompat.Builder buildNormal() {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);

    b.setAutoCancel(true)
     .setDefaults(Notification.DEFAULT_ALL)
     .setContentTitle(getString(R.string.download_complete))
     .setContentText(getString(R.string.fun))
     .setContentIntent(buildPendingIntent(Settings.ACTION_SECURITY_SETTINGS))
     .setSmallIcon(android.R.drawable.stat_sys_download_done)
     .setTicker(getString(R.string.download_complete))
     .setPriority(NotificationCompat.PRIORITY_DEFAULT)
     .setLocalOnly(true)
     .addAction(android.R.drawable.ic_media_play,
         getString(R.string.play),
         buildPendingIntent(Settings.ACTION_SETTINGS));

    return(b);
  }
(from Notifications/BigLocal/app/src/main/java/com/commonsware/android/biglocal/MainActivity.java)

Note that we do not need to use NotificationManagerCompat for local-only behavior — simply calling setLocalOnly(true) on an up-to-date NotificationCompat.Builder will suffice.

Running this sample provides the same behavior as Notifications/BigNotify, except that the Notification only appears on the primary device, not the Wear device.

Other Wear-Specific Notification Options

Configuring stacked notifications, and opting into local-only behavior when needed, should give you Wear behavior that is acceptable. Right now, Android Wear is fairly nascent, and therefore it may not behoove you to do much more than this, as you decide how to prioritize your engineering time.

However, there are other things that you can do to further tailor your notifications on Wear that can improve user satisfaction, if you wish for Wear to be a key part of your marketing message.

Pages

On the primary device, the amount of information you can provide in a Notification is intentionally capped. This prevents a Notification from drowning out its peers. The cap is not a big problem, simply because the whole UI for the app raising the Notification is usually just a tap away.

With a Wear device, though, the whole UI for the app raising the Notification involves pulling out the primary device.

Hence, it might be nice to provide some additional information to the Wear user, so that perhaps they can make a more informed decision as to whether it is worthwhile to open up their primary device. In Wear terms, this involves adding more “pages” to a Notification.

To do this, you must:

We can see this in action in the Notifications/Pages sample project. This is a clone of Notifications/BigNotify, where we make the “big” content be on a second page.

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    NotificationManager mgr=
      (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
    }

    NotificationManagerCompat mgrCompat=
        NotificationManagerCompat.from(this);
    NotificationCompat.Builder normal=buildNormal();
    NotificationCompat.InboxStyle big=
        new NotificationCompat.InboxStyle();

    big.setSummaryText(getString(R.string.summary))
        .addLine(getString(R.string.entry))
        .addLine(getString(R.string.another_entry))
        .addLine(getString(R.string.third_entry))
        .addLine(getString(R.string.yet_another_entry))
        .addLine(getString(R.string.low));

    NotificationCompat.Builder bigPage=
        new NotificationCompat.Builder(this, CHANNEL_WHATEVER)
            .setStyle(big);
    NotificationCompat.Builder twoPages=
        new NotificationCompat.WearableExtender()
            .addPage(bigPage.build())
            .extend(normal);

    mgrCompat.notify(NOTIFY_ID, twoPages.build());

    finish();
  }
(from Notifications/Pages/app/src/main/java/com/commonsware/android/pages/MainActivity.java)

Here, we:

On the primary device, we just see the primary Notification content:

Pages Demo, on a Galaxy Nexus
Figure 387: Pages Demo, on a Galaxy Nexus

On the Wear device, we see the main Notification and the second page as separate pages on the wearable:

Pages Demo, on a Samsung Galaxy Wear, Showing Initial Notification
Figure 388: Pages Demo, on a Samsung Galaxy Wear, Showing Initial Notification

Pages Demo, on a Samsung Galaxy Wear, Showing Second Page
Figure 389: Pages Demo, on a Samsung Galaxy Wear, Showing Second Page

Note that you cannot use addAction() to define a custom action on the extra pages added to the primary Notification. Instead, use addAction() and setContentAction() on the WearableExtender to define actions associated with those extra pages. We will see this in use in the next section.

Wear-Only Actions

Sometimes, you may want certain actions to only be available on the Wear device, and not on the primary device. We will see a specific example of this coming up in the next section, when we cover voice input actions.

Sometimes, you may want a different mix of actions on the primary device versus the Wear device — some in common, some only on the primary device, some only on the Wear device.

To set up Wear-only actions, use addAction() on WearableExtender, as opposed to (or in addition to) addAction() on NotificationCompat.Builder. This takes an action as a parameter, which you create using NotificationCompat.Action.Builder, a custom builder for building Notification actions.

This is illustrated in the Notifications/WearActions sample project, yet another variation on the “launch an activity, show a Notification” samples that we have been using. This time, though, we will apply an action to the Wear device:

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    NotificationManager mgr=
      (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
    }

    NotificationCompat.Builder normal=buildNormal();
    NotificationCompat.Action.Builder wearActionBuilder=
        new NotificationCompat.Action.Builder(android.R.drawable.ic_media_pause,
                                              getString(R.string.pause),
                                              buildPendingIntent(Settings.ACTION_DATE_SETTINGS));

    NotificationCompat.Builder extended=
        new NotificationCompat.WearableExtender()
            .addAction(wearActionBuilder.build())
            .extend(normal);

    NotificationManagerCompat.from(this).notify(NOTIFY_ID, extended.build());

    finish();
  }
(from Notifications/WearActions/app/src/main/java/com/commonsware/android/wearactions/MainActivity.java)

Here, we:

However, note that we have also defined an action on the primary Notification:

  private NotificationCompat.Builder buildNormal() {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);

    b.setAutoCancel(true)
     .setDefaults(Notification.DEFAULT_ALL)
     .setContentTitle(getString(R.string.download_complete))
     .setContentText(getString(R.string.fun))
     .setContentIntent(buildPendingIntent(Settings.ACTION_SECURITY_SETTINGS))
     .setSmallIcon(android.R.drawable.stat_sys_download_done)
     .addAction(android.R.drawable.ic_media_play,
        getString(R.string.play),
        buildPendingIntent(Settings.ACTION_SETTINGS));

    return(b);
  }
(from Notifications/WearActions/app/src/main/java/com/commonsware/android/wearactions/MainActivity.java)

addAction() on WearableExtender replaces, for the Wear device, any actions defined on the Notification itself using addAction(), but not the action defined via setContentIntent().

On the primary device, we do not see the wear-only action:

WearActions Demo, on a Galaxy Nexus
Figure 390: WearActions Demo, on a Galaxy Nexus

On a Wear device, though, we see both the wear-only and the main content action, but not the device-only action added via addAction() on the NotificationCompat.Builder:

WearActions Demo, on a Samsung Galaxy Wear, Showing Notification
Figure 391: WearActions Demo, on a Samsung Galaxy Wear, Showing Notification

WearActions Demo, on a Samsung Galaxy Wear, Showing Wear-Only Action
Figure 392: WearActions Demo, on a Samsung Galaxy Wear, Showing Wear-Only Action

WearActions Demo, on a Samsung Galaxy Wear, Showing Main Content Action
Figure 393: WearActions Demo, on a Samsung Galaxy Wear, Showing Main Content Action

Hence:

Voice Input

In the spirit of Dick Tracy’s two-way wrist radio, Wear OS allows you to talk to your wrist and not seem like you are completely insane.

In particular, your Notification, when presented on the Wear, can request that the user provide you with a response, via voice input or via canned responses. This can be very handy:

In many cases, with a regular Notification, the result of the user choosing an action is for us to display an activity. Sometimes, though, that’s not what we want, such as a music player’s Notification handling “pause” and similar events via its background service. Similarly, actions from a Notification seen on a Wear device will sometimes need to perform operations in the background, as the user may not be in position to look at your UI. This is especially true with voice input — usually, if we are bothering to dictate words to our wrist, that should happen instead of opening up the primary device. As a result, our flow for responding to the action is a little bit different, as is illustrated in the Notifications/VoiceInput sample project.

The Activity and Notification

Let’s walk through the MainActivity that sets up our Notification:

package com.commonsware.android.wearvoice;

import android.app.Activity;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.app.RemoteInput;

public class MainActivity extends Activity {
  private static final String CHANNEL_WHATEVER="channel_whatever";
  private static final int NOTIFY_ID=1337;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    NotificationManager mgr=
      (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
    }

    Intent i=new Intent(this, VoiceReceiver.class);
    PendingIntent pi=
        PendingIntent.getBroadcast(this, 0, i,
            PendingIntent.FLAG_UPDATE_CURRENT);

    RemoteInput remoteInput=
        new RemoteInput.Builder(VoiceReceiver.EXTRA_SPEECH)
          .setLabel(getString(R.string.talk))
          .setChoices(getResources().getStringArray(R.array.replies))
          .build();

    NotificationCompat.Action wearAction=
        new NotificationCompat.Action.Builder(
              android.R.drawable.ic_btn_speak_now,
              getString(R.string.talk),
              pi).addRemoteInput(remoteInput).build();

    NotificationCompat.WearableExtender wearExtender=
        new NotificationCompat.WearableExtender()
            .addAction(wearAction);

    NotificationCompat.Builder builder=
        new NotificationCompat.Builder(this, CHANNEL_WHATEVER)
            .setSmallIcon(android.R.drawable.stat_sys_download_done)
            .setContentTitle(getString(R.string.title))
            .setContentText(getString(R.string.talk))
            .extend(wearExtender);

    NotificationManagerCompat
      .from(this)
      .notify(NOTIFY_ID, builder.build());

    finish();
  }
}
(from Notifications/VoiceInput/app/src/main/java/com/commonsware/android/wearvoice/MainActivity.java)

We start by creating a broadcast PendingIntent, pointing to a VoiceReceiver that will respond to the voice input. We will examine this VoiceReceiver later in this example.

We then set up a RemoteInput.Builder. This is a builder-style API for defining a RemoteInput configuration to attach to a Wear-only action. Here, we configure it with:

That RemoteInput is then applied to a NotificationCompat.Action, via its NotificationCompat.Action.Builder and the addRemoteInput() method. That Action, in turn, is wrapped in a NotificationCompat.WearableExtender, which is used to extend() a NotificationCompat.Builder.

Finally, the resulting Notification is raised using a NotificationManagerCompat instance.

The Receiver

Our VoiceReceiver, registered in the manifest, is set up to respond to the voice action:

package com.commonsware.android.wearvoice;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.v4.app.RemoteInput;
import android.util.Log;
import android.widget.TextView;

public class VoiceReceiver extends BroadcastReceiver {
  static final String EXTRA_SPEECH="speech";

  @Override
  public void onReceive(Context ctxt, Intent i) {
    Bundle input=RemoteInput.getResultsFromIntent(i);

    if (input!=null) {
      CharSequence speech=input.getCharSequence(EXTRA_SPEECH);

      if (speech!=null) {
        Log.d(getClass().getSimpleName(), speech.toString());
      }
      else {
        Log.e(getClass().getSimpleName(), "No voice response speech");
      }
    }
    else {
      Log.e(getClass().getSimpleName(), "No voice response Bundle");
    }
  }
}
(from Notifications/VoiceInput/app/src/main/java/com/commonsware/android/wearvoice/VoiceReceiver.java)

It uses RemoteInput.getResultsFromIntent(i) to pick out the response we got from the user for this action. There are three major possibilities:

  1. We did not get any response (should not happen)
  2. We got a response, but for whatever reason, the decoded Bundle is missing our VoiceReceiver.EXTRA_SPEECH key (also should not happen)
  3. The CharSequence from the VoiceReceiver.EXTRA_SPEECH key in the decoded Bundle is the user’s response, whether from speech recognition or from choosing one of our canned responses

In this case, we just log the message to Logcat, but in principle you could do whatever you wanted. Just bear in mind that your UI may not be in the foreground, and that the device screen may be off entirely. It is also possible that your process will have been terminated between the time you raised the Notification and the user got around to responding to it from the Wear device. Hence, you should be making few assumptions about the environment at the point when you get the voice response.

The Results

The Wear device starts off with a typical action:

VoiceInput Demo, on a Samsung Galaxy Wear, Showing Voice Action
Figure 394: VoiceInput Demo, on a Samsung Galaxy Wear, Showing Voice Action

Tapping it brings up a voice input screen, where the user can dictate some text:

WearActions Demo, on a Samsung Galaxy Wear, Showing Voice Input
Figure 395: WearActions Demo, on a Samsung Galaxy Wear, Showing Voice Input

If the user delays too long without saying anything recognizable, or if the user swipes up the screen, they are taken to our list of canned responses:

WearActions Demo, on a Samsung Galaxy Wear, Showing Canned Responses
Figure 396: WearActions Demo, on a Samsung Galaxy Wear, Showing Canned Responses

If the user instead does dictate some text, initially they are shown just the interpreted text:

WearActions Demo, on a Samsung Galaxy Wear, Showing Voice Input Results
Figure 397: WearActions Demo, on a Samsung Galaxy Wear, Showing Voice Input Results

Then a cancel button with a progress indicator around the edge appears:

WearActions Demo, on a Samsung Galaxy Wear, Showing Voice Input Progress
Figure 398: WearActions Demo, on a Samsung Galaxy Wear, Showing Voice Input Progress

If the user taps the cancel button before the progress indicator elapses, they are prompted to confirm or reject the input:

WearActions Demo, on a Samsung Galaxy Wear, Showing Voice Input Confirmation
Figure 399: WearActions Demo, on a Samsung Galaxy Wear, Showing Voice Input Confirmation

Remote Input, On-Device

As is noted above, Wear OS uses RemoteInput to get input from the user. However, historically, that capability was limited to notifications appearing on Wear.

Starting with Android 7.0, RemoteInput is also available for standard device notifications. Rather than using voice input, you get a small EditText into which the user can type something and submit it. You get what the user typed in, and can use that as needed.

The Notifications/RemoteInput sample project is a near-clone of the Notifications/VoiceInput sample project profiled in the advanced Notifications chapter. Instead of putting the RemoteInput in an action on the WearExtender, it puts the RemoteInput on the main Notification itself:

package com.commonsware.android.remoteinput;

import android.app.Activity;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.app.RemoteInput;

public class MainActivity extends Activity {
  static final String CHANNEL_WHATEVER="channel_whatever";

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    NotificationManager mgr=
      (NotificationManager)getSystemService(NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
    }

    Intent i=new Intent(this, RemoteInputReceiver.class);
    PendingIntent pi=
        PendingIntent.getBroadcast(this, 0, i,
          PendingIntent.FLAG_UPDATE_CURRENT);

    RemoteInput remoteInput=
        new RemoteInput.Builder(RemoteInputReceiver.EXTRA_INPUT)
          .setLabel(getString(R.string.talk))
          .build();

    NotificationCompat.Action remoteAction=
        new NotificationCompat.Action.Builder(
              android.R.drawable.ic_btn_speak_now,
              getString(R.string.talk),
              pi).addRemoteInput(remoteInput).build();

    NotificationCompat.Builder builder=
      RemoteInputReceiver.buildNotificationBase(this)
        .addAction(remoteAction);

    NotificationManagerCompat
      .from(this)
      .notify(RemoteInputReceiver.NOTIFY_ID, builder.build());

    finish();
  }
}
(from Notifications/RemoteInput/app/src/main/java/com/commonsware/android/remoteinput/MainActivity.java)

The activity, when launched, will raise the Notification with a “Talk to Me” action:

Notification with Action
Figure 400: Notification with Action

Tapping on the action converts it into an EditText, with your action prompt as the hint, plus an arrow-shaped “send” button:

Notification with Remote Input
Figure 401: Notification with Remote Input

Typing something in and tapping that button converts the button into a progress spinner:

Notification with Remote Input and Progress Spinner
Figure 402: Notification with Remote Input and Progress Spinner

Also, the PendingIntent that you associated with the action is invoked. In this case, that triggers a broadcast to RemoteInputReceiver:

package com.commonsware.android.remoteinput;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.app.RemoteInput;
import android.util.Log;

public class RemoteInputReceiver extends BroadcastReceiver {
  static final int NOTIFY_ID=1337;
  static final String EXTRA_INPUT="input";

  static NotificationCompat.Builder buildNotificationBase(Context ctxt) {
    NotificationCompat.Builder builder=
      new NotificationCompat.Builder(ctxt, MainActivity.CHANNEL_WHATEVER)
        .setSmallIcon(
          android.R.drawable.stat_sys_download_done)
        .setContentTitle(ctxt.getString(R.string.title));

    return(builder);
  }

  @Override
  public void onReceive(Context ctxt, Intent i) {
    Bundle input=RemoteInput.getResultsFromIntent(i);

    if (input!=null) {
      CharSequence speech=input.getCharSequence(EXTRA_INPUT);

      if (speech!=null) {
        Log.d(getClass().getSimpleName(), speech.toString());
      }
      else {
        Log.e(getClass().getSimpleName(), "No voice response speech");
      }
    }
    else {
      Log.e(getClass().getSimpleName(), "No voice response Bundle");
    }

    NotificationCompat.Builder builder=
      buildNotificationBase(ctxt);

    NotificationManagerCompat
      .from(ctxt)
      .notify(RemoteInputReceiver.NOTIFY_ID, builder.build());
  }
}
(from Notifications/RemoteInput/app/src/main/java/com/commonsware/android/remoteinput/RemoteInputReceiver.java)

Here, we get what the user typed in via our designated extra (EXTRA_INPUT, as requested via the RemoteInput.Builder), which we can use as we see fit, such as logging it to Logcat.

However, we also have to update or cancel the Notification. Otherwise, that progress spinner will spin indefinitely. If the Notification still has value to the user after the RemoteInput, just update it, with or without another RemoteInput (depending on whether one would now be needed). You might also show the user’s input in the updated Notification. Or, if the Notification is no longer needed, just cancel() it. In this case, we raise a fresh Notification for this ID, just without the RemoteInput that the activity added.

Note that setChoices() on RemoteInput is ignored for regular device notifications. You can use this for Wear notifications to give the user a list of strings to choose from, as an alternative to voice recognition.

You can call setRemoteInputHistory() on your Builder as well. This takes a CharSequence array, though many developers will elect to use a simple String array. This represents the inputs supplied by the user and accepted by your app, in reverse chronological order (first element in the array is the most recent input). Some of this history may be added automatically to the Notification when you raise the updated Notification containing this input history.

Notification Groups

Another concept introduced with Wear in mind was the notification group. With this, you create a summary Notification, along with detail Notifications for individual events. The quintessential example is an email app, with a summary Notification indicating the unread message count, and with detail Notifications for individual messages.

The idea for Wear was to allow the user to individually respond to the detail Notifications without having to pull out the associated phone or tablet. That phone or tablet would show the summary Notification, since the user could just tap on it and bring up the activity to see the detail.

For some reason, Google back-pedaled on that last part, as with Android 7.0, phones and tablets will also show the summary-and-detail Notification hierarchy.

The Notifications/Stacked sample project, presented in the chapter on advanced Notifications, demonstrates this without any modifications. Initially, the user just sees the summary:

Stacked Notification, Showing Summary
Figure 403: Stacked Notification, Showing Summary

A two-finger swipe gesture will expose the full hierarchy:

Stacked Notification, Showing Hierarchy
Figure 404: Stacked Notification, Showing Hierarchy

Lockscreen Notifications

Historically, notification icons would be visible on the user’s lockscreen, but that was it. This would give the user an indication of what apps need attention, but no additional context.

Android 5.0 added notifications to the lockscreen, to help provide that missing context. Now users can have more details about the notifications, to determine whether it is necessary to unlock the device right now to deal with them.

Also note that on Android 7.0+, RemoteInput works on the lockscreen. You can see this in action with the sample app — just leave the Notification up, then lock the device. When you power on the screen again, you will get the lockscreen, and the Notification tile will appear. It will not visibly show any sign of remote input, but if you swipe down on the Notification, the remote input field will appear, and you can type in a message.

However, this also raises privacy concerns, as now notification text can be seen by anyone with access to the phone. As such, Android 5.0 introduced the concept of visibility to notifications, so developers can help control what is shown on the lockscreen versus what is shown only past the lockscreen.

However, these visibility options are only useful if:

Choosing Notification Control, When Securing the Lockscreen
Figure 405: Choosing Notification Control, When Securing the Lockscreen

or in the “Sound & notification” portion of the Settings app:

Sound & notification Settings
Figure 406: “Sound & notification” Settings

Notification Control Options in Sound & notification in Settings
Figure 407: Notification Control Options in “Sound & notification” in Settings

Given that the user has enabled “hide sensitive notification content” mode, you as a developer can choose a visibility to apply to your notifications. There are three such visibility options — private, public, and secret — covered in the following sections.

Private Notifications

The default behavior is a “private” Notification. Basic information appears on the lockscreen, but not the whole Notification. However, you as a developer can also provide a separate Notification that will be shown on the lockscreen, so you can choose what information appears publicly and what information does not.

The sample app for this section has a “public” edition of the Notification that shows up on the lockscreen:

Public Edition of Private Lockscreen Notification, on a Nexus 7
Figure 408: Public Edition of Private Lockscreen Notification, on a Nexus 7

Public Notifications

Instead of creating a separate Notification for public visibility on the lockscreen, you could flag your main Notification as having public visibility. This is suitable for notifications where there is little to no privacy implications for having the information appear on the lockscreen.

Secret Notifications

A Notification with visibility set to “secret” will not show up on the lockscreen at all. The ringtone, etc. will occur, as requested (and based on device settings, like it being muted), but otherwise there is no visible indication on the lockscreen that your Notification exists. Only when the user gets past the lockscreen will your Notification appear, in the status bar.

A Visibility Sample

The Notifications/Lollipop sample project demonstrates the use of these visibility values. It also demonstrates heads-up notifications, covered later in this chapter.

The user interface consists of a Spinner of possible Notification variants, a SeekBar to allow the user to specify a delay period in seconds before showing the Notification, and a Button to trigger showing the Notification:

<?xml version="1.0" encoding="utf-8"?>

<TableLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="8dp"
    android:stretchColumns="1">

    <TableRow>
        <TextView
            android:text="@string/type_label"/>

        <Spinner
            android:id="@+id/type"/>
    </TableRow>

    <TableRow>
        <TextView
            android:text="@string/delay_label"/>

        <SeekBar
            android:id="@+id/delay"
            android:progress="5"
            android:max="30"/>
    </TableRow>

    <Button
        android:text="@string/notify_button"
        android:id="@+id/download"
        android:onClick="notifyMe"/>
</TableLayout>
(from Notifications/Lollipop/app/src/main/res/layout/main.xml)

Lollipop Notifications Demo, on a Nexus 7
Figure 409: Lollipop Notifications Demo, on a Nexus 7

The onCreate() method of our launcher activity (MainActivity) initializes the UI:

package com.commonsware.android.lollipopnotify;

import android.app.Activity;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.SeekBar;
import android.widget.Spinner;

public class MainActivity extends Activity {
  private Spinner type=null;
  private SeekBar delay=null;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    type=findViewById(R.id.type);

    ArrayAdapter<String> types=
        new ArrayAdapter<String>(this,
            android.R.layout.simple_spinner_item,
            getResources().getStringArray(R.array.types));

    types.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
    type.setAdapter(types);

    delay=findViewById(R.id.delay);
  }

  public void notifyMe(View v) {
    Intent i=new Intent(this, AlarmReceiver.class)
        .putExtra(AlarmReceiver.EXTRA_TYPE, type.getSelectedItemPosition());
    PendingIntent pi=PendingIntent.getBroadcast(this, 0, i,
                                                PendingIntent.FLAG_UPDATE_CURRENT);
    AlarmManager mgr=(AlarmManager)getSystemService(ALARM_SERVICE);

    mgr.set(AlarmManager.ELAPSED_REALTIME_WAKEUP,
        SystemClock.elapsedRealtime()+(1000*delay.getProgress()),
        pi);
  }
}
(from Notifications/Lollipop/app/src/main/java/com/commonsware/android/lollipopnotify/MainActivity.java)

In particular, onCreate() populates the Spinner based on a <string-array> resource:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="types">
        <item>Private</item>
        <item>Public</item>
        <item>Secret</item>
        <item>Heads-Up</item>
    </string-array>
</resources>
(from Notifications/Lollipop/app/src/main/res/values/arrays.xml)

When the button is clicked, the notifyMe() method on MainActivity is called. Here, we:

Since the targetSdkVersion of this project is below 19, the set() method will behave in an exact fashion, triggering our AlarmReceiver at the designated time.

AlarmReceiver, in turn, uses a switch statement to call out to different private methods based upon which Spinner item was selected:

package com.commonsware.android.lollipopnotify;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.provider.Settings;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;

public class AlarmReceiver extends BroadcastReceiver {
  private static final String CHANNEL_WHATEVER="channel_whatever";
  private static final String CHANNEL_HEADS_UP="channel_heads_up";
  private static final int NOTIFY_ID=1337;
  static final String EXTRA_TYPE="type";

  @Override
  public void onReceive(Context ctxt, Intent i) {
    NotificationManager mgr=
      (NotificationManager)ctxt.getSystemService(Context.NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_HEADS_UP,
        "Heads Up!", NotificationManager.IMPORTANCE_HIGH));
    }

    NotificationManagerCompat mgrCompat=NotificationManagerCompat.from(ctxt);

    switch (i.getIntExtra(EXTRA_TYPE, -1)) {
      case 0:
        notifyPrivate(ctxt, mgrCompat);
        break;

      case 1:
        notifyPublic(ctxt, mgrCompat);
        break;

      case 2:
        notifySecret(ctxt, mgrCompat);
        break;

      case 3:
        notifyHeadsUp(ctxt, mgrCompat);
        break;
    }
  }

  private void notifyPrivate(Context ctxt, NotificationManagerCompat mgr) {
    Notification pub=
      buildBase(ctxt, CHANNEL_WHATEVER, R.string.public_title).build();

    mgr.notify(NOTIFY_ID,
      buildBase(ctxt, CHANNEL_WHATEVER, R.string.private_title).setPublicVersion(pub).build());
  }

  private void notifyPublic(Context ctxt, NotificationManagerCompat mgr) {
    mgr.notify(NOTIFY_ID,
        buildBase(ctxt, CHANNEL_WHATEVER, R.string.public_title)
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .build());
  }

  private void notifySecret(Context ctxt, NotificationManagerCompat mgr) {
    mgr.notify(NOTIFY_ID,
        buildBase(ctxt, CHANNEL_WHATEVER, R.string.secret_title)
            .setVisibility(NotificationCompat.VISIBILITY_SECRET)
            .build());
  }

  private void notifyHeadsUp(Context ctxt, NotificationManagerCompat mgr) {
    mgr.notify(NOTIFY_ID,
        buildBase(ctxt, CHANNEL_HEADS_UP, R.string.headsup_title)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .build());
  }

  private NotificationCompat.Builder buildBase(Context ctxt, String channel,
                                               int titleId) {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(ctxt, channel);

    b.setAutoCancel(true)
        .setDefaults(Notification.DEFAULT_ALL)
        .setContentTitle(ctxt.getString(titleId))
        .setContentIntent(buildPendingIntent(ctxt, Settings.ACTION_SECURITY_SETTINGS))
        .setSmallIcon(android.R.drawable.stat_sys_download_done)
        .addAction(android.R.drawable.ic_media_play,
            ctxt.getString(R.string.play),
            buildPendingIntent(ctxt, Settings.ACTION_SETTINGS));

    return(b);
  }

  private PendingIntent buildPendingIntent(Context ctxt, String action) {
    Intent i=new Intent(action);

    return(PendingIntent.getActivity(ctxt, 0, i, 0));
  }
}
(from Notifications/Lollipop/app/src/main/java/com/commonsware/android/lollipopnotify/AlarmReceiver.java)

If the user chooses the “Private” option in the Spinner, we call notifyPrivate(). That method builds two Notification objects: the regular one and a separate public edition. We attach the public edition to the regular Notification via a call to setPublicVersion() on the NotificationCompat.Builder. Then, we raise the regular Notification. This will show the public edition if the lockscreen is locked; otherwise, it will show the regular edition.

If the user chooses the “Public” option, we call notifyPublic(). That, in turn, calls setVisibility(NotificationCompat.VISIBILITY_PUBLIC) on the NotificationCompat.Builder, causing our Notification to appear normally both on the lockscreen and past the lockscreen.

If the user chooses the “Secret” option, we call notifySecret(). That uses setVisibility(NotificationCompat.VISIBILITY_SECRET) to configure the Notification to only appear once the user has gotten past the lockscreen.

The “Heads-Up” option — fourth in the Spinner — is covered in the next section.

Priority, Importance, and Heads-Up Notifications

Notifications can have a priority associated with them. Normally, notifications with higher priority will appear higher in the list of notifications in the notification tray than will notifications with lower priority.

Android 5.0 took this a step further, showing high-priority notifications in a “heads-up” style, popping up a small dialog-like window over the main screen, with the same basic content as would appear for the Notification in its tile in the notification tray:

Lollipop Demo, on a Nexus 7, Showing Heads-Up Notification
Figure 410: Lollipop Demo, on a Nexus 7, Showing Heads-Up Notification

Users can interact with the heads-up Notification or ignore it; in the latter case, the Notification will move into the status bar and the “heads-up” display will disappear from the screen.

Note that the “priority” concept being described here seems to be independent of the notion of “priority notifications” in the user’s interruption configuration in Settings. There, “priority notifications” is tied to the app, not tied to any sort of configuration of the Notification itself.

Android 8.0’s notification channels introduced a separate concept, called importance, set on the channel. Roughly speaking, importance on a channel is the same as priority on a notification. Priorities are marked as deprecated on API Level 26+, though they still work.

Specifying the Priority

NotificationCompat.Builder has a setPriority() method that allows you to specify your requested priority. There are five priority values accepted as a parameter, all defined as constants out on the NotificationCompat class:

The actual priority applied to the Notification will depend upon other factors, and so you should not assume that your requested value will be accepted and applied as-is.

Results on Android 5.x Devices

The heads-up Notification appears as shown in the above screenshot. The pop-up itself is centered across the top of the screen, as shown below:

Lollipop Demo, Showing Heads-Up Notification
Figure 411: Lollipop Demo, Showing Heads-Up Notification

After a few seconds of inactivity, the pop-up vanishes, and the Notification goes into the status bar.

Results on Older Devices

The concept of priority was introduced in API Level 16 (Android 4.1). On Android 4.1 through 4.4, the only effect of priority was to help influence the sort order of notifications in the notification tray, with higher-priority items drifting towards the top.

While NotificationCompat.Builder will allow you to specify a priority even on devices running older versions of Android than 4.1, the requested priority will be ignored, simply because priority did not exist back then. Hence, while your code will still work, it will have no effect on such old devices.

Full-Screen Notifications

Before Android 5.0 added heads-up notifications, while priority would influence things like sort order, it would have no real impact on how the user would be informed about whatever event triggered the Notification. The user would still just get an icon in the status bar, and perhaps a ringtone and other hardware output.

However, sometimes we need to be somewhat more “in the user’s face”, such as for a calendar event reminder, or for an incoming phone call from our VOIP app.

It is tempting to launch an activity in these cases. In fact, that is what the user tends to perceive as happening, on Android 4.4 and older devices. And some apps no doubt actually do launch an activity.

A “middle ground” between showing a Notification and launching an activity is to use a full-screen Notification. Here, we provide a PendingIntent that should be invoked if the user is actively using the device at the time of the Notification. Typically, that PendingIntent will display an activity. However, on Android 5.0+, the behavior has changed, where a full-screen Notification actually just triggers a heads-up notification, as would a high-priority Notification.

Requesting Full-Screen Output

Prior to Android 8.0, all that you need to do to set up a Notification to be full-screen is to call setFullScreenIntent() on your NotificationCompat.Builder, supplying two values:

  1. A PendingIntent to be invoked when the notification is added to the screen
  2. A boolean, where true indicates that even if the user has blocked notifications, you want this one to appear

For example, in the Notifications/FullScreen sample project, MainActivity shows a Notification constructed via the buildNormal() method:

  private NotificationCompat.Builder buildNormal() {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);

    b.setAutoCancel(true)
     .setDefaults(Notification.DEFAULT_ALL)
     .setContentTitle(getString(R.string.download_complete))
     .setContentText(getString(R.string.fun))
     .setContentIntent(buildPendingIntent(Settings.ACTION_SECURITY_SETTINGS))
     .setSmallIcon(android.R.drawable.stat_sys_download_done)
      .setNumber(5)
     .setFullScreenIntent(buildPendingIntent(Settings.ACTION_DATE_SETTINGS), true)
     .addAction(android.R.drawable.ic_media_play,
         getString(R.string.play),
         buildPendingIntent(Settings.ACTION_SETTINGS));

    return(b);
(from Notifications/FullScreen/app/src/main/java/com/commonsware/android/fullscreen/MainActivity.java)

Here, the PendingIntent is created using the same buildPendingIntent() method as before, this time opening up a distinct screen from the Settings app.

Results on Android 5.0-7.1 Devices

On Android 5.0 through 7.1, the “full screen” Notification appears as a heads-up Notification:

FullScreen Demo, on a Nexus 7, Showing Full Screen Notification
Figure 412: FullScreen Demo, on a Nexus 7, Showing “Full Screen” Notification

Note that there is no obvious way to actually invoke the PendingIntent associated with the setFullScreenIntent() method. Hence, you need to make sure that the Notification has some other means of getting the user to the right place in your UI, such as via setContentIntent() or an action.

Results on Android 3.0-4.4 Devices

On API Levels 11 through 19 (Android 3.0 through 4.4), the effect of a full-screen PendingIntent is to invoke the PendingIntent when the Notification is added to the screen. This will happen regardless of whether the user is using the device or not, though if the device is asleep, the activity triggered by the PendingIntent will only be visible once the user gets past their lockscreen.

Note that the Notification is also shown, along with whatever the PendingIntent does. That Notification is not automatically cleared when the user exits out of that activity via BACK, HOME, etc. Hence, it is up to you to clear that Notification if and when it is no longer relevant. The primary value of the Notification is to have the icon appear in the status bar on the lockscreen — even though the user cannot interact with your Notification then, the user may recognize your icon and therefore elect to unlock their device to see what all the fuss is about.

Results on Older Devices

Full-screen notifications were not supported prior to Android 3.0. While NotificationCompat.Builder will allow you to call setFullScreenIntent(), the value will be ignored prior to API Level 11.

In theory, there is nothing stopping NotificationCompat from launching an activity itself, in addition to displaying the Notification. However, at least at this time, it is not doing so, and it is fairly likely that Google will not add this in at this point.

Hence, the only way to do a “full-screen notification” is for your app to launch the desired activity, in addition to (or instead of) showing the Notification.

Hey, What About Android 8.0+?

On these devices, setFullScreenIntent() has no effect.

Progress Notifications

Often, you will see a Notification with a ProgressBar in it, showing progress of some long-running background work, such as a large download. There are two approaches towards building this sort of thing:

  1. Create a custom Notification, as we will cover later in this chapter
  2. Use setProgress() on the NotificationCompat.Builder, periodically updating the Notification to reflect the now-current amount of progress

Needless to say, the second option is simpler.

The HTTP/OkHttpProgress sample project demonstrates how this works in a fairly realistic situation: tracking progress of a long download. In this case, we will use OkHttp3, showing how you can integrate its somewhat convoluted “interceptor” API to find out about download progress, then use that information to update a Notification.

The UI

This sample app is a variation on other download samples shown elsewhere in the book. We have a fragment with a large “Do the Download” button. When the button is clicked, we want to start a Downloader service to do the actual downloading on a background thread.

The two primary differences in this fragment’s onClick() method are:

  @Override
  public void onClick(View v) {
    Intent i=new Intent(getActivity(), Downloader.class);

    i.setDataAndType(Uri.parse("http://commonsware.com/Android/Android-1_1-CC.pdf"),
                     "application/pdf");

    getActivity().startService(i);
    getActivity().finish();
  }
(from HTTP/OkHttpProgress/app/src/main/java/com/commonsware/android/okhttp3/progress/DownloadFragment.java)

Also, we are not bothering to offer the user the ability to view the PDF immediately after downloading, to simplify the example a bit.

The Downloader Service

The significant changes come in the Downloader service. Previous editions of this sample use HttpURLConnection, but here we switch to OkHttp3, which offers a cleaner way to find out our download progress. Plus, our foreground service Notification will employ the ProgressBar to show how far along we are in downloading the file.

Everything but the Icky Parts

A large chunk of our Downloader IntentService does the same stuff as you see in the Notifications/Foreground sample project.

Of particular note here, we call startForeground(), to elevate our process priority while the download is happening and show a Notification along the way:

      String filename=i.getData().getLastPathSegment();
      final NotificationCompat.Builder builder=
        buildForeground(filename);

      startForeground(FOREGROUND_ID, builder.build());
(from HTTP/OkHttpProgress/app/src/main/java/com/commonsware/android/okhttp3/progress/Downloader.java)

The Notification itself comes from buildForeground(), which takes the name of the file as a parameter and builds a Notification with that information:

  private NotificationCompat.Builder buildForeground(
    String filename) {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);

    b.setContentTitle(getString(R.string.downloading))
     .setContentText(filename)
     .setSmallIcon(android.R.drawable.stat_sys_download)
     .setOnlyAlertOnce(true)
     .setOngoing(true);

    return(b);
  }
(from HTTP/OkHttpProgress/app/src/main/java/com/commonsware/android/okhttp3/progress/Downloader.java)

Note that we use setOnlyAlertOnce(true) here. This tells Android to only do the “alert” portion of the Notification once. Here, “alert” means things like ringtones and vibration patterns. We will see why this flag is important shortly.

Eventually, we start using OkHttpClient to download the file:

      OkHttpClient client=new OkHttpClient.Builder()
        .addNetworkInterceptor(nightTrain)
        .build();
      Request request=
        new Request.Builder().url(i.getData().toString()).build();
      Response response=client.newCall(request).execute();
      BufferedSink sink=Okio.buffer(Okio.sink(new File(output.getPath())));

      sink.writeAll(response.body().source());
      sink.close();

      stopForeground(true);
      raiseNotification(null);
(from HTTP/OkHttpProgress/app/src/main/java/com/commonsware/android/okhttp3/progress/Downloader.java)

We start off by building an instance of OkHttpClient using an OkHttpClient.Builder. We will take a closer look at the addNetworkInterceptor() call shortly, as that is where we are hooking in our code to find out about the progress of the HTTP request.

We then:

Other than using OkHttp3, little of that is different from the original foreground service sample. Where things start to get interesting is in that addNetworkInterceptor() call.

The Interceptor

Interceptors are a way for you to hook into the flow of OkHttp3 processing, such that your code gets invoked for any request made of this OkHttpClient. In particular, a network interceptor allows you to get control during the actual network I/O of processing the request.

The OkHttp Git repository contains some sample code that uses a network interceptor to track download progress, and that code forms the foundation of what is shown in this sample.

The addNetworkInterceptor() call in Downloader is using a local Interceptor object, named nightTrain:

      Interceptor nightTrain=new Interceptor() {
        @Override
        public Response intercept(Chain chain)
          throws IOException {
          Response original=chain.proceed(chain.request());
          Response.Builder b=original
            .newBuilder()
            .body(
              new ProgressResponseBody(original.body(),
                  progressListener));

          return(b.build());
        }
      };
(from HTTP/OkHttpProgress/app/src/main/java/com/commonsware/android/okhttp3/progress/Downloader.java)

An Interceptor will be called with a Chain, representing the HTTP request and response. The job of the Interceptor is three-fold:

  1. Call chain.proceed() at some point, to kick off the actual HTTP processing
  2. Return a Response object that will be used as the “real” response of this request
  3. Do whatever work the Interceptor was designed to do, such as request logging

The chain.proceed() call returns the Response that would be what OkHttp3 would use in the absence of this Interceptor. The Interceptor can either return that Response or some other Response. If you want to monitor the actual network I/O — such as we want to do here, to see how many bytes we have downloaded — the recipe is to use the wrapper pattern and wrap something from the original Response in a wrapper that has your business logic.

That is what this sample does. original.newBuilder() gives us a Response.Builder that is based on the original Response. The body() of a Response is a ResponseBody that manages an Okio Source object, which handles the actual streaming. We wrap the original ResponseBody in a ProgressResponseBody that will track our download progress, put that ProgressResponseBody into the Response.Builder, then return the Response that is built by that Builder. The net effect is that all calls to the ResponseBody will go to our ProgressResponseBody.

The ProgressResponseBody

What we really want to wrap is the Source, an Okio object that is responsible for the real streaming. However, to get there, we have to wrap that original ResponseBody in a ProgressResponseBody.

ProgressResponseBody itself extends from a ResponseBodyWrapper, which is a ResponseBody that forwards everything onto a wrapped ResponseBody… except for a hook to allow us to wrap the Source:

package com.commonsware.android.okhttp3.progress;

import okhttp3.MediaType;
import okhttp3.ResponseBody;
import okio.BufferedSource;
import okio.Okio;
import okio.Source;

// inspired by https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java

abstract class ResponseBodyWrapper extends ResponseBody {
  abstract Source wrapSource(Source original);

  private final ResponseBody wrapped;
  private BufferedSource buffer;

  ResponseBodyWrapper(ResponseBody wrapped) {
    this.wrapped=wrapped;
  }

  @Override
  public MediaType contentType() {
    return(wrapped.contentType());
  }

  @Override
  public long contentLength() {
    return(wrapped.contentLength());
  }

  @Override
  public BufferedSource source() {
    if (buffer==null) {
      buffer=Okio.buffer(wrapSource(wrapped.source()));
    }

    return(buffer);
  }
}
(from HTTP/OkHttpProgress/app/src/main/java/com/commonsware/android/okhttp3/progress/ResponseBodyWrapper.java)

Subclasses of ResponseBodyWrapper need to implement wrapSource() to wrap the Source of the Response.

ProgressResponseBody does just that, wrapping the Source in a subclass of ForwardingSource named ProgressSource:

package com.commonsware.android.okhttp3.progress;

import java.io.IOException;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.ForwardingSource;
import okio.Source;

// inspired by https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java

class ProgressResponseBody extends ResponseBodyWrapper {
  private final Listener listener;

  ProgressResponseBody(ResponseBody wrapped, Listener listener) {
    super(wrapped);

    this.listener=listener;
  }

  @Override
  Source wrapSource(Source original) {
    return(new ProgressSource(original, listener));
  }

  class ProgressSource extends ForwardingSource {
    private final Listener listener;
    private long totalRead=0L;

    public ProgressSource(Source delegate, Listener listener) {
      super(delegate);

      this.listener=listener;
    }

    @Override
    public long read(Buffer sink, long byteCount)
      throws IOException {
      long bytesRead=super.read(sink, byteCount);
      boolean done=(bytesRead==-1);

      if (!done) {
        totalRead+=bytesRead;
      }

      listener.onProgressChange(totalRead,
        ProgressResponseBody.this.contentLength(), done);

      return(bytesRead);
    }
  }

  interface Listener {
    void onProgressChange(long bytesRead, long contentLength,
                          boolean done);
  }
}
(from HTTP/OkHttpProgress/app/src/main/java/com/commonsware/android/okhttp3/progress/ProgressResponseBody.java)

Our ProgressSource does two things:

  1. It tracks the total number of bytes that have been read so far
  2. Every time that we read more data, we call a ProgressResponseBody.Listener with the number of bytes that have been read so far, the known content length of the stream, and whether we are now done reading from the stream

Updating the Notification

Back in Downloader, the nightTrain passed in a progressListener to the ProgressResponseBody constructor. That progressListener is an implementation of ProgressResponseBody.Listener, where we can actually update our Notification:

      final ProgressResponseBody.Listener progressListener=
        new ProgressResponseBody.Listener() {
          long lastUpdateTime=0L;

          @Override
          public void onProgressChange(long bytesRead,
                                       long contentLength,
                                       boolean done) {
            long now=SystemClock.uptimeMillis();

            if (now-lastUpdateTime>1000) {
              builder.setProgress((int)contentLength,
                (int)bytesRead, false);
              mgr.notify(FOREGROUND_ID, builder.build());
              lastUpdateTime=now;
            }
          }
        };
(from HTTP/OkHttpProgress/app/src/main/java/com/commonsware/android/okhttp3/progress/Downloader.java)

Mostly, what we do is call setProgress() on the NotificationCompat.Builder. This takes:

Then, we build() a fresh Notification from the Builder and pass that to notify() with the same ID. This will update our existing Notification, showing the updated progress. This is where setOnlyAlertOnce(true) comes into play — the additional times we raise this same Notification with the updated progress will not trigger the alert, so the device ringtone does not play with each update.

However, our listener is going to be invoked fairly frequently, so much that we might swamp the system just constantly updating the Notification. To help with that, we track when we update the Notification and only update it again if a second has passed.

What If We Had an Activity in the Foreground?

You may want to be presenting the progress of the download in two places: the Notification and the UI of your application, if a relevant bit of that UI happens to be in the foreground. For example, you might have a fragment that contained the button or action bar item that kicked off the download. So long as that fragment is visible, you might want to have a ProgressBar on it and update the progress there.

If you use an event bus, you can have your ProgressResponseBody.Listener post an event with the progress of the download. However, that event should include some sort of unique identifier for the download itself, in addition to the progress. That way, only the fragment related to this specific download will show the progress, not similar fragments elsewhere.

The APK edition of this book takes this approach in the Community Theater area, where you can download and watch presentations on Android app development topics (“appinars”). An appinar is a ZIP archive up on a CommonsWare server. When you browse the catalog of appinars and choose one, the screen will either let you download the appinar or play the appinar (if it is already downloaded). The download will use code reminiscent of what you see in the sample app, with the added feature of event bus messages publishing the progress to the rest of the app. However, we only show the download progress on the fragment for the particular appinar that is being downloaded. If, while the download is progressing, you visit other appinar descriptions, we do not want to show the download progress there, as you will think that you are downloading those appinars too. The Notification shows the name of the appinar that you are downloading, so it provides built-in context for what appinar the progress pertains to.

Custom Views

When you specify a title and a description for a Notification, you are implicitly telling Android to use a stock layout for the structure of the Notification object’s entry in the notification drawer. However, instead, you can provide Android with the layout to use and its contents, by means of a RemoteViews. In other words, by using the same techniques that you use to create app widgets, you can create tailored notification drawer content. Just create the RemoteViews and supply it to your NotificationCompat.Builder via setContent().

To update the notification tile content, you update your RemoteViews in your Notification and re-raise the Notification via a call to notify(). Android will apply your revised RemoteViews to the notification drawer content, and the user will see the changed widgets.

The Notifications/CustomView sample project is a clone of the HTTP/OkHttpProgress shown in the previous section. The difference is that we will use our own custom layout and a RemoteViews rather than use the standard Notification UI.

The Notification Layout

This sample app has its minSdkVersion set to 21, so we only need to worry about providing a layout that looks OK on Android 5.0+ devices. Google dramatically changed the look of notifications with Android 5.0, so a layout that looks good on older devices may not blend in well with newer devices. If you have a need to support a wider range of Android versions, you will want to consider using versioned layout resources (e.g., res/layout/ for older devices, res/layout-v21/ for API Level 21+ devices).

But, since this app’s scope is limited, we can directly refer to Theme.Material-based themes, to get a layout that has elements that resembles the actual notification tile content:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical">

  <TextView
    android:id="@android:id/title"
    style="@android:style/TextAppearance.Material.Notification.Title"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="4dp"
    android:ellipsize="marquee"
    android:singleLine="true" />

  <ProgressBar
    android:id="@android:id/progress"
    style="@android:style/Widget.Material.ProgressBar.Horizontal"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="4dp"
    android:indeterminate="false" />
</LinearLayout>
(from Notifications/CustomView/app/src/main/res/layout/notif_content.xml)

Here, we are just showing a title and a ProgressBar. The TextView uses @android:style/TextAppearance.Material.Notification.Title, which is the same style as is used by the official Notification layout. The ProgressBar uses @android:style/Widget.Material.ProgressBar.Horizontal, also mirroring what you will see in real notifications.

Using the Layout

The new sample’s buildForeground() method now creates a RemoteViews for this layout, fills in the title, and uses that with NotificationCompat.Builder and setContent():

  private NotificationCompat.Builder buildForeground(
    String filename) {
    NotificationCompat.Builder b=
      new NotificationCompat.Builder(this, CHANNEL_WHATEVER);
    RemoteViews content=new RemoteViews(getPackageName(),
      R.layout.notif_content);

    content.setTextViewText(android.R.id.title, "Downloading: "+filename);

    b.setOngoing(true)
      .setContent(content)
      .setSmallIcon(android.R.drawable.stat_sys_download);

    return(b);
  }
(from Notifications/CustomView/app/src/main/java/com/commonsware/android/okhttp3/progress/Downloader.java)

However, NotificationCompat.Builder has a write-only API. We cannot get our RemoteViews back from that. But, we need the RemoteViews to be able to update our progress. So, to that end, we hold onto the actual Notification built by the Builder in onHandleIntent():

      NotificationCompat.Builder builder=
        buildForeground(filename);
      final Notification notif=builder.build();
(from Notifications/CustomView/app/src/main/java/com/commonsware/android/okhttp3/progress/Downloader.java)

Then, in our ProgressResponseBody.Listener, we get at the RemoteViews via the contentView public field on the Notification. We can call a setProgressBar() method on the RemoteViews, much as we called setProgress() on the NotificationCompat.Builder in the preceding example:

      final ProgressResponseBody.Listener progressListener=
        new ProgressResponseBody.Listener() {
          long lastUpdateTime=0L;

          @Override
          public void onProgressChange(long bytesRead,
                                       long contentLength,
                                       boolean done) {
            long now=SystemClock.uptimeMillis();

            if (now-lastUpdateTime>1000) {
              notif
                .contentView
                .setProgressBar(android.R.id.progress,
                  (int)contentLength, (int)bytesRead, false);
              mgr.notify(FOREGROUND_ID, notif);
              lastUpdateTime=now;
            }
          }
        };
(from Notifications/CustomView/app/src/main/java/com/commonsware/android/okhttp3/progress/Downloader.java)

Then, we can notify() the NotificationManager with the updated Notification, causing the ProgressBar to advance based on the actual progress made.

The resulting Notification shows our RemoteViews in action:

Custom Notification
Figure 413: Custom Notification

Styling Custom Views

A custom view for a Notification takes over the entire tile in the notification shade. Sometimes, this may be necessary to achieve the developer’s objective. Other times, though, while the main content area of the Notification might need to be custom, the rest of the “frame” around that content area could be left intact. This would include things like the app’s icon, the time the Notification was raised, any action buttons below the content, and so forth.

Android 7.0+ offers this via the Notification.DecoratedCustomViewStyle and Notification.DecoratedMediaCustomViewStyle styles. On your Builder, call setCustomContentView() with the RemoteViews for the content area, plus call setStyle(), passing in an instance of DecoratedCustomViewStyle or DecoratedMediaCustomViewStyle, to gain this effect.

Life After Delete

Most of the time, you do not care about your Notification being dismissed by the user from the notification drawer (e.g., pressing the Clear button on Android 1.x/2.x devices). If you do care about the Notification being deleted this way, you can supply a PendingIntent in the deleteIntent data member of the Notification — this will be executed when the user gets rid of your Notification. Usually, this will be a getService() or getBroadcast() PendingIntent, to have you do something in the background related to the dismissal. Users are likely to get rather irritated with you if you pop up an activity because they got rid of your Notification.

Note that this only works for Notification objects that can be cleared. If you have FLAG_ONGOING_EVENT set on the Notification, it will remain on-screen until you get rid of it.

The Mysterious Case of the Missing Number

The Notification class has a number data member. On Android 1.x and 2.x, setting that data member would cause a number to be super-imposed on top of your icon in the status bar. That data member no longer works as of Android 3.0.

However, NotificationCompat.Builder has a setNumber() method which does work on API Level 11 and higher, though with slightly different behavior. Instead of putting the number on top of your status bar icon, the number will appear in your notification drawer entry. This only works if you do not use setContent() with NotificationCompat.Builder to define your own notification drawer entry layout — in that case, you could put your own number in wherever you would like.

Notifications and MessagingStyle

Android 7.0 offers a new MessagingStyle to the roster of expanded Notification styles. This one is designed for a chat-style presentation, where you supply a series of chat messages (person, timestamp, and message), and they are rendered in the Notification. It is designed to be used with the RemoteInput option described earlier in this chapter, for the user to be able to participate in a chat without having to open up your activity.

As usual, there are two implementations of MessagingStyle:

The Notifications/Messaging sample project demonstrates the use of the latter, along with the RemoteInput support from earlier.

While the RemoteInput and MessagingStyle from NotificationCompat will build and run on older devices, they do not work especially well. You simply get a do-nothing Notification action for the RemoteInput and no real context around the messages. As such, you only want to use these options on Android 7.0 devices, gracefully degrading to some other experience on older devices.

Also note that the NotificationCompat.MessagingStyle is from the v24 generation of the Android Support libraries. In this case, we are using 25.0.0 of support-compat, which contains NotificationCompat and NotificationManagerCompat.

apply plugin: 'com.android.application'

dependencies {
    implementation 'com.android.support:support-compat:26.1.0'
}

android {
    compileSdkVersion 26
    buildToolsVersion '26.0.2'

    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"
    }
}
(from Notifications/Messaging/app/build.gradle)

As with the RemoteInput sample, the MainActivity is here just to provide us with an easy way to get the Notification to appear on the screen. In this case, though, all we do is use NotificationManagerCompat to show a Notification built elsewhere:

package com.commonsware.android.messaging;

import android.app.Activity;
import android.os.Bundle;
import android.support.v4.app.NotificationManagerCompat;

public class MainActivity extends Activity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    NotificationManagerCompat
      .from(this)
      .notify(RemoteInputReceiver.NOTIFY_ID,
        RemoteInputReceiver.buildNotification(this).build());

    finish();
  }
}
(from Notifications/Messaging/app/src/main/java/com/commonsware/android/messaging/MainActivity.java)

Our RemoteInputReceiver is more complex as a result:

package com.commonsware.android.messaging;

import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.app.RemoteInput;
import android.util.Log;
import java.util.Stack;

public class RemoteInputReceiver extends BroadcastReceiver {
  private static final String CHANNEL_WHATEVER="channel_whatever";
  static final int NOTIFY_ID=1337;
  static final String EXTRA_INPUT="input";
  static final Stack<Message> MESSAGES=new Stack<>();
  static final long INITIAL_TIMESTAMP=System.currentTimeMillis();

  static NotificationCompat.Builder buildNotification(Context ctxt) {
    NotificationManager mgr=
      (NotificationManager)ctxt.getSystemService(Context.NOTIFICATION_SERVICE);

    if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.O &&
      mgr.getNotificationChannel(CHANNEL_WHATEVER)==null) {
      mgr.createNotificationChannel(new NotificationChannel(CHANNEL_WHATEVER,
        "Whatever", NotificationManager.IMPORTANCE_DEFAULT));
    }

    Intent i=new Intent(ctxt, RemoteInputReceiver.class);
    PendingIntent pi=
      PendingIntent.getBroadcast(ctxt, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);

    RemoteInput remoteInput=
      new RemoteInput.Builder(RemoteInputReceiver.EXTRA_INPUT)
        .setLabel(ctxt.getString(R.string.talk))
        .build();

    NotificationCompat.Action remoteAction=
      new NotificationCompat.Action.Builder(
        android.R.drawable.ic_btn_speak_now,
        ctxt.getString(R.string.talk),
        pi).addRemoteInput(remoteInput).build();

    NotificationCompat.MessagingStyle style=
      new NotificationCompat.MessagingStyle("Me")
        .setConversationTitle("A Fake Chat");

    style.addMessage("Want to chat?", INITIAL_TIMESTAMP, "Somebody");

    for (Message msg : MESSAGES) {
      style.addMessage(msg.text, msg.timestamp,
        style.getUserDisplayName());
    }

    NotificationCompat.Builder builder=
      new NotificationCompat.Builder(ctxt, CHANNEL_WHATEVER)
        .setSmallIcon(
          android.R.drawable.stat_sys_download_done)
        .setContentTitle(ctxt.getString(R.string.title))
        .setStyle(style)
        .addAction(remoteAction);

    return(builder);
  }

  @Override
  public void onReceive(Context ctxt, Intent i) {
    Bundle input=RemoteInput.getResultsFromIntent(i);

    if (input!=null) {
      CharSequence text=input.getCharSequence(EXTRA_INPUT);

      if (text!=null) {
        MESSAGES.push(new Message(text));
      }
      else {
        Log.e(getClass().getSimpleName(), "No voice response speech");
      }
    }
    else {
      Log.e(getClass().getSimpleName(), "No voice response Bundle");
    }

    NotificationManagerCompat
      .from(ctxt)
      .notify(RemoteInputReceiver.NOTIFY_ID,
        buildNotification(ctxt).build());
  }

  private static class Message {
    final CharSequence text;
    final long timestamp;

    Message(CharSequence text) {
      this.text=text;
      timestamp=System.currentTimeMillis();
    }
  }
}
(from Notifications/Messaging/app/src/main/java/com/commonsware/android/messaging/RemoteInputReceiver.java)

For the MessagingStyle, we need messages. In a production app, this would be part of your app’s data model, probably saved in a file or database somewhere, with an in-memory cache for speed. In this sample app, we just have a static MESSAGES Stack, for our messages. Initially, this Stack is empty, but we will eventually fill in Message objects, each of which has text and a timestamp. Since this is a sample app, and all Message objects will come from our app’s user, we do not need Message to track the sender of the message — a real chat-style app would need this, in all likelihood.

The buildNotification() method starts off with the same basic code shown in the RemoteInput sample. But then, mid-way through the method, we build up the MessagingStyle:

    NotificationCompat.MessagingStyle style=
      new NotificationCompat.MessagingStyle("Me")
        .setConversationTitle("A Fake Chat");

    style.addMessage("Want to chat?", INITIAL_TIMESTAMP, "Somebody");

    for (Message msg : MESSAGES) {
      style.addMessage(msg.text, msg.timestamp,
        style.getUserDisplayName());
    }
(from Notifications/Messaging/app/src/main/java/com/commonsware/android/messaging/RemoteInputReceiver.java)

The parameter to the MessagingStyle constructor is the name associated with the user of this app. That name will appear alongside messages that come from this user. A MessagingStyle can have a title, set via setConversationTitle(), to provide some context for the chat transcript.

We then add one fake message, ostensibly from the chat partner, via the addMessage() method. The version we use here takes the text, timestamp, and name of the other party to use in the message.

Then, if there are messages in the MESSAGES stack, we add those to the chat transcript as well. Note that our third parameter is style.getUserDisplayName(), which returns the value that we passed into the MessagingStyle constructor. We could get the same effect by passing null for the third parameter.

That MessagingStyle then is attached to the NotificationCompat.Builder via setStyle().

At the outset, since MESSAGES is empty, we get a single message from “Somebody”:

Messaging Demo Notification, As Initially Launched
Figure 414: Messaging Demo Notification, As Initially Launched

If the user taps “Talk to Me”, types in a message, and clicks the send button, our RemoteInputReceiver will take that text, put it in a Message, push that Message into the MESSAGES Stack, then update the Notification with the new transcript:

Messaging Demo Notification, After Two Replies
Figure 415: Messaging Demo Notification, After Two Replies

The timestamp does not show up; it is unclear how that is used by Android.

Changes in API Level 23

Historically, while we could supply a “large bitmap” (e.g., photo or avatar) to a Notification for use in the tile in the notification tray as a Bitmap, the “small icon” used for the status bar always had to be a resource in our app. This was aggravating for developers that wanted to tailor the small icon, such as a weather app showing the current temperature. Now, we can supply an Icon object, which can wrap a drawable resource, a Uri to a ContentProvider, a byte array of encoded bitmap data, a Bitmap, or a path to a local PNG or JPEG file. Any of those can be used for the small icon, offering greater flexibility. That being said, please do bear in mind that the small icon is small (i.e., tiny changes may not be noticeable) and that ideally it should adhere to the platform aesthetic for notification icons (i.e., do not use a photo).

The user can now disable our heads-up notifications, if the user finds them irritating. We can get an idea of what the user’s chosen notification policies are via getCurrentInterruptionFilter() and getNotificationPolicy(), so we have some general sense of what the user is and is not expecting to see in terms of notifications.

And, at long last, we can find out all of our active notifications, via a getActiveNotifications() method. This will include any notification that is visible to the user (i.e., the user has not dismissed it and we have not gotten rid of it via cancel()).

Sounds and Android 7.0

You can put a custom ringtone on a Notification, via methods like setSound() on NotificationCompat.Builder. This requires a Uri. And, unfortunately, your options for that Uri are limited:

The best easy solution at the moment, for content Uri values, is to grant read access to your content to the com.android.systemui package, via the grantUriPermissions() method on Context:


grantUriPermission("com.android.systemui", sound,
  Intent.FLAG_GRANT_READ_URI_PERMISSION);

(where sound is the Uri that you want to use with setSound())

However, it is unclear if this is the right package to use for all versions of Android and all possible device manufacturer modifications to Android.

Another possibility is for you to create a read-only ContentProvider that can serve your file, perhaps modeled after this book sample, then export that provider. The FileProvider from the Android Support library would work, except that it cannot be exported.

Eventually, the author’s StreamProvider will be updated to support this sort of public read-only pattern.

With luck, future versions of Android (and the NotificationCompat backport) will address this more formally.

Auto-Timeout

In Android 8.0+ can now provide a time when the notification should be automatically deleted, as if the user had cleared it personally. This is good for notifications whose value declines a lot after a particular point in time.

To use this, call setTimeoutAfter() on the NotificationCompat.Builder, supplying a duration in milliseconds:


  public void raiseCoins(View view) {
    Notification n=new NotificationCompat.Builder(MainActivity.this, CHANNEL_COINS)
      .setContentTitle(getString(R.string.notif_coins_title))
      .setContentText(getString(R.string.notif_coins_text))
      .setSmallIcon(android.R.drawable.stat_sys_warning)
      .setTimeoutAfter(5000)
      .build();

    mgr.notify(NOTIF_ID_COINS, n);
(from Notifications/Channels/app/src/main/java/com/commonsware/android/notify/channel/MainActivity.java)

Here, we set the notification to time out after five seconds. Five seconds would be far too short of an active window for ordinary users, but it allows a developer to see the results more quickly.

Launcher Icon Badge

Depending on the home screen launcher implementation, your notifications may display a dot on the home screen launcher icon:

Home Screen Launcher Notification Dot, from Pixel
Figure 416: Home Screen Launcher Notification Dot, from Pixel

This will happen by default; you do not need to do anything to make it happen. However, it will only appear on a handful of devices that have a compatible home screen.

You can disable this by calling setShowBadge(false) on the NotificationChannel when you create it.

For launchers that support badges, a long-press on the launcher icon may show details of the notification:

Home Screen Launcher Notification Details, from Pixel
Figure 417: Home Screen Launcher Notification Details, from Pixel

You can control two elements of this, beyond what you would normally configure in the notification itself:

Home Screen Launcher Notification Details With Number, from Pixel
Figure 418: Home Screen Launcher Notification Details With Number, from Pixel

For example, you might use this for an unread message count, if that information is not otherwise present in the notification.