In this post we'll discuss scheduling notifications in Android using JobIntentService & BroadcastReceiver.
For context, we'll be scheduling these notifications for records - contacts with a name, phone number, and email address - stored in the app via Room. We will set a start date and time, as well as a recurrence frequency for the notifications. The notifications will also carry intents, so that when clicked they launch an activity (phone call) with contextual information from the associated contact (their phone number). We'll also be using a BroadcastReceiver to start the notification schedule back up when the device restarts.
Grab the project on GitHub here to follow along.
Our sample application will allow us to create, store, and display information about contacts. It will have four activities:
- A MainActivity with three buttons taking users to the other three
- A ContactEdit activity, which lets us create contact records manually (contact fields include name, phone number, and email address)
- A ContactSelect activity, which lets us see existing contacts in a list & click on them to edit them
- A Call activity class - this activity is what is launched from a notification - it opens the phone call activity so a user can call the contact that the notification is for
In addition to the four activity classes, we have several others. These are listed below along with a description in some sort of logical grouping:
- Database classes:
- DatabaseClient - database client class
- AppDatabase - Room database class
- DAO (Data Access Object)
- ContactDao - dao for operations on Room (contact) records
- Repository
- ContactRepository - repository class for performing CRUD operations on contacts (& other utilities for contacts)
- Models
- Contact - model class for contacts with properties, Room annotations, and getters & setters
- ViewModel
- ContactsViewModel - view model for a list of contacts, used by ContactSelect (primarily so we can set wrap LiveData around the list of contacts)
- Adapters
- ContactAdapter - takes care of binding contacts to the RecyclerView in the ContactSelect activity as well as updating the contact list and launching the ContactEdit activity
- ClickListener
- CustomContactClickListener - click listener interface to launch the EditContact activity when a contact is clicked in ContactSelect
- Converters - helper functions to perform conversions
- MessageCreator - class with static methods to handle creating recurring notification tasks
- NotificationScheduler - handles scheduling & cancelling of repeating alarms
- NotificationBroadcastReceiver - extends BroadCastReceiver & that NotificationService to create notifications for each contact; this is invoked when the phone boots so that notifications can be started againa after the device has been off
- NotificationService - extends JobIntentService to handle the actual creating of notifications
That may look like a lot, but don't panic! Much of this is just supporting code & a barebones application for us to use in order to create & view records to make the notifications part of the application more meaningful. Specifically, we'll be focusing on MessageCreator, NotificationBroadcastReceiver, NotificationScheduler, NotificationService, and Call, as well as a bit of extra treatment on ContactEdit.
Before we do, let's take a tour of the rest of the code to understand what's going on there. This list is just about understanding what the baseline moving pieces are and how they fit together - we'll moslty gloss over the complexity in these parts. Feel free to skim or skip this part until we get to the section on MessageCreator below.
- MainActivity - not much going on here at all. Just onClick methods to launch the other activities.
- AppDatabase - very little meat in this one. We're just defining our Room database, where we'll be storing our contact records. We give it a member for the name of the table and for its dao.
- DatabaseClient - this is a simple database client class, like one you'll see in a many Room applications. This will facilitate our use of a singleton pattern so we can have just one instantiated instance of the database for every access.
- ContactDao - about what you'd expec to see in a contact DAO in here. Note how we're wrapping the List returned by getAllContactsLiveData with LiveData - this is for our implementation of LiveData so that our list of contacts in ContactSelect is updated immediately when we make any changes, such as deleting a contact.
- ContactRepository - holder of repository methods to instantiate the CRUD method signatures we defined in ContactDao.
- Contact - our model class for contacts. This has member fields for ID, name, phone number, email address, and country code for phone number. The inclusion of country code is for more robust handling of international phone numbers.
- Unlike the previous article on importing contacts, this time Contacts also contains fields for frequency, start date, and status (on or off) of notifications.
- ContactsViewModel - our view model for a list of contacts, used by the ContactSelect activity.
- ContactSelect - activity class to display the created contacts in a recyclerview for selection. We can only come here via the Contact Select button on the MainActivity.
- Everything in onCreate below the "// set ui pieces"... comment is only there to handle the case when no contacts have been created yet. In this case, we make visible the text view letting the user know that there are no contacts yet, and also make visible a button to enable them to create their first contact.
- ContactsViewModel - view model to wrap LiveData around a list of contacts for ContactSelect
- ContactAdapter - the RecyclerView adapter used by ContactSelect to bind a contact to each row and implement our CustomContactClickListener to launch ContactEdit for a selected contact.
- CustomContactClickListener - just an interface for our click listener.
With that out of the way, let's cover the real reasons we're here, which reside in the following classes.
MessageCreator - this class just has a couple of static methods & is used wherever we're creating a recurring notificaiton task for a contact (in MainAcitviy, ContactEdit, and NotificationBroadcastReceiver). The setMessageTask method takes in a Contact and creates a recurring notification task for the contact using the start date and frequency for that contact. We check if the contact is active before proceeding with scheduling. To create the notification task we use a NotificationScheduler, which we'll look at next.
We also have the getNextAlarmTime method, which takes in startTime and repeatTime and calculates the occurrence of th next alarm ("next" meaning "after now" at time of evaluation). This is used by setMessageTask to derive the next notification's time to pass to NotificationScheduler, and it's also used by ContactEdit to display the time of the next notification for a given contact to the user based on their selected start time & frequency.
Next, let's look at NotificationScheduler. This is another fairly small class - it has just three methods. It uses the AlarmManager class to handle scheduling of recurring notification tasks. The getAlarmManager method just returns an AlarmManager instance to the other methods - setRepeatAlarm and cancelAlarm.
The setRepeatAlarm method is called by MessageCreator, which passes it a start time, frquency, and contact. After getting an AlarmManager instance, we call NotificationBroadcastReceiver.getReminderPendingIntent (passing in our contact) to get a PendingIntent for the NotificationBroadcastReceiver class. When we then call setRepeating to set up our repeating alarm and pass in the PendingIntent, the induced alarm will cause us to enter the NotificationBroadcastReceiver's onReceive method. From there we'll handle launching the actual notifications, which we'll discuss shortly.
The cancelAlarm method also gets an AlarmManager instance and PendingIntent from NotificationBroadcastReceiver, but then calls cancel instead to cancel an existing scheduled alarm with the passed in intent.
NotificationBroadcastReceiver extends from BroadcastReceiver and implements an onReceive method, which gets called whenever the BroadcastReceiver is invoked. This happens in two cases:
- When a scheduled notificaiton alarm time comes up
- When one of the receiver intents registered in our AndroidManifest.xml occurs
Case #1 comes from the scheduled alarms in NotificationScheduler we discussed earlier. Case #2 is provided by our adding the contents of the tag in AndroidManifest.xml, where we register our BroadcastReceiver & intents that we want to invoke it (e.g. "android.intent.action.BOOT_COMPLETED"). These intents ensure that our BroadcastReceiver's onReceive method is invoked when the device boots up or performs a similar return from a state that would have seen our alarm get unscheduled.
Depending on which intent brings us into NotificationBroadcastReceiver, we'll do a different thing. If we're there for a regularly scheduled alarm, we'll call NotificationService.enqueueWork to create the actual notification, and that's it. If we're there because we're booting up, we'll then use an AsyncTask (StartNotificationsAsyncTask) to loop through all contacts and call MessageCreator.setMessageTask to start up their notification schedules again. We do something similar in MainActivity as well, just in case alarms have stopped being scheduled for whatever reason, to ensure that opening the app gets them scheduled again. Since we're using a serialized contact & contact ID to identify our alarm's PendingIntent, along with the PendingIntent.FLAG_CANCEL_CURRENT flag, we won't be creating duplicate alarm schedules by doing this if the alarms are still scheduled.
The only other thing in NotificationBroadcastReceiver is the getReminderPendingIntent method, which was called by NotificationScheduler to return a PendingIntent for this BroadcastReceiver. It creates an intent for NotificationBroadcastReceiver and uses this intent - along with a contact ID (to be used as the notification ID) and serialized contact to create a PendingIntent for NotificationBroadcastReceiver with enough contextual info for the notification.
NotificationService extends from JobIntentService, which means that it uses the enqueueWork (which we call in NotificationBroadcastReceiver) method to queue up work, which is handled by the onHandleWork method. The "work" being enqueued is an intent with our Contact stored as a serialized string extra. In onHandleWork we deserialize to reassemble the Contact and then pass this to createNotification.
The createNotification method handles everything around creating the actual notification that's shown to the user. We call isNotificationChannelEnabled & createNotificationChannel (if the notification channel is not enabled), to ensure we have a notification channel to use - these methods each follow a fairly standard pattern.
We then call notificationBuilder, to which we pass our Contact and have returned a NotificationCompat.Builder. It's in this method that we put the details of a specific notification (title, text, & PendingIntent to open the Call activity) into the NotificationCompat.Builder object. When this is returned to createNotification, we create a NotificationManagerCompat instance and call its notify method to finally launch the notificaiton to be displayed.
The Call activity is opened via from the notification because in notificationBuilder we used a PendingIntent for the Call activity (callPendingIntent) as the notification's content intent via setContentIntent. So when the notification is tapped, this is the activity that is launched.
In Call's onCreate method we retrieve the Contact from the intent's extra, and then pass it to the method handleCallIntent. In this method we get a phone number string by calling getFormattedPhoneNumber for the Contact, and then use this number to create a call intent from the setupCallIntent method. This set's the telephone number in the intent, which will let the user select from avaialble phone call apps when the intent is triggered, which we do next.
ContactEdit - the activity for creating a new contact. We're taken here to create new contacts, or when we've selected an existing contact to edit from the ContactSelect activity. In this latter case we'll be passed in an int extra in the intent called editContactID, which we use to fetchContactById. If we're not passed an ID, we set editContactID to -100 which indicates that we're working on a new contact (all saved contacts in the app have IDs >= 0). We keep track of whether we're in either case via the isNewContact and isImported boolean variables.
Worth pointing out in this are the inputs that control the notification's status and schedule. We have buttons to allow the user to select a start date & time - these call the showDatePickerDialog and showTimePickerDialog methods respectively to do this. These set ContactEdit's selectedCalStartDate instance variable, which is a Calendar object. We also have a SwitchCompat instance variable called statusSwitch which toggled whether notifications are on or off for the contact.
We have TextViews to show the selected start date & time as well as the next scheduled notification date & time (this latter one being given by the MessageCreator.getNextAlarmTime method). These update based on listeners which update the displayed dates & times when inputs that would affect them are changed - these being the selected start date, start time, frequency, and status.
There is some additional content for validating input data beneath the "// validation code starts here" comment. There's also come complexity added by the country code picker for robust phone numbers. This is provided by the neat android library on github here: https://github.com/hbb20/CountryCodePickerProject.
We're just about done, but before we wrap up let's touch on our layouts (this is a proper subset of the layouts used in the previously covered ImportContacts project, so if you've been through that one, these won't be new):
- activity_main - this one's nothing but a container for buttons to the other contacts
- activity_contact_edit - nothing too fancy in here (no databinding or anything) - just some EditTexts and a buttons to save or delete a contact
- We also have buttons to edit the selected start date & time, which call showDatePickerDialog and showTimePickerDialog respectively
- Additionally, we have TextView displays of the selected start date & time and the next scheduled notification, based on the selected start date & time, frequency, and whether the notification status is enabled
- activity_contact_select - this one uses a RecyclerView in a ScrollView for us to scroll through contacts; we use databinding with the ContactAdapter to provide data to contact_list_row instances within the RecyclerView
- contact_list_row - databinding binds to a Contact & our custom click listener takes us to ContactEdit when you click one; note the expressions we use to display contact info in each contact's CardView's android:text
And that's all! We've shown how you can scheudule notifications for records in an application at a frequency set by the user. These notifications can be turned on and off, carry information from their associated record, and can launch an intent for an activity to which we can pass information from that record. Furthermore, these scheduled notifications won't be interrupted by a device restart. If you find yourself wanting to implement similar recurring notifications in your own application, the techniques covered here should help guide the way. Happy notifying!