Blog Infos
Author
Published
Topics
Published

 

Abstract

Showing notifications is a great mechanism for engaging users at mobile platforms. Smart management of notifications is benefitting products as gaining user retention. But it should be used carefully and not annoy a user because then he can either disable notifications for the current app or even uninstall the app completely.

The most common scenario for mobile platforms is showing push notifications that come from the server. But in addition, there is a scenario when notification logic is implemented at the client, which is not so well described in learning sources as remote notifications. This article series will focus on the technical details of client-based notifications.

Besides that, the series will cover integrating the suggested solution to the multi-module structure of a modern Android application.

Requirements

The solution should support 3 types of notifications:

  • Sent from FCM by server-side
  • Triggered by some events at the client
  • Scheduled at client

Some notifications has a high priority and demand immediate user attention.

Considering the fact that there are few sources of notifications that are not synchronized it should be a mechanism to throttle frequent notifications to not annoy a user.

Each notification type has its own notification channel. Also the amount of notifications should be reasonable and each source should have a stable notification id to prevent over bloating user attention with dozens of similar notifications.

Also, it’s nice to have a custom notification layout like this:

Custom notification layout with gradient background

Project structure

Consider a modern Android application codebase which is following to good scalability principles and takes care of low coupling and high cohesion.

For simplification let’s come up with 4 features for our app:

  • Memory optimization
  • Battery optimization
  • Checking for threats
  • Antihack protection

Hence, the project structure can be presented like a simple graph:

Simple modularized application layout

High-level design

As a high-level pattern, I’ve chosen a queue with a looper. I think it very naturally applies to existing requirements.

Looper and queue design

Take note that arrows at the scheme is a data flow and it’s not dependencies between components.

High-level implementation

In this section we’ll cover the structure of the integration to existing project and APIs of the most common components : Queue, Looper and the Data that should be passed between.

Integration structure

Notifications related core code will be placed in a separate :notifications module. I will contain Looper and Queue implementations, logic for building notifications and other low level (from end user perspective) things.

Considering the facts, that:

  • All features must be able to send notifications
  • :notifications is relatively big
  • Build performance is important

we don’t want all features modules to depend directly on:notifications module. So as a solution we’ll extract a reasonable API for sending notifications placed in another API module. And each feature that wants to show notifications must depend on this thin module. Let’s see adjusted project structure:

Integration structure

To keep attention on a file location, at the very top of each code snippet I’ll add a comment with the module it belongs to.

Data

We are already committed to having a queue of notifications. But let’s consider the case when the device is turned off in the middle of the throttling interval and there are waiting in queue notifications. In this case these notifications will be lost. As a solution we could persist notifications.

Notification persistence is an expression of its importance for me.

Generally, there are a few persistence mechanisms available in Android world:

  • Database
  • Shared preferences
  • Storing data in file directly

Each tool is applicable in different scenarios and use cases like:

  • Requirements to access frequency and speed
  • Data complexity and relations
  • Requirements to security

Considering the data structure I came up with and relatively rare access I’ve decided to use Shared Preferences for persistence and Kotlinx.serialization for serialization.

Your product requirements can be more strict (more frequent access or more complex data) or you can have some inherited solutions like Moshi/Gson/etc for serialization or some database/ORM already included in the project. So use common sense to make your solution native for your project.

// :notifications-api
@Serializable
data class NotificationData(
 val id: Int,
 val channel: Channel, // <1>
 val type: Type, // <2>
 val title: String,
 val description: String,
 val icon: Icon, // <3>
 @ColorInt val backgroundGradientStart: Int = Color.WHITE, // <4>
 @ColorInt val backgroundGradientEnd: Int = Color.WHITE,
 @ColorInt val textColor: Int = Color.BLACK,
 val action: String? = null // <5>
) : java.io.Serializable { // <6>

 @Serializable
 sealed class Icon : java.io.Serializable {

   @Serializable
   data class Res(@DrawableRes val resId: Int) : Icon()

   @Serializable
   data class Url(val url: String) : Icon()
 }

 enum class Type {
   FULLSCREEN, SYSTEM
 }

 enum class Channel(
   @StringRes val displayNameRes: Int,
   val importance: Int
 ) {

   SCHEDULED(
     R.string.notifications_scheduled_channel_display_name,
     NotificationManager.IMPORTANCE_HIGH
   ),
   REMOTE(
     R.string.notifications_remote_channel_display_name,
     NotificationManager.IMPORTANCE_HIGH
   ),
   TRIGGERED(
     R.string.notifications_triggered_channel_display_name,
     NotificationManager.IMPORTANCE_HIGH
   );
 }
}
  1. Channel is an abstraction over NotificationChannel.
  2. There are two types of notifications: Fullscreen shown as an activity over all UI including system one, and System shown as regular system notification.
  3. As an icon source we can have either remote url (using mostly remote notifications) or drawable resource(using by client notifications)
  4. Reasonable defaults for notification appearance
  5. String represented action to be invoked after click at notification. Normally this action is handled by the main (and single) activity by doing navigation to a particular screen.
  6. NotificationData must be able to be passed to Intent
Queue

Queue itself is a simple thing. It allows you to somehow add data and somehow to fetch it with some contract (FIFO). So let’s start with contract for notification queue:

// :notifications-api
interface NotificationQueue {

 suspend fun add(notification: NotificationData)

 suspend fun remove(notification: NotificationData)

 suspend fun poll() : NotificationData?

 suspend fun peek() : NotificationData?
}

 

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

NotificationQueue is a single way for features to show notification. For simplicity, poll() and peek() methods are part of the public API, but really we don’t want to expose these methods to the end users of NotificationQueue since polling and peeking will be encapsulated in :notifications module. As a alternative you could have more strict separation of concerns like:

// :notifications-api
interface NotificationQueue {

 suspend fun add(notification: NotificationData)

 suspend fun remove(notification: NotificationData)
}

and

// :notifications
interface NotificationQueueInternal : NotificationQueue {

 suspend fun poll() : NotificationData?

 suspend fun peek() : NotificationData?
}

Note the locations of files. I’d recommend this approach.

Looper

Looper is responsible for polling notifications from NotificationQueue. We made the queue persistent due to the importance of notifications for the project. Now we need robust mechanism to poll it which:

  • It should work always, even after reboot of device
  • The user shouldn’t be able to cancel it
  • Ideally, it should guarantee that notification will be polled even in Doze mode

Candidates:

WorkManager. First, correct and recommended solution for any periodic/long running job in Android.

Pros:

  • Idiomatic solution
  • Robust scheduling(work remains scheduled through app restarts and system reboots)
  • Built-in support of new SDK versions

Cons:

  • Minimal interval for periodic job is 15 minutes what is too much. Ideally 5 minutes polling interval should be supported
  • Adheres Doze mode and this behaviour is not configurable

Probably it’s possible to workaround minimal with recursive one-time jobs.

Foreground service. Solution if you want to perform operations that are noticeable to the user. Any behaviour can be implemented.

Pros:

  • Full control on implementation

Cons:

  • Visible for user
  • Services behaviour in Android system is changing with SDK versions
  • After reboot should be started with BroadcastReceiver
  • Looping countdown and other logic should be implemented by hands.

Starting with a receiver is not a big deal because eventually we will use it anyway. But visibility for users is not desirable.

AlarmManager. Solution for performing time-based operations outside the lifetime of your application.

Pros:

  • Operate outside of your application, so you can use them to trigger events or actions even when your app is not running, and even if the device itself is asleep
  • Not visible for user
  • No need to implement countdown logic
  • Has an API to perform action even in Doze mode

Cons:

  • API is using PendingIntent and not very fancy itself
  • BroadcastReceiver still needed to make a behaviour robust

We still need a receiver to start the loop. But at the same time AlarmManager operates with PendingIntent, so it seems possible to use one receiver for catching BOOT_COMPLETE events and as a target for PendingIntent from the alarm. Sounds good, so I’ve chosen this approach.

// :notifications-api
private const val ACTION_START_ALARM_LOOPER = "alarm.loopers.START"

fun Context.startAlarmLooper(loopReceiverClass: Class<out AlarmLoopReceiver>) { // <1>
  sendBroadcast(Intent(this, loopReceiverClass).apply {
    action = ACTION_START_ALARM_LOOPER
  })
}

abstract class AlarmLoopReceiver : BroadcastReceiver() {

  abstract val loopPeriod: Long

  abstract val loopAction: String

  abstract val alarmManager: AlarmManager

  @CallSuper // <2>
  override fun onReceive(context: Context, intent: Intent) {
    val isStartAction =
      intent.action == ACTION_START_ALARM_LOOPER || intent.action == Intent.ACTION_BOOT_COMPLETED // <3>
    val isLoopStarted = getLoopIntent(context, true) != null // <4>

    if (isStartAction && !isLoopStarted) {
      loop(context)
    }
  }

  protected fun loop(context: Context) {
    val intent = getLoopIntent(context, false)!! // <5>
    if (Build.VERSION.SDK_INT >= 31) { // <6>
      api31LoopInternal(intent)
    } else {
      vintageLoopInternal(intent)
    }
  }

  protected fun stop(context: Context) { // <9>
    alarmManager.cancel(getLoopIntent(context, false))
  }

  @SuppressLint("MissingPermission") // provided in :common:notifications
  private fun vintageLoopInternal(intent: PendingIntent) { // <7>
    alarmManager.setExactAndAllowWhileIdle(
      AlarmManager.RTC_WAKEUP,
      System.currentTimeMillis() + loopPeriod,
      intent
    )
  }

  @RequiresApi(Build.VERSION_CODES.S)
  private fun api31loopInternal(intent: PendingIntent) { // <8>
    if (alarmManager.canScheduleExactAlarms()) {
      vintageLoopInternal(intent)
    } else {
      alarmManager.setAndAllowWhileIdle(
        AlarmManager.RTC_WAKEUP,
        System.currentTimeMillis() + loopPeriod,
        intent
      )
    }
  }

  private fun getLoopIntent(context: Context, noCreate: Boolean): PendingIntent? = // <10>
    PendingIntent.getBroadcast(
      context,
      0,
      Intent(context, this::class.java).apply { action = loopAction },
      if (noCreate) PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_NO_CREATE else PendingIntent.FLAG_IMMUTABLE
    )
}
  1. API for run the looper
  2. It is mandatory for inheritors to call super.onReceive() since it provides basic logic for first loop launch.
  3. If the app just installed and launched — we call Context.startAlarmLooper() at the very beginning of app launch.
    If app was installed some time ago and device was rebooted — we want to start loopers with BOOT_COMPLETED action
  4. Checking whether PendingIntent for this looper exists. This is actually signals whether the looper was already launched or not. We don’t want to break the schedule of alarms.
  5. We are confident that intent is not null here due to passing noCreate = false
  6. API level 31 introduced new restrictions for exact alarms scheduling. In short after Android S we can’t schedule exact alarms without asking runtime permission. This article describes well the reasons and details of change.
    loop() implementation is scheduling exact alarms at older API levels, and using inexact alarms at newer API levels. It is not asking for runtime permission. Here are the details of how inexact alarms work.
  7. Exact alarm loop implementation for API level < 31
  8. Inexact alarm loop implementation for API level ≥ 31
  9. API for stop the loop by inheritors
  10. Creating intent with loop action targeted to inheritor receiver. FLAG_NO_CREATE is serving for check purposes — whether current PendingIntent exists or not.
Metadata

It’s important to provide some stable and conflict-free way to get an ids for notifications. Conflict-free is important option since we have a multi-module project structure and it can be challenging to keep ids unique across the modules. Uniqueness should be provided within a single notification source.

// :notification-api
interface NotificationMetadataProvider {

 fun getIdFor(source: KClass<*>): Int
}

And at application level we can define it even anonymously:

// :app
class AppModule {

 @Provides
 @Singleton
 fun notificationMetadataProvider(): NotificationMetadataProvider =
  object : NotificationMetadataProvider {

   private val sourcesToIds = mapOf(
     BatteryNotificationsSchedule::class to 1122,
     AntihackNotificationsSchedule::class to 2233,
     MemoryNotificationsSchedule::class to 3344,
     ThreatsNotificationsSchedule::class to 4455,
     RemoteMessagingService::class to 5566,
     TreatProtectionNotificationsTrigger::class to 6677,
     MemoryNotificationsTrigger::class to 7788,
     BatteryNotificationsTrigger::class to 8899
   )

    override fun getIdFor(source: KClass<*>): Int =
      sourcesToIds[source] ?: throw IllegalArgumentException()
  }
}
Conclusion

We chose a high-level approach for client-scheduled notifications and defined the contracts for Queue and Looper. In the next chapter, we’ll cover implementation details for it.

This article was originally published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
In Chapter 1 we have chosen a Looper and Queue as a high-level design…
READ MORE
blog
In Chapter 2 we provided a robust mechanism for queueing notifications. The system is…
READ MORE
blog
At this point we have an infrastructure to robustly show notifications. Now we are…
READ MORE
Menu