Implementing Periodic Notifications with WorkManager

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.

image.png

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:

image.png

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.

Screenshot_1649503731.png

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!