【Kotlin/Android】Bluetooth接続アプリCentral側の実装方法!
この記事からわかること
- Android Studio/KotlinでBluetooth接続アプリの実装方法
- Central側の実装
- ServiceとCharacteristicの取得方法
- peripheralから値を取得したり書き込むには?
- peripheralから更新通知を受け取る(notify)には?
index
[open]
\ アプリをリリースしました /
友達や家族の誕生日をメモ!通知も届く-みんなの誕生日-
posted withアプリーチ
環境
- Android Studio:Koala
- Kotlin:1.9.0
今回はAndroidアプリでBluetoothでの接続機能を持ったCentral側の実装方法をまとめていきます。iOS側にはなってしまいますが実際の接続テストが行えるようにPeripheral側の実装も公開しているので参考にしてみてください。
AndroidのBluetooth機能の実装
Androidアプリでは「Classic Bluetooth」と「Bluetooth Low Energy(BLE)」の両方をサポートするBluetoothスタックがデフォルトで用意されています。これにより比較的簡単にBluetooth機能を実装することができるようになっています。
セントラルやペリフェラルという言葉が出てきましたがBluetoothで接続する側をセントラル、接続される側をペリフェラルと呼びます。よくあるBluetoothイヤホンで例えるとスマホがセントラルでイヤホンがペリフェラルになります。
AndroidアプリではBluetooth 4.1以降に対応しているデバイスでは「セントラル側」、「ペリフェラル側」問わず実装できるようになっています。古い端末だとペリフェラル側の実装はできないので注意してください。
今回は「Central側」の実装をしていきたいと思います。
実装するにあたって
Android Studioで実装するにあたってエミュレーターではBluetooth機能をサポートしていないのでテストする際には実機が必要になります。ペリフェラル側との連携を試したい場合は実機が2台に必要になるので注意してください。
Central機能の実装方法
流れ
- AndroidManifest.xmlに権限を追加
- Bluetooth有効管理クラスの実装
- ペリフェラル側のUUID定義
- BluetoothAdapterの取得
- スキャンの実装
- 接続する
- サービス一覧と各キャラクタリスティックを取得する
また今回の全体のコードはGitHubに掲載しています。
1.AndroidManifest.xmlに権限を追加
AndroidアプリでBluetooth機能を実装するためには「AndroidManifest.xml」に適切な権限を追加しておく必要があります。詳細は以下の記事を参考にしてください。
<!-- Bluetooth -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!-- Bluetooth-option -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<!-- Android9(APIレベル28)以上 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Android9(APIレベル28)以下 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
2.Bluetooth有効管理クラスの実装
アプリからBluetooth機能を使用するために「端末自体がBluetoothをサポートしているかどうかのチェック」と「権限のリクエスト処理&承諾チェック」をする必要があります。この処理は専用クラスにまとめて実装してみました。こちらも詳細はこちらを参考にしてください。
GitHub:BleActiveStateManager.kt
/** Bluetoothを使用するためのパーミッションを確認&リクエストするクラス */
class BleActiveStateManager(
private val activity: ComponentActivity,
private val bluetoothAdapter: BluetoothAdapter?
) {
/** パーミッションの状態を保持する StateFlow */
private val _permissionState = MutableStateFlow<BluetoothState>(BluetoothState.Initial)
val permissionState: StateFlow<BluetoothState> = _permissionState
/** Bluetoothサポートしているかのチェック */
private val checkSupport: Boolean
get() = bluetoothAdapter?.isEnabled ?: false
/** Bluetooth有効状態チェック開始 */
public fun checking() {
// Bluetooth非サポート
if (!checkSupport) {
_permissionState.value = BluetoothState.NotSupport
return
}
// 全ての権限がマニフェスト内で承認済みかチェック
if (!permissionCheck) {
// リクエストを投げたいパーミションをまとめる
val permissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN
)
// リクエスト送信 → onRequestPermissionsResultコールバック
launcher.launch(permissions)
} else {
// 有効
_permissionState.value = BluetoothState.Active
}
}
/** リクエスト送信前にパーミッションが定義されているかチェックする */
private val permissionCheck: Boolean
get() {
val result = ActivityCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
&& ActivityCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
&& ActivityCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
return result
}
/** 許可ダイアログの表示と結果の処理を実装するランチャー */
private var launcher =
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
val location = it[Manifest.permission.ACCESS_FINE_LOCATION] ?: false
val connect = it[Manifest.permission.BLUETOOTH_CONNECT] ?: false
val scan = it[Manifest.permission.BLUETOOTH_SCAN] ?: false
if (location && connect && scan) {
// 有効
_permissionState.value = BluetoothState.Active
} else {
// 権限否認
_permissionState.value = BluetoothState.Denied
}
}
/** パーミッションの状態を表す sealed class */
sealed class BluetoothState {
/** 初期状態 */
object Initial : BluetoothState()
/** 有効 */
object Active : BluetoothState()
/** 初期状態 */
object NotSupport : BluetoothState()
/** 初期状態 */
object Denied : BluetoothState()
}
}
3.ペリフェラル側のUUID定義
今回はiOS側で実装したPeripheral側の機能に準ずる形で実装していきます。そのためUUIDは以下のように定義しておいてください。
object BleServiceConfig {
val SERVICE_UUID: UUID = UUID.fromString("00000000-0000-1111-1111-111111111111")
val READ_CHARACTERISTIC_UUID: UUID = UUID.fromString("00000000-1111-1111-1111-111111111111")
val WRITE_CHARACTERISTIC_UUID: UUID = UUID.fromString("00000000-2222-1111-1111-111111111111")
val NOTIFY_CHARACTERISTIC_UUID: UUID = UUID.fromString("00000000-3333-1111-1111-111111111111")
val INDICATE_CHARACTERISTIC_UUID: UUID = UUID.fromString("00000000-4444-1111-1111-111111111111")
}
4.BluetoothAdapterの取得
続いてBluetoothの有効状態やペアリング済みデバイスの取得などを行えるBluetoothAdapterを取得します。getSystemService(Context.BLUETOOTH_SERVICE)
からBluetoothManager
に参照できadapter
プロパティからBluetoothAdapter
を取得することが可能です。
/** Bluetoothの有効状態やペアリング済みデバイスの取得などを行えるクラス */
private val bluetoothAdapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}
5.スキャン処理の実装
続いて周辺からアドバタイズしているペリフェラル機器を検出するためにスキャン処理を実装します。スキャン処理はBluetoothLeScanner
型がスキャン結果のコールバックはScanCallback
型で実装します。プロパティとして保持できるように定義しておきます。
/** ペリフェラルデバイスのスキャンを行えるクラス */
private var bluetoothLeScanner: BluetoothLeScanner? = null
/** スキャン結果のコールバック */
private var scanCallback: ScanCallback? = null
スキャン処理では最終的に接続対象のデバイスアドレスを取得することが目的です。そのデバイスアドレスを使用して後続の接続処理を実装していきます。スキャン処理の詳細は以下の記事を参考にしてください。
private fun startScan() {
// スキャン対象のペリフェラルサービスUUIDをフィルタリング
val scanFilters = listOf(BleServiceConfig.SERVICE_UUID)
.map {
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(it))
.build()
}
// SCAN_MODE_BALANCED:検出効率とエネルギー消費の適度なバランスを維持したスキャン
val scanSettings: ScanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_BALANCED)
.build()
bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
scanCallback = leScanCallback()
logArea.text = "スキャン開始\n"
// スキャンの開始
bluetoothLeScanner?.startScan(scanFilters, scanSettings, scanCallback)
}
/** スキャンコールバック */
private fun leScanCallback(): ScanCallback {
return object : ScanCallback() {
override fun onScanResult(
callbackType: Int,
result: ScanResult,
) {
super.onScanResult(callbackType, result)
result.device ?: return
// ペリフェラルデバイスが発見された
logArea.append( "デバイス「${result.device.name}」を検出\n")
// スキャンの停止
bluetoothLeScanner?.stopScan(scanCallback)
// デバイスアドレスを取得(接続処理に必要)
val deviceAddress = result.device.address
// ローカルにデバイスアドレスを保存
sharedPreferencesManager.save(SharedPreferencesManager.ADDRESS_KEY, deviceAddress)
logArea.append( "スキャン停止\n")
}
}
}
初回なのでわかりやすくするために接続処理に必要なスキャンで取得したデバイスアドレスをローカルに保存することで次回のスキャン処理をスキップできるように今回は実装してみました。
ただボンディングという機能を使用することでローカルに保存しなくてもデバイスアドレスを保持できる仕組みがあるので興味があればこちらを参考にしてください。
6.接続する
接続するにはBluetoothDevice
を取得してconnectGatt
メソッドを実行します。接続結果はBluetoothGattCallback
型から受け取ることができます。返り値でBluetoothGatt
を取得できるのでプロパティとして保持できるように定義しておきます。
/** GATT (Generic Attribute Profile) プロトコルを使用してデバイスと通信するためのクラス */
private var bluetoothGatt: BluetoothGatt? = null
コールバックのonConnectionStateChange
はペリフェラルとの接続状態が変化する際に呼ばれます。BluetoothProfile.STATE_CONNECTED
なら接続成功なので続いてdiscoverServices
でサービスの検索を行います。
/** デバイスアドレスを元に接続処理 */
private fun connect(address: String) {
val device: BluetoothDevice? = bluetoothAdapter?.getRemoteDevice(address)
if (device == null) {
logArea.append("デバイス取得失敗\n")
return
}
logArea.append("対象デバイスと接続開始\n")
bluetoothGatt = device.connectGatt(this, false, bluetoothGattCallback)
}
/** 接続 & 通信コールバック */
private val bluetoothGattCallback = object : BluetoothGattCallback() {
/** ペリフェラルとの接続状態が変化した際に呼ばれる */
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
// STATE_CONNECTEDなら接続成功
if (newState == BluetoothProfile.STATE_CONNECTED) {
logArea.append("接続成功\n")
// サービスの検索を開始
bluetoothGatt?.discoverServices()
}
}
}
7.サービス一覧と各キャラクタリスティックを取得する
discoverServices
でサービスの検索を実行するとBluetoothGattCallback#onServicesDiscovered
が呼ばれgatt.services
でサービス一覧を取得することが可能です。getService
を使用することで任意のサービス(BluetoothGattService
)を指定して取得することも可能なので、そこからgetCharacteristic
で対象のキャラクタリスティックを取得します。
/** キャラクタリスティック */
private var readCharacteristic: BluetoothGattCharacteristic? = null
private var writeCharacteristic: BluetoothGattCharacteristic? = null
private var notifyCharacteristic: BluetoothGattCharacteristic? = null
private val bluetoothGattCallback = object : BluetoothGattCallback() {
/** ペリフェラルとの接続状態が変化した際に呼ばれる */
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
// 〜〜〜〜
}
/** サービスが検出された時に呼ばれる */
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
super.onServicesDiscovered(gatt, status)
gatt?: return
logArea.append("サービス発見:${gatt.services.size}個 \n")
// 対象のサービス(BluetoothGattService)を取得
val service: BluetoothGattService = gatt.getService(BleServiceConfig.SERVICE_UUID)
readCharacteristic = service.getCharacteristic(BleServiceConfig.READ_CHARACTERISTIC_UUID)
if (readCharacteristic != null) {
logArea.append( "Read Characteristic取得成功\n")
}
writeCharacteristic = service.getCharacteristic(BleServiceConfig.WRITE_CHARACTERISTIC_UUID)
if (writeCharacteristic != null) {
logArea.append( "Write Characteristic取得成功\n")
}
notifyCharacteristic = service.getCharacteristic(BleServiceConfig.NOTIFY_CHARACTERISTIC_UUID)
if (notifyCharacteristic != null) {
logArea.append( "Notify Characteristic取得成功\n")
}
}
}
これで端末(セントラル)が対象のペリフェラルと接続し、各キャラクタリスティックを取得できたことでデータの通信が可能な状態になりました。ここからは各キャラクタリスティックの使い方を見ていきます。
Readキャラクタリスティック
「Readキャラクタリスティック」は名前の通りペリフェラルから情報を取得するためのキャラクタリスティックです。値を読み取るにはreadCharacteristic
メソッドを実行します。
/** Readキャラクタリスティックの実行 */
private fun read() {
bluetoothGatt?.let { gatt ->
logArea.append("Readメソッド実行\n")
gatt.readCharacteristic(readCharacteristic)
}
}
読み取った値はBluetoothGattCallback#onCharacteristicRead
から取得することが可能です。読み取れるデータはByteArray
型なので文字列に直したい場合は以下のように実装する必要があります。
/** ④ Readキャラクタリスティック */
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
status: Int
) {
super.onCharacteristicRead(gatt, characteristic, value, status)
if (status == BluetoothGatt.GATT_SUCCESS) {
logArea.append("読み取り成功\n")
try {
// UTF-8エンコーディングを使用してバイト配列を文字列に変換
val data = String(value, Charsets.UTF_8)
// 変換された文字列を使用
logArea.append("読み取りデータ:${data}\n")
} catch (e: UnsupportedEncodingException) {
// エンコーディングがサポートされていない場合の例外処理
e.printStackTrace()
}
} else {
logArea.append("読み取り失敗\n")
}
}
読み取った値はBluetoothGattCallback#onCharacteristicRead
から取得することが可能です。読み取れるデータはByteArray
型なので文字列に直したい場合は以下のように実装する必要があります。
Writeキャラクタリスティック
「Writeキャラクタリスティック」は名前の通りペリフェラルに対して情報を書き込みするためのキャラクタリスティックです。値を書き込むためにはwriteCharacteristic
メソッドを実行します。TIRAMISU
でコードが少し分岐します。また書き込む値はByteArray
型です。
/** Writeキャラクタリスティックの実行 */
private fun write() {
bluetoothGatt?.let { gatt ->
logArea.append("Write実行\n")
// 文字列
val str = "Hello World"
try {
// UTF-8エンコーディングを使用して文字列をバイト配列に変換
val byteData = str.toByteArray(Charsets.UTF_8)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
writeCharacteristic ?: return
gatt.writeCharacteristic(writeCharacteristic!!, byteData, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
writeCharacteristic?.value = byteData
writeCharacteristic?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
gatt.writeCharacteristic(writeCharacteristic)
}
} catch (e: UnsupportedEncodingException) {
// エンコーディングがサポートされていない場合の例外処理
e.printStackTrace()
}
}
}
書き込みの成否はBluetoothGattCallback#onCharacteristicWrite
から取得することが可能です。BluetoothGatt.GATT_SUCCESS
であれば書き込みは成功です。
/** Writeキャラクタリスティック */
override fun onCharacteristicWrite(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?,
status: Int
) {
if (status == BluetoothGatt.GATT_SUCCESS) {
logArea.append("書き込み成功\n")
} else {
logArea.append("書き込み失敗\n")
}
}
Notifyキャラクタリスティック
「Notifyキャラクタリスティック」は名前の通りペリフェラルからの通知を検知するためのキャラクタリスティックです。通知を受け取るためにはwriteCharacteristic
メソッドを実行します。TIRAMISU
でコードが少し分岐します。また書き込む値はByteArray
型です。
/** Notifyキャラクタリスティックの実行(観測開始) */
private fun observeNotify() {
bluetoothGatt?.let { gatt ->
logArea.append("Notify観測開始\n")
gatt.setCharacteristicNotification(notifyCharacteristic, true)
}
}
通知を検知するとBluetoothGattCallback#onCharacteristicChanged
から値を取得することが可能です。
/** Notifyキャラクタリスティック */
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
logArea.append("Notify変化検知\n")
}
※ しかしこちらのみ動作が確認できませんでした。動作確認が出来次第修正予定です。
切断処理の実装
ペリフェラルとの切断処理はdisconnect
メソッドを使用します。完了後はリソースを適切に開放する必要があります。
private fun disconnect() {
// デバイスとの接続を解除
bluetoothGatt?.disconnect()
// リソースを解放
bluetoothGatt?.close()
// GATTインスタンスをクリア
bluetoothGatt = null
logArea.text = "切断\n"
}
今回の全体のコードはGitHubに掲載していますので参考にしてください。
まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。
ご覧いただきありがとうございました。