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 and queue components in high-level design
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
Job Offers
// :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.
This article was originally published on proandroiddev.com