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.
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
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:
In the apk files, META-INF/services folder is located in the root of the apk. Final 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!