On of the ways to drive engagement with a mobile application is through the use of notifications. This tutorial will go through the basics of creating an app which can initially create notifications which appear instantly, then using a started service, delay those notifications by a specified period of time
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
binding
for MainActivity in the usual wayfun setupPermissions(){
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()){
permitted: Boolean ->
if (!permitted){
Toast.makeText(this,"Permission not granted, this app will not function", Toast.LENGTH_LONG).show()
}
}
if (ContextCompat.checkSelfPermission(this,android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
}
}
onCreate
method<?xml version="1.0" encoding="utf-8" ?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText android:id="@+id/etMessage"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_marginBottom="16dp"
android:layout_marginEnd="16dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:ems="10"
android:hint="e.g. Call Bob"
android:inputType="textMultiLine"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:layout_marginTop="16dp"
android:text="Alert Me"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/etMessage" />
<EditText android:id="@+id/etHours"
android:layout_width="60dp"
android:layout_height="50dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:ems="10"
android:hint="Hours"
android:inputType="number"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/etMessage" />
<EditText android:id="@+id/etMinutes"
android:layout_width="80dp"
android:layout_height="50dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:ems="10"
android:hint="Minutes"
android:inputType="number"
app:layout_constraintStart_toEndOf="@+id/etHours"
app:layout_constraintTop_toBottomOf="@+id/etMessage" />
</android.support.constraint.ConstraintLayout>
fun scheduleAlert(view: View){
}
val message = binding.etMessage.text.toString()
val hours = binding.etHours.text.toString().toLongOrNull() ?: 0
val mins = binding.etMinutes.text.toString().toLongOrNull() ?: 0
val delay = (hours * 60) + mins
Note the use of the Elvis operator (?:
) which will set the value of the hours and mins variables to 0 if the conversion of the value in them results in null being returned.
Initially, it would be possible to put all the logic for creating notifications in the Activity, however later we will want notifications to be fired by a service (i.e. when the app is not running), so this would cause us problems. Also, by creating a class whose responsibility is to create a notification (rather than to also collect the information for the notification from the user) we are adhering to the Single Responsibility Principle
class Notifier(val context: Context) {
}
You'll need to add an import for android.content.Context
<string name="channelName">Reminders</string>
<string name="channelDescription">Reminders you\'ve added</string>
private fun createNotificationChannel(){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelName = context.getString(R.string.channelName)
val channelDescription = context.getString(R.string.channelDescription)
val channelId = channelName
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, channelName, importance).apply {
description = channelDescription
}
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
Looking through the code we first create variables to represent the channels name, description, provide an Id unique to the app (which is a string) and specify the importance of notifications provided by the channel. We then create the channel object, get a reference to the NotificationManager and register the channel with itcreateNotificationChannel()
to itThe next stage is to add a method which will create and return a Notification object.
private fun notification(title: String, content: String) : Notification {
}
notification
method to create a notification:val builder = NotificationCompat.Builder(context, context.getString(R.string.channelName))
builder.setSmallIcon(R.drawable.baseline_done_outline_black_18)
builder.setContentTitle(title)
builder.setContentText(content)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
return builder.build()
To send a notification, we use a notification manager, and provide it with a notification, and an Id. The id should be unique, and could be used to update a notification in future. This example uses a hashcode of the title to generate an Id, which, whilst uniqueness of a hashcode is not guaranteed, given the relatively low number of notifications the user is likely to set, the probability of two notifications with different titles having the same hashcode will be low, so it will suffice in this instance
fun sendNotification(title: String, content: String){
val notification = notification(title, content)
with(NotificationManagerCompat.from(context)){
val notificationId = title.hashCode() //pretty much unique
notify(notificationId, notification)
}
}
scheduleAlert
method add the following code:
val notifier = Notifier(this)
notifier.sendNotification(message,"")
Note that the content part of the notification is not used - you can add text here if you want to see how the notification content can appearCurrently, if you tap on a notification, nothing will happen. To remedy this, we'll create a new activity, which displays a textView full screen
onCreate
method which will check for an intent extra called "title" and use it to set the message (assuming it's found):binding.tvMessage.text = intent.getStringExtra("title") ?: ""
Note the use of the Elvis operator again, (?:
) which will set the value of the text property to an empty string if the getStringExtra()
method returns null notification
method, add the following code: val intent = Intent(context, ViewReminderActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
intent.putExtra("title",title)
Note that the flag being set ensures that a new instance of the activity is created, that way if another notification is open in the app, when a new one is opened from the notification, the old one will be revealed after the user presses the back buttonval requestCode = title.hashCode()
val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getActivity(context,requestCode, intent, flag)
This time, we specify a unique(ish) request code based on the notification title, and create a PendingIntent object which contains the intent, as well as a flag which means that if we tried to create the pending intent again (i.e. with the same request code), any intent extras would be replacedbuilder.setContentIntent(pendingIntent)
The main flaw in our application so far, is that the notifications fire immediately. Typically we want to be reminded of something later, because we don't want to, or can't, do it now. We also need to consider that a user may leave our application, so we cant just add a thread or a runner to the Activity or Notifier class. Instead we need to create a Service
onBind
and a started service, which doesn't. Whilst we will create a started service, we still need to specify that the onBind method returns null, however if we modify the code (likely showing as a TODO
at this point, it will complain because it expects an IBinder object, and Kotlin won't allow as to return null if the return type is not nullable. To remedy this, modify the return type of the method to return a Nullable IBinder object (i.e. by adding a question mark) and change the body of the method to return null.onStartCommand
method. The code completion will help you do this with the required parameters (intent, flags and startId)return START_STICKY
This ensure that it will continue running unless the user terminates it manually or the device runs out of resources to keep it running, which, hopefully, it won'tonStartCommand
method will be responsible for creating the notification via the Notifier class, so it will need the various parameters, unfortunately, however, we can't adjust the method signature, so the title, content text (which we're not using, but could do in future) and the delay values all need to be passed via intent extras. Add the following lines of code which extract these values from an intent:val title = intent?.getStringExtra("title") ?: ""
val content = intent?.getStringExtra("content") ?: ""
val delayMins = intent?.getLongExtra("delay", 0) ?: 0L
val runner = Runnable {
val notifier = Notifier(this)
notifier.sendNotification(title, content)
}
Note that a Service also inherits from Context, so we can pass this
to the Constuctor of Notifier, in the same way we could when creating a notifier in an ActivitypostDelayed
method, passing in the runner and the delay as follows:val handler = Handler(Looper.getMainLooper())
handler.postDelayed(runner,1000*delayMins)
//TODO: multiply by 60 after testing
val intent = Intent(this, NotificationService::class.java)
intent.putExtra("title",message)
intent.putExtra("delay", delay)
startService(intent)
Note that we could specify an extra for the Notification content, but as we don't have anything to put in it, we have omitted itbinding.etHours.text.clear()
binding.etMinutes.text.clear()
binding.etMessage.text.clear()
Toast.makeText(this,"Reminder to $message set in $delay minutes",Toast.LENGTH_SHORT).show()
//TODO:
, so that a minute represents a minute, rather than a second and test the application one final timeA completed version of this project can be downloaded from Github