blob: 3afb7c0b0f3af5c789f68fe29aebf1743e3965b6 [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.connecteddevice.transport.ble
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattService
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelUuid
import androidx.annotation.VisibleForTesting
import com.google.android.connecteddevice.transport.ConnectChallenge
import com.google.android.connecteddevice.transport.ConnectionProtocol
import com.google.android.connecteddevice.transport.IDataSendCallback
import com.google.android.connecteddevice.transport.IDiscoveryCallback
import com.google.android.connecteddevice.util.ByteUtils
import com.google.android.connecteddevice.util.EventLog
import com.google.android.connecteddevice.util.SafeLog.logd
import com.google.android.connecteddevice.util.SafeLog.loge
import com.google.android.connecteddevice.util.SafeLog.logw
import java.time.Duration
import java.util.UUID
import java.util.concurrent.Executor
import java.util.concurrent.Executors
/**
* A ble peripheral communication protocol that provides actions and event notifications for
* interacting with devices.
*/
class BlePeripheralProtocol
@JvmOverloads
constructor(
private val blePeripheralManager: BlePeripheralManager,
private val reconnectServiceUuid: UUID,
private val reconnectDataUuid: UUID,
advertiseDataCharacteristicUuid: UUID,
writeCharacteristicUuid: UUID,
readCharacteristicUuid: UUID,
serviceChangedCharacteristicUuid: UUID,
private val maxReconnectAdvertisementDuration: Duration,
defaultMtuSize: Int,
callbackExecutor: Executor = Executors.newCachedThreadPool()
) : ConnectionProtocol(callbackExecutor) {
private val writeCharacteristic =
BluetoothGattCharacteristic(
writeCharacteristicUuid,
BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_READ,
BluetoothGattCharacteristic.PERMISSION_READ
)
private val readCharacteristic =
BluetoothGattCharacteristic(
readCharacteristicUuid,
BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE,
BluetoothGattCharacteristic.PERMISSION_WRITE
)
private val serviceChangedCharacteristic =
BluetoothGattCharacteristic(
serviceChangedCharacteristicUuid,
BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_READ,
BluetoothGattCharacteristic.PERMISSION_READ
)
private val advertiseDataCharacteristic =
BluetoothGattCharacteristic(
advertiseDataCharacteristicUuid,
BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_READ,
BluetoothGattCharacteristic.PERMISSION_READ
)
private val peripheralCallback: BlePeripheralManager.Callback =
object : BlePeripheralManager.Callback {
override fun onMtuSizeChanged(size: Int) {
maxWriteSize = size - ATT_PROTOCOL_BYTES
protocolId?.let { id ->
maxDataSizeChangedListeners[id]?.invoke {
it.onDeviceMaxDataSizeChanged(id, maxWriteSize)
}
}
}
override fun onRemoteDeviceConnected(device: BluetoothDevice) {
bluetoothDevice = device
val currentProtocolId = createProtocolId()
protocolId = currentProtocolId
logd(TAG, "Remote device ${device.address} connected. Protocol ID: $protocolId")
discoveryCallback?.onDeviceConnected(currentProtocolId)
blePeripheralManager.addOnCharacteristicWriteListener(
this@BlePeripheralProtocol::onCharacteristicWrite
)
blePeripheralManager.addOnCharacteristicReadListener(
this@BlePeripheralProtocol::onCharacteristicRead
)
stopAdvertising()
}
override fun onRemoteDeviceDisconnected(device: BluetoothDevice) {
logd(
TAG,
"Remote device ${device.address} disconnected. Current connected device: " +
"${bluetoothDevice?.address}"
)
if (device != bluetoothDevice) {
loge(
TAG,
"Disconnected from device ${device.address} that is not the expected device " +
"${bluetoothDevice?.address}. Ignoring."
)
return
}
bluetoothDevice = null
val currentProtocolId = protocolId
if (currentProtocolId == null) {
logw(
TAG,
"Device disconnected but no protocol ID. Cannot notify disconnect listeners. Ignore."
)
return
}
protocolId = null
val listener = deviceDisconnectedListeners[currentProtocolId]
if (listener != null) {
logd(TAG, "Valid disconnect listener exists for protocolId $protocolId. Notifying.")
listener.invoke { it.onDeviceDisconnected(currentProtocolId) }
} else {
logw(TAG, "No disconnect listener exists for protocolId $protocolId.")
}
removeListeners(currentProtocolId)
}
}
private val timeoutHandlerThread: HandlerThread =
HandlerThread(TIMEOUT_HANDLER_THREAD_NAME).apply { start() }
private var maxWriteSize: Int = defaultMtuSize - ATT_PROTOCOL_BYTES
// Indicates ongoing connection discovery if not null
private var deviceId: UUID? = null
/** Current connected bluetooth device */
private var bluetoothDevice: BluetoothDevice? = null
/** The id of the current connection */
private var protocolId: String? = null
// Indicates ongoing association advertising if not null
private var associationAdvertiseCallback: AdvertiseCallback? = null
// Indicates ongoing reconnect advertising if not null
private var connectionAdvertiseCallback: AdvertiseCallback? = null
/** Indicates ongoing discovery if not null */
private var discoveryCallback: IDiscoveryCallback? = null
private var timeoutHandler = Handler(timeoutHandlerThread.looper)
private var dataSendCallback: IDataSendCallback? = null
init {
writeCharacteristic.addDescriptor(createBluetoothGattDescriptor())
readCharacteristic.addDescriptor(createBluetoothGattDescriptor())
advertiseDataCharacteristic.addDescriptor(createBluetoothGattDescriptor())
}
override fun startAssociationDiscovery(
name: String,
identifier: ParcelUuid,
callback: IDiscoveryCallback,
) {
discoveryCallback = callback
blePeripheralManager.registerCallback(peripheralCallback)
val advertiseCallback =
object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
super.onStartSuccess(settingsInEffect)
logd(TAG, "Successfully started advertising for association.")
callback.onDiscoveryStartedSuccessfully()
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
logd(TAG, "Failed to start advertising for association. Error code: $errorCode")
callback.onDiscoveryFailedToStart()
}
}
associationAdvertiseCallback = advertiseCallback
startAdvertising(
identifier.uuid,
advertiseCallback,
scanResponse = ByteUtils.hexStringToByteArray(name),
scanResponseUuid = reconnectDataUuid
)
}
override fun startConnectionDiscovery(
id: ParcelUuid,
challenge: ConnectChallenge,
callback: IDiscoveryCallback
) {
deviceId = id.uuid
discoveryCallback = callback
blePeripheralManager.registerCallback(peripheralCallback)
val advertiseCallback =
object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
super.onStartSuccess(settingsInEffect)
timeoutHandler.postDelayed(
{
logd(TAG, "Timeout period expired without a connection. Restarting advertisement.")
retryConnectionDiscovery(id.uuid, challenge, callback)
},
maxReconnectAdvertisementDuration.toMillis()
)
logd(TAG, "Successfully started advertising for device $id.")
callback.onDiscoveryStartedSuccessfully()
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
callback.onDiscoveryFailedToStart()
}
}
connectionAdvertiseCallback = advertiseCallback
val advertiseData = createConnectData(challenge)
if (advertiseData == null) {
loge(TAG, "Unable to create advertisement data. Aborting connecting.")
callback.onDiscoveryFailedToStart()
return
}
EventLog.onDeviceSearchStarted()
startAdvertising(reconnectServiceUuid, advertiseCallback, advertiseData, reconnectDataUuid)
}
private fun retryConnectionDiscovery(
deviceId: UUID,
connectChallenge: ConnectChallenge,
discoveryCallback: IDiscoveryCallback
) {
stopConnectionDiscovery(ParcelUuid(deviceId))
startConnectionDiscovery(ParcelUuid(deviceId), connectChallenge, discoveryCallback)
}
override fun stopAssociationDiscovery() {
if (associationAdvertiseCallback == null) {
logd(TAG, "No association discovery is happening, ignoring.")
return
}
blePeripheralManager.stopAdvertising(associationAdvertiseCallback)
associationAdvertiseCallback = null
discoveryCallback = null
}
override fun stopConnectionDiscovery(id: ParcelUuid) {
if (id.uuid != deviceId || connectionAdvertiseCallback == null) {
logd(TAG, "No connection discovery is happening for device $id, ignoring.")
return
}
timeoutHandler.removeCallbacksAndMessages(null)
blePeripheralManager.stopAdvertising(connectionAdvertiseCallback)
connectionAdvertiseCallback = null
discoveryCallback = null
deviceId = null
}
override fun sendData(protocolId: String, data: ByteArray, callback: IDataSendCallback?) {
val device: BluetoothDevice? = bluetoothDevice
if (device == null) {
loge(TAG, "Failed to send data, no connected device.")
callback?.onDataFailedToSend()
return
}
if (protocolId != this.protocolId) {
loge(TAG, "Failed to send data, no connected device matches protocol id $protocolId.")
callback?.onDataFailedToSend()
return
}
if (data.size > maxWriteSize) {
loge(
TAG,
"Failed to send data, data size ${data.size} exceeds the max write size $maxWriteSize."
)
callback?.onDataFailedToSend()
return
}
dataSendCallback = callback
writeCharacteristic.value = data
blePeripheralManager.notifyCharacteristicChanged(
device,
writeCharacteristic,
/* confirm= */ false
)
}
override fun disconnectDevice(protocolId: String) {
if (protocolId == this.protocolId) {
blePeripheralManager.disconnect()
}
}
/**
* Resets all internal states.
*
* Please be cautious when calling this method, a disconnect usually does not need a reset, please
* call [disconnectDevice] instead. This should only be called when Bluetooth is turned off.
*/
override fun reset() {
super.reset()
logd(TAG, "Resetting protocol.")
stopAdvertising()
blePeripheralManager.cleanup()
bluetoothDevice = null
protocolId = null
dataSendCallback = null
}
override fun getMaxWriteSize(protocolId: String): Int = maxWriteSize
override fun isDeviceVerificationRequired(): Boolean = true
private fun startAdvertising(
serviceUuid: UUID,
callback: AdvertiseCallback,
advertiseData: ByteArray? = null,
advertiseDataUuid: UUID? = null,
scanResponse: ByteArray? = null,
scanResponseUuid: UUID? = null
) {
logd(TAG, "Starting advertising for service $serviceUuid.")
val gattService = BluetoothGattService(serviceUuid, BluetoothGattService.SERVICE_TYPE_PRIMARY)
gattService.addCharacteristic(writeCharacteristic)
gattService.addCharacteristic(readCharacteristic)
gattService.addCharacteristic(serviceChangedCharacteristic)
val uuid = ParcelUuid(serviceUuid)
val advertiseDataBuilder = AdvertiseData.Builder().addServiceUuid(uuid)
if (advertiseData != null) {
val dataUuid = if (advertiseDataUuid != null) ParcelUuid(advertiseDataUuid) else uuid
advertiseDataBuilder.addServiceData(dataUuid, advertiseData)
// Also embed the advertise data into a fixed GATT service characteristic.
advertiseDataCharacteristic.value = advertiseData
gattService.addCharacteristic(advertiseDataCharacteristic)
}
val scanResponseBuilder = AdvertiseData.Builder()
if (scanResponse != null && scanResponseUuid != null) {
val scanResponseParcelUuid = ParcelUuid(scanResponseUuid)
scanResponseBuilder.addServiceData(scanResponseParcelUuid, scanResponse)
}
blePeripheralManager.startAdvertising(
gattService,
advertiseDataBuilder.build(),
scanResponseBuilder.build(),
callback
)
}
private fun stopAdvertising() {
logd(TAG, "Attempting to stop all ongoing advertising.")
stopAssociationDiscovery()
if (deviceId != null) {
stopConnectionDiscovery(ParcelUuid(deviceId))
}
}
private fun onCharacteristicRead(device: BluetoothDevice) {
if (device != bluetoothDevice) {
logw(
TAG,
"Received a read notification from device ${device.address} that is not the expected" +
" device ${bluetoothDevice?.address}. Ignoring."
)
return
}
// Clear the callback first because it may be set inside the callback's method call.
val callback = dataSendCallback
dataSendCallback = null
callback?.onDataSentSuccessfully()
}
private fun onCharacteristicWrite(
device: BluetoothDevice,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
logd(TAG, "Received a message from device ${device.address}.")
if (device != bluetoothDevice) {
logw(
TAG,
"Received a message from device ${device.address} that is not expected" +
" device ${bluetoothDevice?.address}. Disconnect."
)
blePeripheralManager.disconnect()
return
}
if (characteristic.uuid != readCharacteristic.uuid) {
logw(
TAG,
"Received a write to a characteristic ${characteristic.uuid} that is not the expected" +
" UUID ${readCharacteristic.uuid}. Ignoring."
)
return
}
protocolId?.let { id -> notifyDataReceived(id, value) }
}
private fun createBluetoothGattDescriptor(): BluetoothGattDescriptor {
val descriptor =
BluetoothGattDescriptor(
CLIENT_CHARACTERISTIC_CONFIG,
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
)
descriptor.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
return descriptor
}
private fun createProtocolId() = UUID.randomUUID().toString()
private fun createConnectData(challenge: ConnectChallenge) =
ByteUtils.concatByteArrays(challenge.challenge.copyOf(TRUNCATED_BYTES), challenge.salt)
companion object {
private const val TAG = "BlePeripheralProtocol"
// Attribute protocol bytes attached to message. Available write size is MTU size minus att
// bytes.
@VisibleForTesting internal const val ATT_PROTOCOL_BYTES = 3
private const val TRUNCATED_BYTES = 3
private const val TIMEOUT_HANDLER_THREAD_NAME = "peripheralThread"
private val CLIENT_CHARACTERISTIC_CONFIG: UUID =
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
}