Mobile Development and 3D Graphics - Part 4

Notifications and Implicit Intents

Notifications

Frequently we wish to inform the user of an event, such as receiving an email, or getting a new message in social media. Such a message is known as a notification. In Android, a notification is a message which appears as an icon at the top of the device and also appears in a notification list. The Notification class represents a notification.

Creating and Displaying a Notification

Making something happen when the user selects the notification

In many cases, we will want something to happen when the user clicks on a notification. or example, imagine a mapping app in which you receive a notification when you are nearby a point of interest. You might want a separate activity to launch when the user clicks on the notification, which displays full details of the point of interest (e.g a description, and reviews, for a pub or hotel).

To do this we need to use pending intents. A pending intent is an intent which will occur at some future time (e.g. when the user clicks on the notification) hence the name PendingIntent.

Here is an example of creating a pending intent. Note we must first create an Intent, and then wrap it with a PendingIntent:

// First, create an Intent, to launch the EmailActivity
val intent1 = Intent(this, EmailActivity::class.java).apply {
    putExtra("emailMessageId", 2345)
}

// Set the flags (options) for the Intent (discussed below)
intent1.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP

// Now create a PendingIntent referencing that intent
val launchActivityPendingIntent = PendingIntent.getActivity(this, 0, intent1, PendingIntent.FLAG_UPDATE_CURRENT)
You then use the PendingIntent in a notification:
        val notification = Notification.Builder(this)
                            .setContentTitle("New email message")
                            .setContentText("You have a new email message")
                            .setSmallIcon(R.drawable.message)
                            .setContentIntent(launchActivityPendingIntent)
                            .build()
        

Navigating back to the same activity with a notification

One common use-case for pending intents is to navigate back to the activity which generated the notification, by clicking on a notification. The main activity of an email app might notify you when an email has been received. However, you might be using another app at the time, with the email app in a stopped state in the background (i.e. between onStop() and onStart()). What should happen is that the email app should become visible again when you click on the notification.

This is a little more complex than you might think: you have to account for whether the email activity is already running in the background or not; if it is, it should become visible, but if it is not, it should be launched. The code above will achieve this.

The key things to note here are the flags of the intent (note: setFlags() in Java). To repeat this code:

intent1.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
These mean the following:

onNewIntent() - handling intents delivered to an activity

Additional actions

Notifications can have additional actions (which appear as buttons). You use Notification.Builder's addAction() with an Action object to add these; again providing an icon, text and a PendingIntent. For example, here we create a button "Stop Music Player" which is associated with a PendingIntent containing a broadcast Intent to send a broadcast with the action STOP_MUSIC. (Note how we use PendingIntent.getBroadcast() rather than PendingIntent.getActivity())

val broadcast =Intent().apply {
    action = "STOP_MUSIC"
}
val piStopMusic = PendingIntent.getBroadcast(this, 1, intent2, PendingIntent.FLAG_UPDATE_CURRENT)
               
val notification = Notification.Builder(this)
                    .setContentTitle("Song update")
                    .setContentText("Now playing: Oh Well by Fleetwood Mac")
                    .setSmallIcon(R.drawable.stop)
                    .setContentIntent(pendingIntent)
                    .addAction(Notification.Action.Builder(R.drawable.icon2, "Stop Music", piStopMusic).build())
                    .build()

Notification channels

On Android Oreo (API level 26) and upwards, notifications must be associated with a particular channel. Channels group together related notifications; all notifications on a given channel can be associated with the same sound or light colour (e.g. flashing green for text message, blue for a social media update, and so on. A user can allow or block all notifications on a particular channel for a particular app, by going to the settings for that app, selecting "Notifications", and turning that specific channel on or off.

To use channels:

If your targetSdkVersion is 26 or more and you're running on an older version of Android, multiple channgels will not be available, but you must still specify a channel ID when creating your notification (otherwise you will get a deprecation warning) but this can be anything.

Notification channels - example

The example was originally based on that provided here, but has been modified. More information on channels can be found on this page, including associating a channel with a notification light colour or vibration, organising channels into groups, opening the user's notification settings and deleting channels.

val channelID = "EMAIL_CHANNEL" 

// Check that we are running at least Oreo, channels can't be used in older versions
if(Build.VERSION.SDK_INT >=Build.VERSION_CODES.O)  {
    val channel = NotificationChannel(channelID, "Email notifications", NotificationManager.IMPORTANCE_DEFAULT) 
    val nMgr = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
    nMgr.createNotificationChannel(channel)
}
When you create your notification with your builder, specify the channel ID:
val notification = Notification.Builder(this, channelID).setContentTitle(...).etc...
Note that:

Implicit Intents

  • Implicit intents allow us to request the services of another application from our own
  • Why is this useful?
    • We might want to make a phone call, send an email, take a photo or look at a web page from within our app (e.g. in a points of interest app, we might want to allow a user to call or message a pub or hotel)
    • Rather than implementing email, photo or web functionality ourselves, we can launch one of the standard system apps from our own
    • We do this by sending an implicit intent to launch the default application for a particular action

Sending an implicit intent

  • Here is an example of an implicit intent:
    val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply{
       putExtra(AlarmClock.EXTRA_MESSAGE, "Time to get up for early flight!")
       putExtra(AlarmClock.EXTRA_HOUR, 4)
       putExtra(AlarmClock.EXTRA_MINUTES, 30)
    }
    if(intent.resolveActivity(packageManager)!=null) {
        startActivity(intent)
    } else {
        Toast.makeText(this, "No activity to handle alarm intent", Toast.LENGTH_LONG).show()
    }
    
    
  • This is an intent to set the alarm for 04:30
  • It uses whichever app is registered as the default alarm clock app
  • Note the use of an inbuilt action, AlarmClock.ACTION_SET_ALARM
  • We add the details of the alarm to the Intent as extras (similar to the approach you have seen before for passing information through an Intent)
  • Note also how we have to check that there is an activity capable of receiving this intent:
    if(intent.resolveActivity(packageManager)!=null) {
        startActivity(intent)
    }  else {
        Toast.makeText(this, "No activity to handle alarm intent", Toast.LENGTH_LONG).show()
    }
    

Another example - opening the phone dialer

  • Using the inbuilt action Intent.ACTION_DIAL, this example will launch the phone dialer with a specific phone number loaded in
  • With dial intents, we set the intent's data rather than adding extras
  • e.g:
     
    val intent = Intent(Intent.ACTION_DIAL).apply {
       data = Uri.parse("tel:+442382013075")
    }       
    if(intent.resolveActivity(packageManager)!=null) {
       startActivity(intent)
    } else {
       Toast.makeText(this, "No activity to handle dialer intent", Toast.LENGTH_LONG).show()
    }
    

A third example - opening a web page

The ACTION_VIEW implicit intent will open a web browser to view a given URL. Again the URL is set as the intent's data.

 
val intent = Intent(Intent.ACTION_VIEW).apply {
    data = Uri.parse("https://www.free-map.org.uk")
}

if(intent.resolveActivity(packageManager)!=null) {
   startActivity(intent)
} else {
   Toast.makeText(this, "No activity to handle web intent", Toast.LENGTH_LONG).show()
}

What if more than one app recognises an implicit intent?

  • It's possible that more than one activity on the device will respond to a given implicit intent, e.g. main activities for two web browsers or email apps
  • The user can select a default app, e.g. default web browser or email app
  • If you want to give the user the choice each time, however, you should launch a chooser to allow the user to select the preferred app
  • e.g. this example allows the user to pick a browser app (this example is based on that given in the Android documentation):
    val viewIntent = Intent(Intent.ACTION_VIEW).apply {
        data =  Uri.parse("https://www.free-map.org.uk")
    }
    val chooser = Intent.createChooser(viewIntent, "Pick a web browser")
    if(viewIntent.resolveActivity(packageManager)!=null) {
        startActivity(chooser)
    }
    

Making your apps respond to implicit intents

  • As well as launching other apps using implicit intents, you might wish to make your own apps respond to implicit intents, so that other apps can call them
  • To do this you create an intent filter (you've already seen these in the broadcasts topic) to allow your activity to receive selected implicit intents
  • In this case, intent filters are specified in your manifest:
    <activity....>
        <intent-filter>
            <action android:name="MyAction" />
            <category android:name="android.intent.category.DEFAULT" />
        </intent-filter>
    </activity>
    
  • Note how we have placed the intent-filter tag inside the activity tag
  • Within the intent-filter tag we specify the action to allow through and the category of intent (usually you can leave this as DEFAULT as here)

Reading an implicit intent received by an activity

  • Having set up our intent filter, we can read the received implicit intent from our activity with the intent attribute (a readable property, in Java you would use getIntent()
  • We can then check the intent's action (the intent filter might be allowing more than one intent type through), and then read its extras
  • Or, if we passed data through the intent instead (as in the phone dialer example), we can read it with getData()
  • To send this implicit intent from another activity, we'd simply create an intent with an action of MyAction

Passing back data from an activity launched with an implicit intent

  • An activity launched with an implicit intent can pass back an Intent to whatever called it with setResult(), and then call finish() (as you have seen already with activities launched with regular Intents)
  • You can use the same techniques to launch an activity with an implicit intent, as you can for a regular (explicit) intent

Resources

Exercises

  1. Return to your services application from last week. Ensure that you have completed the BroadcastReceiver question so that a broadcast is sent back from the service to the activity when a new GPS location is received. Write code in the main activity to display a notification containing the current location whenever the GPS location changes. Ensure you use a notification channel.
  2. Add a "Stop GPS" button to the notification, which, when clicked, stops the GPS by sending a broadcast to the service. If you did not do this last week, you should do it now.
  3. Change your main activity so that it can receive an implicit intent from another application. The implicit intent should have an action of ACTION_DISPLAY_MAP and should take latitude, longitude, and map style (regular or OpenTopoMap, see last year) as extras. The activity should read the latitude and longitude from the implicit intent and set the map's location accordingly.
  4. In a completely different project, write a simple app with one activity, allowing the user to enter a latitude and longitude in text fields and pick the map style via a Spinner (see below). This activity should use an implicit intent to launch your map activity from your original application when a button is pressed.
    • A Spinner is a dropdown list of items. Here is an example:
          <Spinner
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:id="@+id/spAddNote"
              android:entries="@array/noteTypes"
              app:layout_constraintTop_toBottomOf="@id/etAddNote" />
      
    • Note how we specify the entries with android:entries which is a reference to an array resource, i.e. any XML file with an <array> tag in the values folder within res:
      <resources>
          <array name="noteTypes">
              <item>Path problem/hazard</item>
              <item>Path directions</item>
              <item>Place of interest</item>
          </array>
      </resources>
      
    • You can access the selected item with the spinner's selectedItem property or its position within the list (starting at 0) with selectedPosition.
  5. If you finish, look at the exercises in the additional notes on coroutines.