Chapter 4. Notifications sources
Intro
At this point we have an infrastructure to robustly show notifications. Now we are ready to declare ‘clients’ for this infrastructure. Initially we decided to support 3 type of notifications sources :
- Remote
- Triggered
- Scheduled
This final chapter is unveiling implementation details for them.
Notification sources components is high-level design
Remote
Remote notifications source
Remote notifications are traditionally managed by implementation of some Service from your push notification vendor. It could be Google with Firebase Cloud Messaging or Huawei with Huawei Messaging Service or any other vendor you prefer. It could be even your own in-house built service.
As a singular entry point to notifications framework is a NotificationQueue
, then we are pretty flexible to choose/switch any implementation details (like a cloud messaging vendor).
// :remote-notifications <1> class RemoteMessagingService : FirebaseMessagingService() { @Inject lateinit var notificationMetadataProvider: NotificationMetadataProvider @Inject lateinit var notificationQueue: NotificationQueue @Inject lateinit var context: Context override fun onMessageReceived(message: RemoteMessage) { val remoteData = message.data if (remoteData.isNotEmpty()) { // <2> val iconUrl = remoteData["icon"]!! val title = remoteData["titleText"]!! val description = remoteData["description"]!! val textColor = remoteData["fontColor"]!! val gradientStartColor = remoteData["bgColorFrom"]!! val gradientEndColor = remoteData["bgColorTo"]!! val action = when (remoteData["action"]) { "memory" -> ACTION_OPEN_MEMORY // <3> "battery" -> ACTION_OPEN_BATTERY "threats" -> ACTION_OPEN_THREAT_PROTECTION "antihack" -> ACTION_OPEN_ANTIHACK else -> null } val data = NotificationData( id = notificationMetadataProvider.getIdFor(this::class), // <4> channel = NotificationData.Channel.REMOTE, type = NotificationData.Type.SYSTEM, icon = NotificationData.Icon.Url(iconUrl), title = title, description = description, backgroundGradientStart = Color.parseColor(gradientStartColor), backgroundGradientEnd = Color.parseColor(gradientEndColor), textColor = Color.parseColor(textColor), action = action ) val loadIconRequest = ImageRequest.Builder(context) // <5> .data(iconUrl) .target { runBlocking(Dispatchers.IO) { notificationQueue.add(data) } } .build() ImageLoader(context).enqueue(loadIconRequest) } }
- Remote notification is placed in a separate module. I’m considering it as a standalone feature of app
- Parsing
remoteData
and extracting fields forNotificationData
- Action for each feature is declared in a dedicated feature module. See further
- Getting a stable notification Id
- As we getting url to icon from remote, it’s need to be loaded first, and only then we able to show notification
Updated integration structure
As actions, that representing navigation to particular feature are declared in proprietary feature module, then :remote-notifications
have to depend on it.
The common rule of scalability here is to prohibit features to depend on each other. Instead each feature could declare a thin API module with a very restricted amount of data. Usually it’s interfaces, constants, event buses etc.
Then the feature module declares its api module as an API configuration dependency to let its clients (which is typically :app
only) to know about it.
Finally features can depend on API modules or other features.
Triggered
Triggered notifications source
Triggered notifications are reactions at some events in the system. It could be:
- New app install
- Power Charging status changing
- Wi-Fi connection establishment
- any other events you can subscribe to.
Typically observers of these events are BroadcastReceivers
, but in the case of your own events — it can be implemented in different ways.
So there is no special abstractions for triggers. As a before, it’s needed only NotificationQueue
as a API of notifications framework.
Triggers can be either declared in a related feature module or even in a separate module if it contains a big piece of logic.
Scheduled
Scheduled notifications source
Notifications that are scheduled to show within some period. Also it is reasonable to apply some conditions to showing. Let’s consider a case, when in some interval each feature wants to show a notification that engages the user to interact with it. If a user interacts with a feature within this interval notification shouldn’t be shown.
Schedule
// :notifications-api interface NotificationSchedule { suspend fun update() // <1> suspend fun getLastUpdateTimestamp(): Long // <2> suspend fun getNotification(): NotificationData // <3> }
Job Offers
- API for updating a schedule
- We are returning last update timestamp from each schedule to have some singular logic (some
Scheduler
) that gathers all the timestamps and deciding which notifications should be shown - Notification to be shown to engage a user
Each feature that is interested in such a way of showing logic should provide an implementation of this interface:
// :battery internal val BATTERY_OPTIMIZED_AT_KEY = longPreferencesKey("battery_optimized_at") class BatteryNotificationsSchedule @Inject constructor( private val context: Context, private val preferences : DataStore<Preferences>, private val notificationMetadataProvider: NotificationMetadataProvider ) : NotificationSchedule { private val notification = NotificationData( id = notificationMetadataProvider.getIdFor(this::class), channel = NotificationData.Channel.SCHEDULED, type = NotificationData.Type.FULLSCREEN, icon = NotificationData.Icon.Res(R.drawable.ic_battery), title = context.getString(R.string.battery_scheduled_notification_title), description = context.getString(R.string.notification_description), action = ACTION_OPEN_BATTERY, ) override suspend fun update() { preferences.edit { it[BATTERY_OPTIMIZED_AT_KEY] = System.currentTimeMillis() // <1> } notificationQueue.remove(notification) // <2> } override suspend fun getLastUpdateTimestamp(): Long = preferences.data.first()[BATTERY_OPTIMIZED_AT_KEY] ?: 0L override suspend fun getNotification(): NotificationData = notification
- Storing last update timestamp in
SharedPreferences
- If the notification show was suspended (it came to the queue in the middle of the polling interval) and the schedule was updated before notification shown then we should remove notification from the queue since it’s not actual anymore.
Scheduler
Scheduler is a class which manages the interval of scheduling, collecting all the schedules and choosing which notification should be shown. Managing the interval can be implemented via AlarmLoopReceiver
.
// :notifications private const val ACTION_SCHEDULES_POLL = "schedule_poll" internal class NotificationsScheduler : AlarmLoopReceiver() { @Inject lateinit var schedules: Set<NotificationSchedule> // <1> @Inject lateinit var queue: NotificationQueue @Inject lateinit var config: NotificationsConfig @Inject override lateinit var alarmManager: AlarmManager override val loopPeriod: Long get() = config.schedulePeriod override val loopAction: String get() = ACTION_SCHEDULES_POLL override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) if (intent.action != ACTION_SCHEDULES_POLL) return runBlocking(Dispatchers.IO) { val notification = schedules .filter { System.currentTimeMillis() - it.getLastUpdateTimestamp() > loopPeriod } // <2> .map { it.getNotification() } .randomOrNull() // <3> if (notification != null) { queue.add(notification) } } loop(context) } }
- We have to provide a set of all schedules somehow. Some DI frameworks provide multibindings feature, some don’t and you have to provide this set to DI tree by hand
- Once within loopPeriod we are getting all schedules which update timestamp is too old
- And if there are few of them then choose randomly which notification should be shown
As a AlarmLoopReceiver
this scheduler will robustly starts at BOOT_COMPLETED
event or at app startup.
Conclusion
Notifications framework
Eventually, we came up with a notifications framework that focused mostly on reliability. Notifications are able to be queued, and the queue is persistent. Polling of the queue is starting at device boot or at app startup.
It’s able to show high-importance fullscreen notifications at different API levels.
It has a singular entry point that allows it to be abstracted from the implementation details of your app and just keep robustly doing its work — showing notifications.
This article was originally published on proandroiddev.com