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

13 min read読了の目安(約11900字

こんにちは、いかの塩辛です。
この記事は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

本題のkotlinコードです。

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周りに悩む人や現在開催中のハッカソンでの知見共有になることを祈って。