Navigation / Notification / TaskStackBuilder

Task Stack

There are often occasions when we need to take the user to a specific Activity and / or piece of content within an app. A typical use-case for this is a notification where tapping on the notification should launch the app directly to a specific place. In the recent series or Oreo Notification Channels we looked at a mocked messaging app, and for such an app it would be good if tapping on the notification could launch the app and take the user directly to the message in question. While this is easy enough to do, the back navigation behaviour from that newly launched Activity can be somewhat confusing to the user. In this article we’ll look at these behaviours, and also some strategies that we can adopt to control this navigation behaviour, and how we can tailor it to provide more a more natural behaviour.

Before we get stuck in, it is worth pointing out that although we’ll be working on the same code as we used for Oreo Notification Channels, the issues and techniques that we’ll cover will work back to Android 4.1 Jelly Bean (API 16) if we use the native version, or earlier if we use the v4 core-utils library support library.

The Oreo Notifications app doesn’t actually support the display of individual messages as it is simply a test bed to show how we can create different kinds of Notification. So we’ll begin by adding a second Activity to the app so that we can have some simple navigation. We’ll add a button to the existing Activity, and then ensure that we get the correct back an up behaviour from the second Activity:

class MainActivity : AppCompatActivity() {
    private val serviceScheduler: ServiceScheduler by lazyFast {
        ServiceScheduler(this)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        serviceScheduler.takeIf { it.isEnabled }?.apply {
            startService()
        }
        button?.setOnClickListener {
            startActivity(Intent(this, SecondActivity::class.java))
        }
    }
}

Next we’ll add SecondActivity to the Manifest:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  package="com.stylingandroid.oreo.notifications">

  <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

  <application
    android:allowBackup="false"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/AppTheme"
    tools:ignore="GoogleAppIndexingWarning">
    <activity android:name=".MainActivity">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>

    <activity
      android:name=".SecondActivity"
      android:parentActivityName=".MainActivity">
      <meta-data
        android:name="android.support.PARENT_ACTIVITY"
        android:value=".MainActivity" />
    </activity>
    .
    .
    .
</application>

By adding parentActivityName and the meta-data we configure the navigation hierarchy. In this case, hitting back will return to the MainActivity. Not only is it good practice to do this, but it will also be really helpful later on.

Finally we implement SecondActivity:

class SecondActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_second)

        supportActionBar?.apply {
            setHomeButtonEnabled(true)
            setDisplayHomeAsUpEnabled(true)
        }
    }

    override fun onOptionsItemSelected(item: MenuItem?): Boolean {
        item?.takeIf { it.itemId == android.R.id.home }?.run {
            onBackPressed()
        }
        return super.onOptionsItemSelected(item)
    }
}

For simplicity I have made both back and home behave the same way, but different apps may have different requirements here, so nothing that we’ll cover here is dependent on this kind of behaviour.

So the basic behaviour that we’ll get here is if we tap on the button in MainActivity, SecondActivity will be launched. If we then hit either “back” or “up” we’ll return to the MainActivity.

So with that in place, we’ll pretend that SecondActivity would display a specific message (it won’t but, we can pretend, right?). When we create our Notification

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

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

private fun getContentIntent(): PendingIntent {
    Intent(context, SecondActivity::class.java).run {
        return PendingIntent.getActivity(context, 0, this, PendingIntent.FLAG_UPDATE_CURRENT)
    }
}

With this in place we can now take a look at how the back navigation works. If our app isn’t actually running, then hitting “back” simply closes SecondActivity and returns us to wherever we were previously:

But this behaviour is different if the app is already running. If the app is currently on running on MainActivity, hitting “back” from the newly launched SecondActivity will actually go back to MainActivity even though it wasn’t actually resumed when SecondActivity was launched. This is because of the navigation that we added to the manifest:

However, if SecondActivity is actually at the top of the task stack in the backgrounded app, then hitting “back” from the newly launched SecondActivity will actually return to the previous SecondActivity:

This kind of inconsistency can be really frustrating for the user, so it is important that we think very carefully about what is the correct behaviour, and ensure that it remains consistent for all permutations of whether the app is already running in the background, and the current state of the backgrounded app.

While there are a number of approaches that we could take, it is often best for the app to behave consistently irrespective of how the use got to SecondActivity. So irrespective of whether the user reached SecondActivity by tapping on a Notification, or by navigating there from MainActivity, then “back” will always return to MainActivity, even if the app wasn’t previously running in the background. We already have that behaviour then the user navigates there from within the app, so we need to recreate the back stack when SecondActivity is launched from a Notification.

The key to achieving this is to use TaskStackBuilder which was introduced in API 16 but is also available in the v4 core utils support library. This can work some real magic if we use correctly declare our navigation hierarchy in the Manifest (as we did earlier – I said it would make life easier!). Let’s replace our PendingIntent creation to use this:

private fun getContentIntent(): PendingIntent =
        TaskStackBuilder.create(context).run {
            addParentStack(SecondActivity::class.java)
            addNextIntent(createIntent(SecondActivity::class.java))
            getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) as PendingIntent
        }
    
private fun createIntent(cls: Class<*>): Intent =
        Intent(context, cls).apply {
            flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
        }

The first line of getContentIntent() creates a TaskStackBuilder instance, and runs the nest three commands on that instance.

The second line constructs the back stack for us. This is where all of the magic happens: the addParentStack() function creates a back stack from the navigation information we provided for the Activity class that we pass in as the argument. Earlier we defined the parent Activity for SecondActivity to be MainActivity and this function will construct this back stack for us. Even if we had multiple layers of navigation, in other words if the parent of this Activity was a different Activity, and that Activity has a declared parent of MainActivity, then the correct back stack would be created. The naming is a little confusing here, because it is easy to wrongly infer that we should be providing the class of the parent Activity here, but we actually need to provide the class of the Activity which will be created by the PendingIntent.

The third line constructs the Intent to launch SecondActivity. We set FLAG_ACTIVITY_SINGLE_TOP here to ensure that if SecondActivity is already top of the task stack then we don’t create a duplicate.

The fourth line creates the PendingIntent which will launch SecondActivity with a complete back stack, and this PendingIntent is what gets returned from this function.

As a result of doing this the in-app back navigation and that which is used when launching from a Notification are both constructed from the parent hierarchy which we declared in the Manifest, so we get consistent behaviour throughout:

TaskStackBuilder is a really simple API to use but, provided we correctly declare our navigation hierarchy in the Manifest, it can do some really powerful things for surprisingly little effort. Of course it is not just limited to Notifications as we can get correct back stack implementation anywhere that a PendingIntent is used to launch an Activity.

The source code for this article is available here.

© 2017, Mark Allison. All rights reserved.

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

1 Comment

Leave a Reply

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

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