Implementing Periodic Notifications with WorkManager
An article on how I implemented daily reminders to log expenses on my app Transactions, first using FCM and then WorkManager.
I had some free time on my hands this morning so I decided to work on an issue for my app Transactions. The issue was to add a recurring notification feature that would remind the users of the app to log their transactions for the day.
So, I did some research, and decided to use one of two ways:
1. Firebase Cloud Messaging (the easy way)
Firebase Cloud Messaging is a service that allows you to send notifications or data payload to your Android, iOS, or Web app. Sending background notifications (which only work when your app is in the background) is fairly easy. You can either use the firebase admin-sdk
or the notification composer in the Firebase Console to send a notification with an optional payload (image, or key-value pairs) to a target token, a topic that the client device subscribed to, or to all the users of the app altogether. When the app is in the background, the notification appears in the system tray, and tapping it launches the app. It's extremely simple to set up a recurring notification as well, you just have to change the frequency.
For Transactions, I simply added the SDK dependencies to my build.gradle
file, ran the app, exited the app (so that it's in the background, foreground notifications work very differently), and used the notification composer to send a test notification. Once that worked, I set up a recurring notification for 10 PM daily.
FCM also allows you to modify your notifications on the go, so that's an added advantage. But, I wanted some more features out of this:
Users should be able to change the default reminder time of 10 PM to a time that they'd prefer
Users should be able to opt out of this feature if they want to.
While the second feature could've been implemented by having the app register or deregister from a daily-reminder
topic in the FCM, the first one would require either cloud functions or a suitable backend, and I wanted to keep this implementation on-device. So, I moved to the second option.
2. WorkManager with Periodic work requests
I'd taken a codelab earlier that taught how to set up notifications with WorkManager. Albeit it was focused on immediate one-time requests, I was aware that WorkManager
had the facility of Deferred, Periodic work requests that could execute repeatedly at a fixed interval. I could add some initial delay to this to get the behavior that I wanted.
I started out by setting up a NotificationHelper
Kotlin singleton object, whose purpose was to create the notification channel (required on Android 8+), the PendingIntent
that'd launch the app when the notification was tapped, and the notification itself.
NotificationHandler
object NotificationHandler {
private const val CHANNEL_ID = "transactions_reminder_channel"
fun createReminderNotification(context: Context) {
// No back-stack when launched
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(context, 0, intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE
else PendingIntent.FLAG_UPDATE_CURRENT)
createNotificationChannel(context) // This won't create a new channel everytime, safe to call
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher_round)
.setContentTitle("Remember to add your transactions!")
.setContentText("Logging your transactions daily can help you manage your finances better.")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent) // For launching the MainActivity
.setAutoCancel(true) // Remove notification when tapped
.setVisibility(VISIBILITY_PUBLIC) // Show on lock screen
with(NotificationManagerCompat.from(context)) {
notify(1, builder.build())
}
}
/**
* Required on Android O+
*/
private fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Daily Reminders"
val descriptionText = "This channel sends daily reminders to add your transactions"
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
description = descriptionText
}
// Register the channel with the system
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
}
I'd planned for the createReminderNotification
method to be called from the Worker
when it was invoked. So, I started writing the implementation for the WorkManager
. Before we look at the code, this is what WorkManager
is, according to the AndroidX team:
WorkManager is the recommended solution for persistent work. Work is persistent when it remains scheduled through app restarts and system reboots. Because most background processing is best accomplished through persistent work, WorkManager is the primary recommended API for background processing.
WorkManager abstracts away the myriads of background processing APIs that Android has, including FirebaseJobDispatcher, GcmNetworkManager, and Job Scheduler. It provides one single surface, which then delegates work to these APIs depending on the context and the API level. These are the type of Work
requests that WorkManager
supports:
Its features cannot be described in a single article, so I hope the code and the comments will be able to convey the meaning of what's happening. To start with WorkManager
, we add this dependency to our build.gradle
(for Kotlin):
implementation "androidx.work:work-runtime-ktx:2.7.1"
ReminderNotificationWorker
These are requests that run at a fixed interval of TimeUnit
, such as Days, Hours, Minutes, etc. One important thing to note here is that WorkManager
doesn't guarantee that a Work
will be executed at the set time. It may be delayed due to battery optimizations and such, but it does guarantee that the Work
will be executed, sooner or later. I started out by extending the Worker
class to create a new Worker
whose doWork()
method is executed by the WorkManager
when it's fired.
class ReminderNotificationWorker(private val appContext: Context, workerParameters: WorkerParameters) : Worker(appContext, workerParameters) {
override fun doWork(): Result {
NotificationHandler.createReminderNotification(appContext)
return Result.success()
}
}
Whenever the ReminderNotificationWorker
is executed, it creates a new reminder notification. I didn't include any error handling here, which I plan on adding some time later.
After that, I defined a static, or as Kotlin likes to call it, companion object
method, called schedule
, which takes in a Context
, and the time at which the notification should be generated daily. This method also handles some cases like when the user selects a time that's before the current time, it delays the first firing until the next day. That may sound easy, but it was surprisingly complex to implement manually, and I resorted to the Calendar
class in the end.
companion object {
/**
* @param hourOfDay the hour at which daily reminder notification should appear [0-23]
* @param minute the minute at which daily reminder notification should appear [0-59]
*/
fun schedule(appContext: Context, hourOfDay: Int, minute: Int) {
log("Reminder scheduling request received for $hourOfDay:$minute")
val now = Calendar.getInstance()
val target = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hourOfDay)
set(Calendar.MINUTE, minute)
}
if (target.before(now)) {
target.add(Calendar.DAY_OF_YEAR, 1)
}
log("Scheduling reminder notification for ${target.timeInMillis - System.currentTimeMillis()} ms from now")
val notificationRequest = PeriodicWorkRequestBuilder<ReminderNotificationWorker>(24, TimeUnit.HOURS)
.addTag(TAG_REMINDER_WORKER)
.setInitialDelay(target.timeInMillis - System.currentTimeMillis(), TimeUnit.MILLISECONDS).build()
WorkManager.getInstance(appContext)
.enqueueUniquePeriodicWork(
"reminder_notification_work",
ExistingPeriodicWorkPolicy.REPLACE,
notificationRequest
)
}
}
We use UniquePeriodicWork
to ensure that there's ever only one instance of our ReminderNotificationWorker
in existence since I didn't want to annoy the user with improper notifications. The tag is used later to identify and cancel all the Work
requests should the user choose to opt out. I realized I'd need to handle the user preferences at this point, so I wrote a SharedPreferences
helper class that'd help us interact with the SharedPreferences
key-value store that Android provides.
PreferenceStore
class PreferenceStore(context: Context) {
private val sharedPref: SharedPreferences = context.getSharedPreferences("transactions_shared_pref", Context.MODE_PRIVATE)
/**
* @return the hour of the day in which the reminder should be shown, default is 22, if canceled -1
* @return the minute at which the reminder should be shown, default is 0, if canceled -1
*/
fun getReminderTime(): Pair<Int, Int> {
return Pair(
sharedPref.getInt(SHARED_PREF_REMINDER_HOUR, 22),
sharedPref.getInt(SHARED_PREF_REMINDER_MINUTE, 0)
)
}
fun setReminderTime(hour: Int, minute: Int) {
sharedPref.edit {
putInt(SHARED_PREF_REMINDER_HOUR, hour)
putInt(SHARED_PREF_REMINDER_MINUTE, minute)
}
}
fun cancelReminder() {
sharedPref.edit {
putInt(SHARED_PREF_REMINDER_HOUR, -1)
putInt(SHARED_PREF_REMINDER_MINUTE, -1)
}
}
fun isDefaultReminderSet() = sharedPref.getBoolean("is_default_reminder_set", false)
fun saveDefaultReminderIsSet() {
sharedPref.edit { putBoolean("is_default_reminder_set", true) }
}
}
When the app is launched for the first time, we set the default reminder at 10 PM, which is then customizable by the user. I probably could have removed some redundant methods at the bottom, but couldn't find a feasible solution. Now, let's move to the ViewModel.
MainViewModel
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val app = application
private val prefStore = PreferenceStore(application)
fun scheduleReminderNotification(hourOfDay: Int, minute: Int) {
prefStore.setReminderTime(hourOfDay, minute)
ReminderNotificationWorker.schedule(app, hourOfDay, minute)
}
fun getReminderTime() = prefStore.getReminderTime()
fun cancelReminderNotification() {
log("Cancelling reminder notification")
prefStore.cancelReminder()
WorkManager.getInstance(app).cancelAllWorkByTag(TAG_REMINDER_WORKER)
}
/**
* This sets the default time at the first launch of the app
*/
private fun checkAndSetDefaultReminder() {
if (!prefStore.isDefaultReminderSet()) {
scheduleReminderNotification(DEFAULT_REMINDER_HOUR, DEFAULT_REMINDER_MINUTE)
prefStore.saveDefaultReminderIsSet()
}
}
We extend the AndroidViewModel
class, since the application instance, or rather the Context
, is needed for initializing the database instance and the shared preferences store, as well as the WorkManager
. I plan on adding Dependency Injection using Hilt to the app soon, which would eliminate the need for this. In our ViewModel, the checkAndSetDefaultReminder
method is called every time the ViewModel is initialized. This is alright for now (or is it? :p) since we use only a single ViewModel shared across fragments because of Transactions being a relatively small app, but it'll soon be migrated to a more appropriate place like the Application class. The intent of the other methods should be clear enough by the names. Now, let's look at the BottomSheetDialogFragment
that allows the user to configure notifications:
NotificationsBottomSheet
class NotificationsBottomSheet : BottomSheetDialogFragment() {
private lateinit var binding: FragmentNotificationsBottomSheetBinding
private val viewModel by viewModels<MainViewModel>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentNotificationsBottomSheetBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setInitial()
binding.checkboxReminder.setOnCheckedChangeListener { buttonView, isChecked ->
if (isChecked) {
// Was previously removed by user, now user wants to re-enable.
viewModel.scheduleReminderNotification(DEFAULT_REMINDER_HOUR, DEFAULT_REMINDER_MINUTE)
binding.textViewReminderTime.isEnabled = true
binding.textViewReminderTime.text = get12HourTime(DEFAULT_REMINDER_HOUR, DEFAULT_REMINDER_MINUTE)
} else {
viewModel.cancelReminderNotification()
binding.textViewReminderTime.isEnabled = false
binding.textViewReminderTime.text = "Not set"
}
}
binding.textViewReminderTime.setOnClickListener {
val time = viewModel.getReminderTime()
val picker = MaterialTimePicker.Builder()
.setTimeFormat(TimeFormat.CLOCK_12H)
.setHour(time.first)
.setMinute(time.second)
.setTitleText("Select Reminder Time")
.build()
picker.show(parentFragmentManager, "notification-time-picker")
picker.addOnPositiveButtonClickListener {
val hour = picker.hour
val minute = picker.minute
viewModel.scheduleReminderNotification(hour, minute)
val newTime = viewModel.getReminderTime()
binding.textViewReminderTime.text = get12HourTime(newTime.first, newTime.second)
}
}
}
private fun setInitial() {
val setTime = viewModel.getReminderTime()
if (setTime.first == -1 || setTime.second == -1 ) {
binding.checkboxReminder.isChecked = false
binding.textViewReminderTime.isEnabled = false
binding.textViewReminderTime.text = "Not set"
} else {
binding.checkboxReminder.isChecked = true
binding.textViewReminderTime.isEnabled = true
binding.textViewReminderTime.text = get12HourTime(setTime.first, setTime.second)
}
}
}
Initial values are fetched and set from the Preferences. The user can enable or disable the notifications by clicking on the checkbox. Time can be configured by tapping the text and defaulting to the value of 10 PM. We use the Material TimePicker
widget to allow the user to select time easily, and it's also super simple to implement, compared to a separate TimerPickerDialog
, which is used elsewhere in the app.
At a fixed interval of every 24 hours, the ReminderNotificationWorker
is triggered by the WorkManager
. The worker, in turn, creates a notification (this being the Work
, tapping which takes the user directly to the app. This allows us to implement the features required above, while also keeping the implementation on the device, by leveraging the awesome AndroidX library that is WorkManager
.
Transactions is available on the Play Store, and the full source code is available on GitHub. Leave a thumbs up if you liked the article. Cheers!