Connect to a GATT server

The first step in interacting with a BLE device is connecting to it. More specifically, connecting to the GATT server on the device. To connect to a GATT server on a BLE device, use the connectGatt() method. This method takes three parameters: a Context object, autoConnect (a boolean indicating whether to automatically connect to the BLE device as soon as it becomes available), and a reference to a BluetoothGattCallback:

var bluetoothGatt: BluetoothGatt? = null
// ...
bluetoothGatt = device.connectGatt(this, false, bluetoothGattCallback)

This connects to the GATT server hosted by the BLE device, and returns a BluetoothGatt instance, which you can then use to conduct GATT client operations. The caller (the Android app) is the GATT client. The BluetoothGattCallback is used to deliver results to the client, such as connection status, as well as any further GATT client operations.

Set up a bound service

In the following example, the BLE app provides an activity (DeviceControlActivity) to connect to Bluetooth devices, display device data, and display the GATT services and characteristics supported by the device. Based on user input, this activity communicates with a Service called BluetoothLeService, which interacts with the BLE device via the BLE API. The communication is performed using a bound service which allows the activity to connect to the BluetoothLeService and call functions to connect to the devices. The BluetoothLeService needs a Binder implementation that provides access to the service for the activity.

class BluetoothLeService : Service() {

    private val binder = LocalBinder()

    override fun onBind(intent: Intent): IBinder? {
        return binder
    }

    inner class LocalBinder : Binder() {
        fun getService(): BluetoothLeService {
            return this@BluetoothLeService
        }
    }
}

The activity can start the service using bindService(), passing in an Intent to start the service, a ServiceConnection implementation to listen for the connection and disconnection events, and a flag to specify additional connection options.

class DeviceControlActivity : AppCompatActivity() {

    private var bluetoothService: BluetoothLeService? = null
    // Code to manage Service lifecycle.
    private val serviceConnection: ServiceConnection = object : ServiceConnection {
        override fun onServiceConnected(
            componentName: ComponentName,
            service: IBinder
        ) {
            bluetoothService = (service as LocalBinder).getService()
            bluetoothService?.let { bluetooth ->
                // call functions on service to check connection and connect to devices
            }
        }

        override fun onServiceDisconnected(componentName: ComponentName) {
            bluetoothService = null
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.gatt_services_characteristics)

        val gattServiceIntent = Intent(this, BluetoothLeService::class.java)
        bindService(gattServiceIntent, serviceConnection, Context.BIND_AUTO_CREATE)
    }
}

Set up the BluetoothAdapter

Once the service is bound to, it needs to access the BluetoothAdapter. It should check that the adapter is available on the device. Read Set up Bluetooth for more information on the BluetoothAdapter. The following example wraps this setup code in an initialize() function that returns a Boolean value indicating success.

private const val TAG = "BluetoothLeService"

class BluetoothLeService : Service() {

    private var bluetoothAdapter: BluetoothAdapter? = null

    fun initialize(): Boolean {
        bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
        if (bluetoothAdapter == null) {
            Log.e(TAG, "Unable to obtain a BluetoothAdapter.")
            return false
        }
        return true
    }

    // ...
}

The activity calls this function within its ServiceConnection implementation. Handling a false return value from the initialize() function depends on your application. You could show an error message to the user indicating that the current device does not support the Bluetooth operation or disable any features that require Bluetooth to work. In the following example, finish() is called on the activity to send the user back to the previous screen.

class DeviceControlActivity : AppCompatActivity() {

    // Code to manage Service lifecycle.
    private val serviceConnection: ServiceConnection = object : ServiceConnection {
        override fun onServiceConnected(
            componentName: ComponentName,
            service: IBinder
        ) {
            bluetoothService = (service as LocalBinder).getService()
            bluetoothService?.let { bluetooth ->
                if (!bluetooth.initialize()) {
                    Log.e(TAG, "Unable to initialize Bluetooth")
                    finish()
                }
                // perform device connection
            }
        }

        override fun onServiceDisconnected(componentName: ComponentName) {
            bluetoothService = null
        }
    }

    // ...
}

Connect to a device

Once the BluetoothLeService instance is initialized, it can connect to the BLE device. The activity needs to send the device address to the service so it can initiate the connection. The service will first call getRemoteDevice() on the BluetoothAdapter to access the device. If the adapter is unable to find a device with that address, getRemoteDevice() throws an IllegalArgumentException.

fun connect(address: String): Boolean {
    bluetoothAdapter?.let { adapter ->
        try {
            val device = adapter.getRemoteDevice(address)
        } catch (exception: IllegalArgumentException) {
            Log.w(TAG, "Device not found with provided address.")
            return false
        }
        // connect to the GATT server on the device
        return true
    } ?: run {
        Log.w(TAG, "BluetoothAdapter not initialized")
        return false
    }
}

The DeviceControlActivity calls this connect() function once the service is initialized. The activity needs to pass in the address of the BLE device. In the following example, the device address is passed to the activity as an intent extra.

// Code to manage Service lifecycle.
private val serviceConnection: ServiceConnection = object : ServiceConnection {
    override fun onServiceConnected(
        componentName: ComponentName,
        service: IBinder
    ) {
        bluetoothService = (service as LocalBinder).getService()
        bluetoothService?.let { bluetooth ->
            if (!bluetooth.initialize()) {
                Log.e(TAG, "Unable to initialize Bluetooth")
                finish()
            }
            // perform device connection
            deviceAddress?.let { bluetooth.connect(it) }
        }
    }

    override fun onServiceDisconnected(componentName: ComponentName) {
        bluetoothService = null
    }
}

Declare GATT callback

Once the activity tells the service which device to connect to and the service connects to the device, the service needs to connect to the GATT server on the BLE device. This connection requires a BluetoothGattCallback to receive notifications about the connection state, service discovery, characteristic reads, and characteristic notifications.

This topic focuses on the connection state notifications. See Transfer BLE data for how to perform service discovery, characteristic reads, and request characteristic notifications.

The onConnectionStateChange() function is triggered when the connection to the device’s GATT server changes. In the following example, the callback is defined in the Service class so it can be used with the BluetoothDevice once the service connects to it.

private val bluetoothGattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
        if (newState == BluetoothProfile.STATE_CONNECTED) {
            // successfully connected to the GATT Server
        } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
            // disconnected from the GATT Server
        }
    }
}

Connect to GATT service

Once the BluetoothGattCallback is declared, the service can use the BluetoothDevice object from the connect() function to connect to the GATT service on the device.

The connectGatt() function is used. This requires a Context object, an autoConnect boolean flag, and the BluetoothGattCallback. In this example, the app is directly connecting to the BLE device, so false is passed for autoConnect.

A BluetoothGatt property is also added. This allows the service to close the connection when it is no longer needed.

class BluetoothLeService : Service() {

    // ...
    private var bluetoothGatt: BluetoothGatt? = null

    // ...
    fun connect(address: String): Boolean {
        bluetoothAdapter?.let { adapter ->
            try {
                val device = adapter.getRemoteDevice(address)
                // connect to the GATT server on the device
                bluetoothGatt = device.connectGatt(this, false, bluetoothGattCallback)
                return true
            } catch (exception: IllegalArgumentException) {
                Log.w(TAG, "Device not found with provided address.  Unable to connect.")
                return false
            }
        } ?: run {
            Log.w(TAG, "BluetoothAdapter not initialized")
            return false
        }
    }
}

Broadcast updates

When the server connects or disconnects from the GATT server, it needs to notify the activity of the new state. There are several ways to accomplish this. The following example uses broadcasts to send the information from the service to activity.

The service declares a function to broadcast the new state. This function takes in an action string which is passed to an Intent object before being broadcast to the system.

private fun broadcastUpdate(action: String) {
    val intent = Intent(action)
    sendBroadcast(intent)
}

Once the broadcast function is in place, it is used within the BluetoothGattCallback to send information about the connection state with the GATT server. Constants and the service’s current connection state are declared in the service representing the Intent actions.

class BluetoothLeService : Service() {

    private var connectionState = STATE_DISCONNECTED

    private val bluetoothGattCallback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                // successfully connected to the GATT Server
                connectionState = STATE_CONNECTED
                broadcastUpdate(ACTION_GATT_CONNECTED)
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                // disconnected from the GATT Server
                connectionState = STATE_DISCONNECTED
                broadcastUpdate(ACTION_GATT_DISCONNECTED)
            }
        }
    }

    // ...
    companion object {
        const val ACTION_GATT_CONNECTED =
            "com.example.bluetooth.le.ACTION_GATT_CONNECTED"
        const val ACTION_GATT_DISCONNECTED =
            "com.example.bluetooth.le.ACTION_GATT_DISCONNECTED"

        private const val STATE_DISCONNECTED = 0
        private const val STATE_CONNECTED = 2
    }
}

Listen for updates in activity

Once the service broadcasts the connection updates, the activity needs to implement a BroadcastReceiver. Register this receiver when setting up the activity, and unregister it when the activity is leaving the screen. By listening for the events from the service, the activity is able to update the user interface based on the current connection state with the BLE device.

class DeviceControlActivity : AppCompatActivity() {

    private val gattUpdateReceiver: BroadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            when (intent.action) {
                BluetoothLeService.ACTION_GATT_CONNECTED -> {
                    connected = true
                    updateConnectionState(R.string.connected)
                }
                BluetoothLeService.ACTION_GATT_DISCONNECTED -> {
                    connected = false
                    updateConnectionState(R.string.disconnected)
                }
            }
        }
    }

    override fun onResume() {
        super.onResume()
        registerReceiver(gattUpdateReceiver, makeGattUpdateIntentFilter())
        bluetoothService?.let { service ->
            deviceAddress?.let { address ->
                val result = service.connect(address)
                Log.d(TAG, "Connect request result=$result")
            }
        }
    }

    override fun onPause() {
        super.onPause()
        unregisterReceiver(gattUpdateReceiver)
    }

    private fun makeGattUpdateIntentFilter(): IntentFilter {
        return IntentFilter().apply {
            addAction(BluetoothLeService.ACTION_GATT_CONNECTED)
            addAction(BluetoothLeService.ACTION_GATT_DISCONNECTED)
        }
    }
}

In Transfer BLE data, the BroadcastReceiver is also used to communicate the service discovery as well as the characteristic data from the device.

Close GATT connection

One important step when dealing with Bluetooth connections is to close the connection when you are finished with it. To do this, call the close() function on the BluetoothGatt object. In the following example, the service holds the reference to the BluetoothGatt. When the activity unbinds from the service, the connection is closed to avoid draining the device battery.

class BluetoothLeService : Service() {

    // ...
    override fun onUnbind(intent: Intent?): Boolean {
        close()
        return super.onUnbind(intent)
    }

    private fun close() {
        bluetoothGatt?.let { gatt ->
            gatt.close()
            bluetoothGatt = null
        }
    }
}