A step-by-step guide to implementing reliable cross-app services
What’s AIDL
AIDL (Android Interface Definition Language) is used to create a common interface for client-server communication in Android.
Both client and server can agree on the common interface to communicate with each other using IPC.
AIDL supports all Java primitive data types and a handful of wrapper data types, such as String, List, Map, Parcelable.
Android IPC
Note: Android apps can communicate using several methods; this guide focuses on IPC via AIDL.
What we are going to build
To demonstrate AIDL in action, we’ll build a practical example: a sensor data logging system consisting of two applications:
Server App:
A service that reads various sensors and provides APIs to:
- Fetch real-time sensor values
- Register callbacks for sensor value changes
- Manage logging sessions
Client App:
- Connects to the server app and consumes the sensor data
- The client app will connect to the server app by binding with the service; once binding is complete, we get a binder object, which is the implementation of the AIDL interface we both agreed on. using this binder, we can call the API exposed from Server app
Service Connector
- We will also discuss a small utility class that I created to take care of the boiler plate code when binding with a service.
-
ServiceConnectorhandles binding to services and manages retries when the server crashes or stops. It provides agetServicemethod that suspendsuntil a connection is established or a timeout occurs. - This will come in handy when the server app crashes/stops due to some reason. The next time we call the
getServicemethod, it takes care of binding with the service and returns the binder interface.
Let’s see some code
Step 1
- Create an AIDL interface
The files should have
.aidlextension and should be placed inside asrc/main/aidlfolder
package com.gandiva.aidl.remoteservices;
import com.gandiva.aidl.remoteservices.SensorDataCallback;
interface SensorDataLoggerService {
String getSpeedInKm();
int getRPM();
void startLogging(in SensorDataCallback callback);
void stopLogging(in SensorDataCallback callback);
}
package com.gandiva.aidl.remoteservices;
import com.gandiva.aidl.remoteservices.model.SensorData;
interface SensorDataCallback {
// Create a SensorData class, which should implement android.os.Parcelable interface, and placed in java/kotlin package.
void onEvent(in SensorData data);
}
- Create a SensorData class, which should implement the
android.os.Parcelableinterface, and place it in theJava/Kotlinpackage.
package com.gandiva.aidl.remoteservices.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class SensorData(val sensorID: Int, val value: Int) : Parcelable
- We need to share this interface with both applications, so it would be advisable to create a library module and share the interface with the two apps. Take a look at this Android library module for reference.
Step 2
- Create an Android Service to expose the Binder interface implementation from onBind method
class SensorDataLoggerServiceImpl : Service() {
companion object {
const val SENSOR_DATA_LOGGER_BIND_ACTION = "com.gandiva.aidl.server.action.BIND_SENSOR_DATA_LOGGER"
}
override fun onBind(intent: Intent): IBinder? {
if (intent.action != SENSOR_DATA_LOGGER_BIND_ACTION) return null
val binder = object : SensorDataLoggerService.Stub() {
override fun getSpeedInKm(): String {
TODO("Not yet implemented")
}
override fun getRPM(): Int {
TODO("Not yet implemented")
}
override fun startLogging(callback: SensorDataCallback?) {
TODO("Not yet implemented")
}
override fun stopLogging(callback: SensorDataCallback?) {
TODO("Not yet implemented")
}
}
binder.linkToDeath(DeathRecipient { Log.d("SensorDataLoggerService", "**** Service died") }, 0)
return binder
}
}
For brevity, the methods just have TODO; actual implementation can be found here
Step 3
- The client can try to bind the exposed service from the server app using explicit intent.
- Once the service is connected, we can typecast the IBinder instance to the AIDL interface type, which both apps agreed on step 1.
@HiltViewModel
class SensorDataLoggerViewModel @Inject constructor(val appContext: Application) : AndroidViewModel(appContext) {
companion object {
const val SENSOR_DATA_LOGGER_PKG_NAME = "com.gandiva.aidl.server"
const val SENSOR_DATA_LOGGER_SERVICE_NAME = "com.gandiva.aidl.server.sensor.SensorDataLoggerServiceImpl"
const val SENSOR_DATA_LOGGER_BIND_ACTION = "com.gandiva.aidl.server.action.BIND_SENSOR_DATA_LOGGER"
}
private var sensorDataLoggerService: SensorDataLoggerService? = null
var isServiceConnected by mutableStateOf(false)
private set
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
isServiceConnected = true
sensorDataLoggerService = SensorDataLoggerService.Stub.asInterface(service)
}
override fun onServiceDisconnected(name: ComponentName?) {
isServiceConnected = false
}
}
fun disconnectService() {
appContext.applicationContext.unbindService(serviceConnection)
isServiceConnected = false
}
// Should call this method before accessing [sensorDataLoggerService]
fun connectToService(appContext: Context = this.getApplication()) {
val bindIntent = Intent().apply {
component = ComponentName(SENSOR_DATA_LOGGER_PKG_NAME, SENSOR_DATA_LOGGER_SERVICE_NAME)
action = SENSOR_DATA_LOGGER_BIND_ACTION
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appContext.bindService(bindIntent, Context.BIND_AUTO_CREATE, appContext.mainExecutor, serviceConnection)
} else {
appContext.applicationContext.bindService(bindIntent, serviceConnection, Context.BIND_NOT_FOREGROUND)
}
}
fun showSpeed() {
val speedInKm = sensorDataLoggerService?.speedInKm // Assume service is connected
Toast.makeText(appContext, "Speed $speedInKm", Toast.LENGTH_SHORT).show()
}
fun showRpm() {
val rpm = sensorDataLoggerService?.rpm // Assume service is connected
Toast.makeText(appContext, "RPM $rpm", Toast.LENGTH_SHORT).show()
}
}
- In the above code, we have to explicitly call the
connectToServicemethod to initialize the connection, and post connection only, we should call any method from the (AIDL) binder instance. - So our code works under the assumption that when a
showSpeedmethod is called, it tries to call the AIDL API irrespective of the connection. This works in the best-case scenario, but the real world will be far from the best-case scenario. The server app can crash or stop, post-initial connection, leaving the last obtained AIDL binder instance as obsolete. - So, we should have a way to call the API only when the service is connected; we can modify the showSpeed method like below to call the API when the service is connected, or call
connectToServiceand wait for the service connection; post we are eligible to call any API.
fun showSpeed() {
if (isServiceConnected) {
val speedInKm = sensorDataLoggerService?.speedInKm
Toast.makeText(appContext, "Speed $speedInKm", Toast.LENGTH_SHORT).show()
} else {
connectToService() // Async operation
// Wait for service to connect then call API
val speedInKm = sensorDataLoggerService?.speedInKm
Toast.makeText(appContext, "Speed $speedInKm", Toast.LENGTH_SHORT).show()
}
}
- But we have to follow the same approach everywhere before calling the AIDL API to handle the worst-case scenario. But if we do that, it will introduce a lot of boilerplate code.
- The best way to deal with this is to create a utility class that takes care of this complexity. Following is one example (i.e.,
ServiceConnector)
Service connector
-
ServiceConnectorexposes a getService suspend function, which will suspend until a connection is made or timeOutInMillis expires. - This handles the service connection and retry logic internally, so clients don’t have to worry about the service connection or retry in case the server died or crashed.
interface IServiceConnector<T> {
suspend fun getService(timeOutInMillis: Long = -1): T?
suspend fun unbindService()
fun onServiceConnected() {}
}
open class ServiceConnector<T>(
private val context: Context,
private val intent: Intent,
val transformBinderToService: (service: IBinder?) -> T?,
private val allowNullBinding: Boolean = false
) : IServiceConnector<T> {
private var serviceConnected = false
private var service: T? = null
private val mutex = Mutex()
private var lastServiceConnection: ServiceConnection? = null
private val logTag = "Service :: ${this.javaClass}"
override suspend fun getService(timeOutInMillis: Long): T? {
// If allowNullBinding is true don't care what service object is
if (serviceConnected && (allowNullBinding || service != null)) {
return service
}
if (timeOutInMillis < 0)
return mutex.withLock { bindAndGetService() }
return mutex.withLock { withTimeoutOrNull(timeOutInMillis) { bindAndGetService() } }
}
private suspend fun bindAndGetService() = suspendCancellableCoroutine { continuation ->
val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
resumeWithServiceInstance(binder)
}
override fun onServiceDisconnected(name: ComponentName?) {
cleanUpAndResumeIfRequired()
}
override fun onBindingDied(name: ComponentName?) {
cleanUpAndResumeIfRequired()
}
override fun onNullBinding(name: ComponentName?) {
if (allowNullBinding) resumeWithServiceInstance(null)
else cleanUpAndResumeIfRequired()
}
private fun resumeWithServiceInstance(binder: IBinder?) {
service = transformBinderToService(binder)
serviceConnected = true
if (continuation.isActive) continuation.resume(service)
onServiceConnected()
}
private fun cleanUpAndResumeIfRequired() {
service = null
serviceConnected = false
if (continuation.isActive) continuation.resume(null)
}
}
context.bindService(
intent, serviceConnection, Context.BIND_AUTO_CREATE
)
lastServiceConnection = serviceConnection
}
@Throws(Exception::class)
override suspend fun unbindService() {
lastServiceConnection?.let(context::unbindService)
serviceConnected = false
service = null
}
}
- Extend the
ServiceConnectorand provide necessary information about the service to which we want to connect and the type of the binder interface. -
contextContext used to bind the service. -
intentExplicit intent describing the service to connect. -
transformBinderToServicecallback function called to transform the generic IBinder instance to the client-specific AIDL interface. -
allowNullBindingPass true to indicate to keep the server connected even if the server returns a null IBinder instance from the onBind method.
class SensorDataLoggerServiceCoordinator(context: Context) : ServiceConnector<SensorDataLoggerService>(
context = context,
intent = bindIntent(),
transformBinderToService = { binder: IBinder? -> binder?.let { SensorDataLoggerService.Stub.asInterface(it) } },
allowNullBinding = false
) {
companion object {
private const val SENSOR_DATA_LOGGER_PKG_NAME = "com.gandiva.aidl.server"
private const val SENSOR_DATA_LOGGER_SERVICE_NAME = "com.gandiva.aidl.server.sensor.SensorDataLoggerServiceImpl"
private const val SENSOR_DATA_LOGGER_BIND_ACTION = "com.gandiva.aidl.server.action.BIND_SENSOR_DATA_LOGGER"
fun bindIntent(): Intent {
return Intent().apply {
component = ComponentName(SENSOR_DATA_LOGGER_PKG_NAME, SENSOR_DATA_LOGGER_SERVICE_NAME)
action = SENSOR_DATA_LOGGER_BIND_ACTION
}
}
}
}
- To use create an instance of
SensorDataLoggerServiceCoordinatorand use thegetServicemethod to obtain the binder instance
@HiltViewModel
class SensorDataLoggerViewModelV2 @Inject constructor(val appContext: Application) : AndroidViewModel(appContext) {
private val sensorDataLoggerServiceCoordinator: SensorDataLoggerServiceCoordinator by lazy {
SensorDataLoggerServiceCoordinator(context = appContext)
}
fun showSpeed() {
viewModelScope.launch {
// Suspend till service gets connected.
val speedInKm = sensorDataLoggerServiceCoordinator.getService()?.speedInKm
Toast.makeText(appContext, "Speed $speedInKm", Toast.LENGTH_SHORT).show()
}
}
fun showRPM() {
viewModelScope.launch {
// Suspend till service gets connected. or at max 1500 ms. which ever comes first.
val rpm = sensorDataLoggerServiceCoordinator.getService(1500L)?.rpm
Toast.makeText(appContext, "RMP $rpm", Toast.LENGTH_SHORT).show()
}
}
fun disconnectService() {
viewModelScope.launch { sensorDataLoggerServiceCoordinator.unbindService() }
}
}
Links:
ServiceConnector
Repo:
https://github.com/sridhar-sp/android-playground/tree/main/AIDL
Top comments (0)