🐾

Androidで周辺のBluetooth端末を検知する

2020/12/16に公開

こんにちは、いかの塩辛です。
この記事はFUN Advent Calendar 2020 13日目の記事です。

初めに

僕は現在、funlocksという公立はこだて未来大学にて行われている学内ハッカソンに参加しています。
このハッカソンのテーマは「コロナ禍に大学などで使いたいプロダクト」というものです。
そこで、僕はAndroidアプリの開発をすることになりました。
仕様の一部に、周辺のBluetooth端末を検知するというのがあったので、頑張って実装しました。
その知見共有のための記事みたいなものです。
記事とかじゃなくてリポジトリ見せろ!という方はこちら

筆者環境

  • Android studio: 4.1.1
  • Pixel 4a
  • MinSdkVersion 19

AndroidManifest.xml

Bluetoothを使用するためにManifestを記述しましょう。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="your.package.name">

    <!-- Bluetooth関係のパーミッション -->
    <uses-permission android:name="android.permission.BLUETOOTH"/>
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>

    <!-- Bluetoothを使うための位置情報パーミッション -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
                                    ・
                                    ・
	                            ・

上記の四つの<uses-permission>の設定を行ってください。

activity_main.xml

全体のレイアウトやらデザインを決めるファイルですが、ユーザーがクリックするButton、取得したデバイスのデータを表示するために、ScrollViewに乗ったLinearLayoutを用意するだけです。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:id="@+id/linearLayout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <Button
            android:id="@+id/update_button"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:layout_weight="1"
            android:text="Update" />

        <Button
            android:id="@+id/cancel_button"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:layout_weight="1"
            android:text="Cancel" />
    </LinearLayout>

    <ScrollView
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginHorizontal="8dp"
        android:padding="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/linearLayout">

        <LinearLayout
            android:id="@+id/device_num_list"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"></LinearLayout>
    </ScrollView>

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.kt

import

細かい部分は適宜importして使ってほしいですがひとまず以下の二つをimportしてください。

import android.bluetooth.*
import android.location.LocationManager

権限

Bluetoothを使用するためにBluetoothと位置情報の権限周りのあれこれをします。
下記二つの関数で位置情報とBluetoothの権限をリクエストします。
この二つはonResume()に置いておくことで、アプリに復帰した時に権限がOffの場合でも滞りなくアプリを使えます。

    private fun requestLocationFeature() {
        if (isGpsEnabled) {
            return
        }
        //startActivityForResult(Intent(LocationManager.PROVIDERS_CHANGED_ACTION), MY_REQUEST_CODE)
        startActivityForResult(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS), MY_REQUEST_CODE)
    }

    private fun requestBluetoothFeature() {
        if (mBluetoothAdapter.isEnabled) {
            return
        }

        // Bluetoothの有効化要求
        var enableBluetoothIntent: Intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
        startActivityForResult(
                enableBluetoothIntent,
                REQUEST_ENABLEBLUETOOTH
        )
    }

レシーバーの登録

さて、権限周りができたので次は情報を受け取るためのレシーバーを作成しましょう。
基本的にはコメントに書いてある通りですが、何かしらのアクションが返ってきた時にonReceive()が呼ばれるので、アクションの種類をwhenで判別しそれぞれに必要そうな処理を書いてください。
この記事では検知時に検知したデバイスの情報をTextViewに入れて表示させています。また、検知の開始、終了時にはToastとLogを出しています。

private val receiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            val action: String? = intent.action
            when (action) {
	        // 主にデバイスを見つけた時
                BluetoothDevice.ACTION_FOUND -> {
                    val device: BluetoothDevice? =
                            intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
                    if (device == null) {
                        Log.d("nullDevice", "Device is null")
                        return
                    }

                    val deviceName = device?.name
                    val deviceHardwareAddress = device?.address // MAC address
                    val deviceUUID = device?.uuids
                    val deviceRssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE)
                    Log.d("device", "Device name: ${deviceName}, address:${deviceHardwareAddress}, UUID:${deviceUUID}, RSSI:${deviceRssi}")

                    // MainActivityのScrollViewに検知したデバイスのデータを表示
                    val textView = TextView(context)
                    textView.text = "Device name: ${deviceName}\naddress:${deviceHardwareAddress}, UUID:${deviceUUID}, RSSI:${deviceRssi}"
                    device_num_list.addView(textView)

                    return
                }
		// 検知を開始した時
                BluetoothAdapter.ACTION_DISCOVERY_STARTED -> {
                    Toast.makeText(
                            context,
                            "Bluetooth検出開始",
                            Toast.LENGTH_SHORT
                    ).show()
                    Log.d("discoveryStart", "Discovery Started")
                    mScanning = true
                    return
                }
		// 検知が終了した時
                BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> { //cancelDiscoveryでも呼ばれる
                    mScanning = false

                    Toast.makeText(
                            context,
                            "Bluetooth検出終了",
                            Toast.LENGTH_SHORT
                    ).show()
                    Log.d("discoveryFinish", "Discovery finished")
                }
            }
        }
    }

作成したレシーバーをonCreate()内で登録してください。
今回は検知開始、終了も行いたかったので三つ登録していますが、必要に応じたレシーバーを登録してください。

        // Receiverの登録
        registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND))
        registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_STARTED))
        registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED))

検知

レシーバーの作成が終わったので実際に検知を行えという命令を書いていきます。
と言ってもある程度簡単で、BluetoothAdapterに対して命令する感じです。
まず下記のようにonCreate()内でBluetoothManagerからBluetoothAdapterを取得します。
権限の時に作成したrequestBluetoothFeature()でも使用するため、少なくともこの関数の前で取得します。

        var bluetoothManager: BluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        mBluetoothAdapter = bluetoothManager.getAdapter()

検知の開始と終了は、取得したBluetoothAdapter(この記事ではmBluetoothAdapter)を使って、

mBluetoothAdapter.startDiscovery()
mBluetoothAdapter.cancelDiscovery()

を呼ぶだけでできます。まあ、Buttonのクリックリスナーに入れたりが普通なのかなと思います。

上記二つの関数は返り値がBooleanで、検知の開始と終了が成功したかの判定ができるため、確認手段として下記のような方法も取れます。

            if (mBluetoothAdapter.startDiscovery()) {
                Log.d("startSuccess", "startDiscovery() success")
            } else {
                Log.d("startFailed", "startDiscovery() failed")
            }
	    
            if (mBluetoothAdapter.cancelDiscovery()) {
                Log.d("cancelSuccess", "cancelDiscovery() success")
            } else {
                Log.d("cancelFailed", "cancelDiscovery() failed")
            }

最終的に

最終的に下記と同じようなコードになれば大丈夫だと思います。

package your.package.name

import android.app.Activity
import android.bluetooth.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.view.View
import android.widget.*

class MainActivity : AppCompatActivity() {
    // ここから変数
    private val REQUEST_ENABLEBLUETOOTH: Int = 1 // Bluetooth機能の有効化要求時の識別コード
    private val MY_REQUEST_CODE: Int = 2 // 位置情報要求時の識別コード
    private lateinit var mBluetoothAdapter: BluetoothAdapter // アダプター
    private var isGpsEnabled: Boolean = false // GPSの許可

    private lateinit var device_num_list: LinearLayout // レイアウト
    
    private var mScanning: Boolean = false // スキャン中かどうかのフラグ

    private val receiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            val action: String? = intent.action
            when (action) {
                BluetoothDevice.ACTION_FOUND -> {
                    // Discovery has found a device. Get the BluetoothDevice
                    // object and its info from the Intent.
                    val device: BluetoothDevice? =
                            intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
                    if (device == null) {
                        Log.d("nullDevice", "Device is null")
                        return
                    }

                    val deviceName = device?.name
                    val deviceHardwareAddress = device?.address // MAC address
                    val deviceUUID = device?.uuids
                    val deviceRssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE)
                    Log.d("device", "Device name: ${deviceName}, address:${deviceHardwareAddress}, UUID:${deviceUUID}, RSSI:${deviceRssi}")

                    // MainActivityのScrollViewに検知したデバイスのデータを表示
                    val textView = TextView(context)
                    textView.text = "Device name: ${deviceName}\naddress:${deviceHardwareAddress}, UUID:${deviceUUID}, RSSI:${deviceRssi}"
                    device_num_list.addView(textView)

                    return
                }
                BluetoothAdapter.ACTION_DISCOVERY_STARTED -> {
                    Toast.makeText(
                            context,
                            "Bluetooth検出開始",
                            Toast.LENGTH_SHORT
                    ).show()
                    Log.d("discoveryStart", "Discovery Started")
                    mScanning = true
                    return
                }
                BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> { //cancelDiscoveryでも呼ばれる
                    mScanning = false

                    Toast.makeText(
                            context,
                            "Bluetooth検出終了",
                            Toast.LENGTH_SHORT
                    ).show()
                    Log.d("discoveryFinish", "Discovery finished")
                }
            }
        }
    }

    // ここから関数
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        device_num_list = findViewById(R.id.device_num_list)

        var bluetoothManager: BluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        mBluetoothAdapter = bluetoothManager.getAdapter()
        if (null == mBluetoothAdapter) {    // Android端末がBluetoothをサポートしていない場合
            Toast.makeText(
                    this,
                    R.string.bluetooth_is_not_supported,
                    Toast.LENGTH_SHORT
            ).show()
            finish()
            return
        }

        // 位置情報許可の確認
        var locationManager: LocationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
        isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER);

        // Receiverの登録
        registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND))
        registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_STARTED))
        registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED))

        // Update Buttonのクリックリスナー設定
        var updateButton: Button = findViewById(R.id.update_button)
        updateButton.setOnClickListener(View.OnClickListener {
            Log.d("clickUpdateButton", "Update Button Clicked!!")

            // scanning中であったらcancelDiscovery()を挟む
            if (mScanning && mBluetoothAdapter.cancelDiscovery()) {
                Log.d("cancelSuccess", "cancelDiscovery() success")
            } else {
                Log.d("cancelFailed", "cancelDiscovery() failed")
            }

            // scanning
            if (!mScanning && mBluetoothAdapter.startDiscovery()) {
                Log.d("startSuccess", "startDiscovery() success")
            } else {
                Log.d("startFailed", "startDiscovery() failed")
            }

            Toast.makeText(
                    this,
                    "Update Button Clicked!!",
                    Toast.LENGTH_SHORT
            ).show()
        })

        // Cancel Buttonのクリックリスナー設定
        var cancelButton: Button = findViewById(R.id.cancel_button)
        cancelButton.setOnClickListener(View.OnClickListener {
            Log.d("clickCancelButton", "Cancel Button Clicked!!")
            if (mBluetoothAdapter.cancelDiscovery()) {
                Log.d("cancelSuccess", "cancelDiscovery() success")
            } else {
                Log.d("cancelFailed", "cancelDiscovery() failed")
            }

            // ScrollViewを全消し
            device_num_list.removeAllViews()

            Toast.makeText(
                    this,
                    "Cancel Button Clicked!!",
                    Toast.LENGTH_SHORT
            ).show()
        })
    }

    private fun requestLocationFeature() {
        if (isGpsEnabled) {
            return
        }
        //startActivityForResult(Intent(LocationManager.PROVIDERS_CHANGED_ACTION), MY_REQUEST_CODE)
        startActivityForResult(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS), MY_REQUEST_CODE)
    }


    private fun requestBluetoothFeature() {
        if (mBluetoothAdapter.isEnabled) {
            return
        }

        // Bluetoothの有効化要求
        var enableBluetoothIntent: Intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
        startActivityForResult(
                enableBluetoothIntent,
                REQUEST_ENABLEBLUETOOTH
        )
    }

    override fun onResume() {
        super.onResume()
        requestLocationFeature()
        requestBluetoothFeature()
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        when (requestCode) {
            REQUEST_ENABLEBLUETOOTH -> {
                if (Activity.RESULT_CANCELED == resultCode) {
                    Toast.makeText(
                            this,
                            R.string.bluetooth_is_not_working,
                            Toast.LENGTH_SHORT
                    ).show()
                    finish()
                    return
                }
            }
            MY_REQUEST_CODE -> {
                if (Activity.RESULT_CANCELED == resultCode) {
                    Toast.makeText(
                            this,
                            R.string.gps_is_not_working,
                            Toast.LENGTH_SHORT
                    ).show()
                    finish()
                    return
                }
            }
        }
        super.onActivityResult(requestCode, resultCode, data)
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d("discovery", "${mBluetoothAdapter.isDiscovering}")
        // Don't forget to unregister the ACTION_FOUND receiver.
        unregisterReceiver(receiver)
        mBluetoothAdapter.cancelDiscovery()
        mScanning = false
    }
}

結果

とまあ、こんな感じでUpdateボタンを押すと検知が始まり、検知した端末をViewに流し込んでいます。
名前やUUIDの取り方がいまいちわかっていませんが、macアドレス(端末識別アドレス)とRSSI(電波強度)は取れるため、どんな端末がおよそどれくらいの距離にいるかの指標にはなると思います。

最後に

アドベントカレンダー大幅な遅刻してすみませんでした。
後説明する気あんのかみたいな雑な説明記事ですみません。多分時間に余裕できたら修正入れます。

この先Bluetooth周りに悩む人や現在開催中のハッカソンでの知見共有になることを祈って。

Discussion