Our migration story to Android O

Since August 2017, Google released Android Oreo (8.0) with API 26. At BlaBlaCar, we believe it is important to keep our targetSdkVersion as up to date as possible:

  • It makes it possible to use the most recent APIs like notification channels or fonts in XML
  • It is important for security reasons, the latest APIs being optimized for security and performance
  • And finally, it includes improvements to avoid excessive use of battery and memory, allowing for a better user experience.

We released a version of our app with targetSdkVersion set to 26 around mid December 2017, last year.

A couple of days later, Google announced that all published applications on the Play Store will be required to target the most recent API level:

In the second half of 2018, Play will require that new apps and app updates target a recent Android API level. This will be required for new apps in August 2018, and for updates to existing apps in November 2018. This is to ensure apps are built on the latest APIs optimized for security and performance.

For more information see, Improving app security and performance on Google Play for years to come by Google

All new versions of Android come with some breaking changes and this one is no exception. There are two in particular in Android O:


Background execution limits

One of the biggest changes relates to background processing limitations. When an application uses too many resources, it can lead to a poor user experience, from sluggish to the inability to interact with the app. To prevent this kind of behavior, Android O forces some restrictions on background services and broadcast receivers.

In this article I will focus on how we transition/migrate the BlaBlaCar application, but you can use services and broadcast receivers for many purposes. For more information, checkout the migration guide and this article from Joe Birch.

Background services

With Android O, you can no longer use startService() to start a service while your app is not in the foreground. If you do this, the system will throw an IllegalStateException.

Our app before we fixed background services

To work around this limitation, a few options can be considered. Foreground services and scheduled jobs are the ones we used. You can choose the solution that fits your need the best.

  • First option: use a foreground service

One of the services we use in our application is for uploading user profiles and car pictures. This is the type of service that can be promoted to the foreground, because it is the consequence of a user action, for which the progress can be seen in a notification.

This is what our method looked like before:

private fun sendPicture(bitmap: Bitmap) {
...
val pictureUploadIntent = Intent(context, UploadPictureService::class.java)
pictureUploadIntent.putExtra(EXTRA_PICTURE_FILE, file)
startService(pictureUploadIntent)
...
}

This service displays a push notification to explain that the upload started, and uploads the photo.

class UploadPictureService : Service(){

override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {

// display notification to show that the loading is starting
sendNotification()

compositeDisposable.add(repository.changeProfilePicture(file)
.subscribeOn(ioScheduler)
.observeOn(scheduler)
.subscribe({ onSuccess() }, { onError(it) }))
}

fun onSuccess() {
// Do things in case of success
stopSelf()
}

fun onError(error: Throwable) {
// Do things in case of error
stopSelf()
}
}

There is one caveat though. If the app is in the background when startService() is executed, the app will crash.

Even if the app is in the foreground but moves into the background just after, the service will be killed after a small amount of time even if the task is not finished.

We now need to use startForegroundService() instead of startService().

This is our method today. If the SDK version is Android Oreo or above, we start a foreground service.

private fun sendPicture(bitmap: Bitmap) {
...
val pictureUploadIntent = Intent(context, UploadPictureService::class.java)
pictureUploadIntent.putExtra(EXTRA_PICTURE_FILE, file)
startUploadPictureService(pictureUploadIntent)
...
}

private fun startUploadPictureService(pictureUploadIntent: Intent) {
ContextCompat.startForegroundService(this, pictureUploadIntent)
}

And our service looks like that now. It is launched as a foreground service, and the notification is automatically cancelled when the upload is finished.

class UploadPictureService : Service() {

override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {

startUploadForegroundService()
compositeDisposable.add(repository.changeProfilePicture(file)
.subscribeOn(ioScheduler)
.observeOn(scheduler)
.subscribe({ onSuccess() }, { onError(it) }))
}

fun startUploadForegroundService() {
val notification = NotificationCompat.Builder(context, channel_id)
.setContentText("Uploading photo…")
.setOngoing(true)
.setAutoCancel(false)

.setProgress(0, 0, true)
.build()
startForeground(1, notification)
}

fun onSuccess() {
// Do things in case of success
stopSelf()
}

fun onError(error: Throwable) {
// Do things in case of error
stopSelf()
}
}
  • Second option: use a Job scheduler

We had another service that sends the push notification token of the user to our backend when the user logged in or when the push token was refreshed.

When the token is updated, onTokenRefresh() is triggered. This may occur if the security of the previous token had been compromised, or when it is initially generated.

InstanceIDListenerService.kt (before migration)

class InstanceIDListenerService: FirebaseInstanceIdService(){
override fun onTokenRefresh() {
startService(Intent(this, SendPushTokenIntentService::class.java))
}
}

And SendPushTokenIntentService.kt

class SendPushTokenIntentService : IntentService() {

override fun onHandleIntent(intent: Intent) {
val token = firebaseInstanceId.getToken()
sendToken(token)
}
}
}

If onTokenRefresh() is called when the app is not on the foreground, the app will crash with an IllegalStateException, like in the previous example.

Our first attempt was to use FirebaseJobDispatcher library.

class InstanceIDListenerService : FirebaseInstanceIdService() {
internal val firebaseJobScheduler:AppFirebaseJobScheduler

override fun onTokenRefresh() {
...
firebaseJobScheduler.schedule(TAG_SEND_PUSH_TOKEN, SendPushTokenIntentJobService::class.java)
}
}

Our AppFirebaseJobScheduler creates a Job from a given JobService, then schedules it.

import com.firebase.jobdispatcher.FirebaseJobDispatcher
import com.firebase.jobdispatcher.Job
import com.firebase.jobdispatcher.JobService

class AppFirebaseJobScheduler constructor(private val firebaseJobDispatcher: FirebaseJobDispatcher) {

private fun createJob(tag: String, cls: Class<out JobService>): Job =
firebaseJobDispatcher.newJobBuilder()
.setService(cls).setTag(tag).build()


fun schedule(tag: String, cls: Class<out JobService>) {
val jobToSchedule = createJob(tag, cls)
firebaseJobDispatcher.mustSchedule(jobToSchedule)
}
}

For the case of the push token, the jobService is responsible for starting SendPushTokenIntentService, which sends the new push token to our backend.

import com.firebase.jobdispatcher.JobService

class SendPushTokenIntentJobService : JobService() {

override fun onStartJob(job: JobParameters): Boolean {
startService(Intent (this, SendPushTokenIntentService))
return false // the job is finished
}
}

Unfortunately, shortly after integrating Firebase JobDispatcher we noticed a spike in the number of ANRs. A quick analysis of the Trace showed that Firebase JobDispatcher was causing the application to hang randomly when scheduling a new job. If you are curious to know more about how and why, you can check the issue “Deadlock occurring when scheduling a job” that we created on their GitHub page.

Given this issue, we decided to try another library to schedule those jobs. We settled on AndroidJob library from Evernote because it uses different implementations depending on the underlying Android version (JobScheduler, GcmNetworkManager or AlarmManager).

This is how we use it:

class InstanceIDListenerService : FirebaseInstanceIdService() {
override fun onTokenRefresh() {
...
SendTokenJob.schedule()
}
}

SendTokenJob.schedule() is also called when the user logs in and logs out. It schedules the job service below, which sends the push token to our backend and Braze.

class SendTokenJob : Job() {

@Inject
internal lateinit var firebaseInstanceId: FirebaseInstanceId

companion object {
fun schedule(): Int {
return JobRequest.Builder(TAG_JOB)
. // add options
.schedule()
}
}

@SuppressLint("CheckResult")
@WorkerThread
override fun onRunJob(params: Job.Params): Job.Result {
...
val token = firebaseInstanceId.token
token?.let {
try {
sendToken(token)
} catch (e: Exception) {
return Job.Result.RESCHEDULE
}
} ?: return Job.Result.RESCHEDULE
return Job.Result.SUCCESS
}
}

There are other ways to use services with Android 8.0, like using FCM to send a notification with high priority in order to trigger a service. You can find out more on Exploring Background Execution Limits on Android Oreo by Joe Birch.

Broadcast receivers

Implicit broadcast receivers defined in the manifest will not be triggered anymore. Since Android O, you need to register them programmatically within your class, use a job scheduler like described above for services or, if your broadcast receiver is in this whitelist, you don’t need to change anything.

In the BlaBlaCar application, we use it mainly for pending intent for actions on push notifications.

We noticed that if you define your pending intent like this:

val seeBookingIntent = Intent()
seeIntent.setAction(INTENT_SEE_BOOKING_ACTION)
return PendingIntent.getBroadcast(context, requestCode, seeBookingIntent, PendingIntent.FLAG_ONE_SHOT)

it doesn’t work anymore with Android O if you register the broadcast in your manifest. You need to precise the name of your receiver.

Note that you shouldn’t use it like in the example above because of security reasons, even before Android O. In this configuration, any app could listen to this INTENT_SEE_BOOKING_ACTION.

val seeBookingIntent = Intent(context, DriverApprovalBroadcastReceiver.class)
seeIntent.setAction(INTENT_SEE_BOOKING_ACTION)
return PendingIntent.getBroadcast(context, requestCode, seeBookingIntent, PendingIntent.FLAG_ONE_SHOT)

Notification channels

The last topic we had to handle for this migration was adding support for push notification channels.

From now on, each notification should belong to a channel. The user can enable or disable notifications per channel rather than doing it for all of them at once.

In the BlaBlaCar application, we support two channels. One for transactional push notifications like when you receive a message or a booking request, and the other for marketing.

You can put everything in only one channel, but the risk is that the user disables all app notifications. Some notifications, like our transactional ones, are critical to our product. That’s why we put them in another channel, with a higher priority.

When you create a push notification, you now need to specify the ID of the channel.

NotificationCompat.Builder(context, CHANNEL_MARKETING_ID).build()

To define your channel(s):

val marketingChannel = NotificationChannel(CHANNEL_MARKETING_ID, "News, fun and rewards", NotificationManager.IMPORTANCE_DEFAULT)
newChannel.description = "Notifications about news, fun and rewards such as our Ambassador program."
newChannel.enableVibration(true)
val notificationManager = getNotificationManager()
notificationManager.createNotificationChannel(marketingChannel)

We do this at the launch of the app, in order to let the user manage channels at any time, even before he received a notification from this channel.
If there is already a channel created with this ID, it will be ignored except if you want to update an information like the name.


That’s it about our journey to Android Oreo! I hope this article will help you if you are going through this transition, or if you intend to do it soon.

If you have done it already, feel free to share some comments and feedback.