Scheduling notifications in Android
Chapter 2. Looper and Queue

Intro
In Chapter 1 we have chosen a Looper and Queue as a high-level design for notifications framework and declared the contracts and public API for it. This chapter is focused on Looper and Queue implementation details.

Looper
// :notifications
internal const val ACTION_NOTIFICATION_POLL = "notification_poll" // <1>
internal class NotificationPoller : AlarmLoopReceiver() {
@Inject
lateinit var config: NotificationsConfig // <2>
@Inject
lateinit var queue: NotificationQueue
@Inject
override lateinit var alarmManager: AlarmManager
override val loopPeriod: Long
get() = config.period
override val loopAction: String
get() = ACTION_NOTIFICATION_POLL
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action != ACTION_NOTIFICATION_POLL) return
runBlocking(Dispatchers.IO) {
val data = queue.poll()
if (data != null) { // <3>
showNotification(data)
loop(context)
} else {
stop(context)
}
}
}
private fun showNotification(data: NotificationData) {
// Chapter 3
}
}
- Action for alarm intent that describing notification poll
- Simple interface holding different notifications related values
- If we poll some data from the queue then we show it and run the loop iteration. Otherwise the queue is empty and we stop the loop.
Register this receiver in AndroidManifest.xml
// :notifications
<receiver
android:name=".NotificationPoller"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
It’s important for any AlarmLoopReceiver
to be exported because alarms are operating outside the lifetime of application. It is a possible security vulnerability that you should be aware of.
Queue
Chapter 1 is defined a queue contract:
// :notifications-api
interface NotificationQueue {
suspend fun add(notification: NotificationData)
suspend fun remove(notification: NotificationData)
suspend fun poll() : NotificationData?
suspend fun peek() : NotificationData?
}
Also there are commitments that queue should be persistent and SharedPreferences
was chosen as a persistence provider.
// :notifications
private val NOTIFICATIONS_KEY = stringPreferencesKey("notifications") // <1>
class PersistentNotificationQueue @Inject constructor(
private val store : DataStore<Preferences>
) : NotificationQueue {
override suspend fun peek(): NotificationData? { // <2>
val notifications = loadNotifications()
return notifications.firstOrNull()
}
override suspend fun poll(): NotificationData? { // <3>
val notifications = loadNotifications()
val first = notifications.firstOrNull()
if (first != null) {
store.editNotifications { it.toMutableList().apply { remove(first) } }
}
return first
}
override suspend fun add(notification: NotificationData) {
store.editNotifications { it.toMutableList().apply { add(notification) } }
}
override suspend fun remove(notification: NotificationData) {
store.editNotifications { it.toMutableList().apply { remove(notification) } }
}
private suspend fun DataStore<Preferences>.editNotifications(transform: (List<NotificationData>) -> List<NotificationData>) {
val oldNotifications = loadNotifications()
val newNotifications = transform(oldNotifications)
edit { preferences ->
preferences[NOTIFICATIONS_KEY] = Json.encodeToString(newNotifications)
}
}
private suspend fun loadNotifications(): List<NotificationData> { // <4>
val notificationsJson = store.data.first()[NOTIFICATIONS_KEY]
return if (notificationsJson == null) emptyList()
else Json.decodeFromString(notificationsJson)
}
}
- Notifications storing as a
Json
string in preferences Peek()
returns the first element of the queue but doesn’t remove it.Poll()
returns the first element of the queue and removes it.- Operating over a list and emulating a queue as an access contract. It’s easier.
Now consider the case, when notification was added to an empty queue, and previous notification was shown later than throttling interval. In this case notification should be shown immediately.
This logic could be done via subscription to preferences and placed in NotificationPoller
, but I placed this logic to queue directly. I’m using delegation to not overcomplicate logic in PersistantNotificationQueue
:
// :notifications
private val LAST_NOTIFICATION_POLL_KEY = longPreferencesKey("last_notification_poll") // <1>
class DefaultNotificationQueue @Inject constructor(
private val delegate: NotificationQueue, // <2>
private val config: NotificationsConfig,
private val store : DataStore<Preferences>
) : NotificationQueue by delegate {
override suspend fun poll(): NotificationData? {
val data = delegate.poll()
if (data != null) {
updateLastNotificationPoll() // <3>
}
return data
}
override suspend fun add(notification: NotificationData) {
val lastNotificationPoll = preferences.data.first()[LAST_NOTIFICATION_POLL_KEY] ?: 0
val isImmediate =
System.currentTimeMillis() - lastNotificationPoll > config.period
if (delegate.peek() == null && isImmediate) { // <4>
val intent = Intent(context, NotificationPoller::class.java).apply {
action = ACTION_NOTIFICATION_POLL
}
delegate.add(notification)
context.sendBroadcast(intent)
} else {
delegate.add(notification)
}
}
private suspend fun updateLastNotificationPoll() {
preferences.edit { preferences ->
preferences[LAST_NOTIFICATION_POLL_KEY] = System.currentTimeMillis()
}
}
}
- Storing timestamp of last notification poll event
- As a
PersistentNotificationQueue
- If notification was polled then we updating last poll timestamp
- Condition for showing notification immediately
Conclusion
This chapter covered implementation details for Looper and Queue. Queue of notifications now is persistent that allows not to lose notifications after device reboot. Looper logic is also robust and launches after device reboot and reasonable in terms of system resources — it doesn’t loop if Queue is empty.
Next chapter will cover notifications appearance.