【Kotlin/Android】Bluetooth接続アプリCentral側の実装方法!

【Kotlin/Android】Bluetooth接続アプリCentral側の実装方法!

この記事からわかること

  • Android Studio/KotlinBluetooth接続アプリ実装方法
  • Central側の実装
  • ServiceCharacteristic取得方法
  • peripheralから取得したり書き込むには?
  • peripheralから更新通知受け取る(notify)には?

index

[open]

\ アプリをリリースしました /

みんなの誕生日

友達や家族の誕生日をメモ!通知も届く-みんなの誕生日-

posted withアプリーチ

環境

今回はAndroidアプリでBluetoothでの接続機能を持ったCentral側の実装方法をまとめていきます。iOS側にはなってしまいますが実際の接続テストが行えるようにPeripheral側の実装も公開しているので参考にしてみてください。

AndroidのBluetooth機能の実装

公式リファレンス:Bluetooth

Androidアプリでは「Classic Bluetooth」と「Bluetooth Low Energy(BLE)」の両方をサポートするBluetoothスタックがデフォルトで用意されています。これにより比較的簡単にBluetooth機能を実装することができるようになっています。

セントラルやペリフェラルという言葉が出てきましたがBluetoothで接続する側をセントラル接続される側をペリフェラルと呼びます。よくあるBluetoothイヤホンで例えるとスマホがセントラルでイヤホンがペリフェラルになります。

AndroidアプリではBluetooth 4.1以降に対応しているデバイスでは「セントラル側」、「ペリフェラル側」問わず実装できるようになっています。古い端末だとペリフェラル側の実装はできないので注意してください。

今回は「Central側」の実装をしていきたいと思います。

実装するにあたって

Android Studioで実装するにあたってエミュレーターではBluetooth機能をサポートしていないのでテストする際には実機が必要になります。ペリフェラル側との連携を試したい場合は実機が2台に必要になるので注意してください。

Central機能の実装方法

流れ

  1. AndroidManifest.xmlに権限を追加
  2. Bluetooth有効管理クラスの実装
  3. ペリフェラル側のUUID定義
  4. BluetoothAdapterの取得
  5. スキャンの実装
  6. 接続する
  7. サービス一覧と各キャラクタリスティックを取得する

また今回の全体のコードは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に掲載していますので参考にしてください。

まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。

ご覧いただきありがとうございました。

searchbox

スポンサー

ProFile

ame

趣味:読書,プログラミング学習,サイト制作,ブログ

IT嫌いを克服するためにITパスを取得しようと勉強してからサイト制作が趣味に変わりました笑
今はCMSを使わずこのサイトを完全自作でサイト運営中〜

New Article

index