SPI in Android

SPI is a standard API in the JVM world but it is not used in Android applications very often. Let’s see how it can be used.

Artsem Kurantsou
Make Android
7 min readDec 31, 2023

--

Photo by Victor Serban on Unsplash

Service Provider Interface (SPI) is an API that allows one to find implementations of some interface in the application. This mechanism is very useful for the plugin architecture.

SPI is a standard API in the JVM world but it is not used in Android applications very often. Let’s see how it can be used.

The problem

In Android, we have different providers for the push functionality and on large projects we can’t use a single implementation. As an example, the following providers are available:

  • FCM (Firebase Cloud Messaging) — main implementation of the push services. However, this implementation is not available on all devices as requires Google services
  • ADM (Amazon Device Messaging) — implementation of the push services on Amazon devices (Kindle devices). This implementation is available and works on Amazon devices only.
  • HCM (Huawei Cloud Messaging) — implementation of the push services on the Huawei devices.
  • Baidu (Baidu Push SDK) — implementation of the push services from Baidu. Mostly used for apps in China region.

With such a variety of services, it becomes hard to keep track of them and their initialization.

The problem becomes even harder when we need to provide different application builds with different sets of services. Here are a few examples that can demonstrate the issue:

  • Google Play Console won’t let you publish an application if it contains Baidu services. So, Baidu services should be included only in builds that target China.
  • Amazon Device Messaging is only available on Amazon devices so it makes sense to include it in a build for the Amazon app store only.
  • Huawei implementation makes sense in the builds for the Huawei store.

Solution

As a first step, we need to create an abstraction for push service implementations. This should be done in a separate Gradle module so we can add a dependency on it in the implementation modules.

Abstraction

A common interface for the push service can look something like the following:

package com.kurantsov.pushservice

import android.content.Context

/**
* Interface used to provide push service implementation via SPI
*/
interface PushService {
/**
* Type of the push service implementation
*/
val type: PushServiceType

/**
* Priority of the push service implementation
*/
val priority: PushServicePriority

/**
* Returns if the push service implementation is available on the device
*/
fun isAvailable(context: Context): Boolean

/**
* Initializes push service
*/
fun initialize(context: Context)
}

/**
* Describes type of the push service implementation
*/
interface PushServiceType {
val name: String
val description: String
}

sealed class PushServicePriority(val value: Int) {
object High : PushServicePriority(0)
object Medium : PushServicePriority(1)
object Low : PushServicePriority(2)
}

Implementation

Then we can implement a common interface based on the push service providers.

To do this we can create a Gradle module for each of the implementations.

Firebase Cloud Messaging implementation example:

package com.kurantsov.pushservice.firebase

import android.content.Context
import android.util.Log
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.firebase.ktx.Firebase
import com.google.firebase.messaging.ktx.messaging
import com.kurantsov.pushservice.PushService
import com.kurantsov.pushservice.PushServiceManager
import com.kurantsov.pushservice.PushServicePriority
import com.kurantsov.pushservice.PushServiceType

class FirebasePushService : PushService {
override val type: PushServiceType = FirebasePushServiceType
override val priority: PushServicePriority = PushServicePriority.High

override fun isAvailable(context: Context): Boolean {
val availability =
GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
return availability == ConnectionResult.SUCCESS
}

override fun initialize(context: Context) {
Firebase.messaging.token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w(TAG, "Fetching FCM registration token failed", task.exception)
}

val token = task.result

PushServiceManager.setPushToken(token, FirebasePushServiceType)
}
}

private companion object {
const val TAG = "FirebasePushService"
}
}

object FirebasePushServiceType : PushServiceType {
override val name: String = "FCM"
override val description: String = "Firebase"
}

Amazon Device Messaging implementation example:

package com.kurantsov.pushservice.amazon

import android.content.Context
import com.amazon.device.messaging.ADM
import com.kurantsov.pushservice.PushService
import com.kurantsov.pushservice.PushServicePriority
import com.kurantsov.pushservice.PushServiceType

/**
* Amazon device messaging implementation of the push service
*/
class AmazonPushService : PushService {
override val type: PushServiceType = AmazonPushServiceType
override val priority: PushServicePriority = PushServicePriority.High

override fun isAvailable(context: Context): Boolean {
return isAmazonServicesAvailable
}

override fun initialize(context: Context) {
val adm = ADM(context)
adm.registrationId?.let { token ->
handleRegistrationSuccess(token)
} ?: run {
adm.startRegister()
}
}
}

object AmazonPushServiceType : PushServiceType {
override val name: String = "ADM"
override val description: String = "Amazon"
}

/**
* Returns if amazon device messaging is available on the device
*/
val isAmazonServicesAvailable: Boolean by lazy {
try {
Class.forName("com.amazon.device.messaging.ADM")
true
} catch (e: ClassNotFoundException) {
false
}
}

Implementation registration

To make the implementations “discoverable” via SPI, we need to register them. This is done by adding a fully qualified name of the implementation in the META-INF/services/{fully qualified name of the interface}. This needs to be done in each module that provides implementation of the interface.

Example of the file content for Firebase implementation:

com.kurantsov.pushservice.firebase.FirebasePushService

Please note that the full path for the services folder to be included in the result aar for the module is the following: {module path}/src/main/resources/META-INF/services

SPI registration example in Android Studio Project view

Usage

The final step is to use interface implementations. Here is an example of the SPI usage:

import java.util.ServiceLoader

private fun listImplementations(context: Context) {
//Loading push service implementations
val serviceLoader = ServiceLoader.load(PushService::class.java)
//Logging implementations
serviceLoader
.sortedBy { pusService -> pusService.priority.value }
.forEach { pushService ->
val isAvailable = pushService.isAvailable(context)
Log.d(
TAG, "Push service implementation - ${pushService.type.description}, " +
"available - $isAvailable"
)
}
}

Example of the output:

Push service implementation - Firebase, available - true
Push service implementation - Amazon, available - false
Push service implementation - Huawei, available - true
Push service implementation - Baidu, available - true

The full code can be found on my GitHub.

Bonus

PushServiceManager

A bit more “real-world” example of the push service manager:

package com.kurantsov.pushservice

import android.content.Context
import android.util.Log
import java.util.ServiceLoader
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicBoolean

object PushServiceManager {
private const val TAG = "PushServiceManager"
var pushToken: PushToken = PushToken.NotInitialized
private set

private val isInitialized: AtomicBoolean = AtomicBoolean(false)
private val tokenChangedListeners: MutableSet<OnPushTokenChangedListener> =
CopyOnWriteArraySet()
private var selectedPushServiceType: PushServiceType? = null

fun initialize(context: Context) {
if (isInitialized.get()) {
Log.d(TAG, "Push service is initialized already")
return
}
synchronized(this) {
if (isInitialized.get()) {
Log.d(TAG, "Push service is initialized already")
return
}
performServiceInitialization(context)
}
}

private fun performServiceInitialization(context: Context) {
//Loading push service implementations
val serviceLoader = ServiceLoader.load(PushService::class.java)
val selectedImplementation = serviceLoader
.sortedBy { pusService -> pusService.priority.value }
.firstOrNull { pushService ->
val isAvailable = pushService.isAvailable(context)
Log.d(
TAG, "Checking push service - ${pushService.type.description}, " +
"available - $isAvailable"
)
isAvailable
}
if (selectedImplementation != null) {
selectedImplementation.initialize(context)
selectedPushServiceType = selectedImplementation.type
isInitialized.set(true)
Log.i(TAG, "Push service initialized with ${selectedImplementation.type.description}")
} else {
Log.e(TAG, "Push service implementation failed. No implementations found!")
throw IllegalStateException("No push service implementations found!")
}
}

/**
* Adds listener for the push token updates. Called immediately if token is available
* already.
*/
fun addOnPushTokenChangedListener(listener: OnPushTokenChangedListener) {
tokenChangedListeners.add(listener)
val currentToken = pushToken
if (currentToken is PushToken.Initialized) {
listener.onPushTokenChanged(currentToken)
}
}

/**
* Removes listener for the push token updates.
*/
fun removeOnPushTokenChangedListener(listener: OnPushTokenChangedListener) {
tokenChangedListeners.remove(listener)
}

/**
* Called by push service implementation to notify about push token change.
*/
fun setPushToken(token: String, serviceType: PushServiceType) {
if (selectedPushServiceType != serviceType) {
Log.w(TAG, "setPushToken called from unexpected implementation. " +
"Selected implementation - ${selectedPushServiceType?.description}, " +
"Called by - ${serviceType.description}")
return
}
val initializedToken = PushToken.Initialized(token, serviceType)
this.pushToken = initializedToken
tokenChangedListeners.forEach { listener ->
listener.onPushTokenChanged(initializedToken)
}
}

/**
* Called by push service implementation to notify about push message.
*/
fun processMessage(message: Map<String, String>, sender: String) {
Log.d(TAG, "processMessage: sender - $sender, message - $message")
}

}

PushServiceInitializer

To simplify the final integration of the push service we can use App startup library, so “app” module won’t need to add anything else.

Initializer:

package com.kurantsov.pushservice

import android.content.Context
import android.util.Log
import androidx.startup.Initializer

class PushServiceInitializer : Initializer<PushServiceManager> {
override fun create(context: Context): PushServiceManager {
runCatching {
PushServiceManager.initialize(context)
}.onFailure { e ->
Log.e(TAG, "create: failed to initialize push service", e)
}.onSuccess {
Log.d(TAG, "create: Push service initialized successfully")
}
return PushServiceManager
}

override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()

private companion object {
const val TAG = "PushServiceInitializer"
}
}

AndroidManifest:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.kurantsov.pushservice.PushServiceInitializer"
android:value="androidx.startup" />
</provider>

</application>
</manifest>

Compile-time implementations set selection

As a result of SPI usage for push services implementation, we have several modules that provide implementation. To add it in the final apk we only need to add a dependency on the implementation module.

There are a few approaches that can add/remove dependencies in compile time. For example:

  • We can create several flavours of the application and use flavour-based dependency (for example, if we have china flavour, we can use chinaImplementation instead of implementation; this will add a dependency for the china flavour only)
  • Add dependency based on the compilation flags.

Here is an example of the flags-based approach (app/build.gradle.kts):

dependencies {
implementation(project(":push-service:core"))
implementation(project(":push-service:firebase"))
if (getBooleanProperty("amazon")) {
implementation(project(":push-service:amazon"))
}
if (getBooleanProperty("huawei")) {
implementation(project(":push-service:huawei"))
}
if (getBooleanProperty("baidu")) {
implementation(project(":push-service:baidu"))
}
}

fun getBooleanProperty(propertyName: String): Boolean {
return properties[propertyName]?.toString()?.toBoolean() == true
}

Then we can add this flags during compilation using -P{flag name}={value} in the command line. Example of the command that adds all implementations:

gradle :app:assemble -Pamazon=true -Phuawei=true -Pbaidu=true

SPI implementations in the aar/apk

You can verify SPI implementations in the final aar/apk file using Android Studio’s built-in apk explorer.

In the aar files, the META-INF/services folder is located inside classes.jar. Firebase implementation aar example:

Firebase implementation AAR example

In the apk files, META-INF/services folder is located in the root of the apk. Final apk example:

APK example

Links

Thanks for Reading!

Please comment with questions and suggestions!! If this blog is helpful for you then hit Please hit the clap! Follow on Medium, Please Follow and subscribe to Make Android for prompt updates on Android platform-related blogs.

Thank you!

--

--