Androidでイヤホンの接続を検知したい

# イヤホンの接続を検出したい
Androidで音楽アプリを作っているのですが（下記参照）、イヤホンの接続時に自動で音楽再生待機状態になって、Bluetoothイヤホンの場合なんか接続した瞬間にそのまま再生ボタンを押せば音楽再生できれば神じゃないかと。そう思いまして実装してみました。（イヤホンかイヤフォンか。どっちが正しいのかは知りません）

最初、この記事は「イヤホンの抜き差しを検出したい」ってタイトルだったんですけど、イヤホンの「抜け」は`AudioManager.ACTION_AUDIO_BECOMING_NOISY`をキャッチするだけで簡単なんでやめました。


作っているのはこのアプリ。実際にどのような動作になるのかはアプリをインストールして確認してほしい。設定方法は下記の「解説」部分に書いてあるのでそちら参照。

[ミュージック -歌詞付き音楽プレイヤー-](https://play.google.com/store/apps/details?id=caios.android.music)

# 問題
単にイヤホンの接続といっても色々なパターンがある。例えば有線のイヤホン接続やワイヤレスのBluetooth接続なんかもある。それらのすべてに対応したい。

-> 有線とBluetoothで処理を分ける必要がある

# 方針
今回はイヤホンの接続時に音楽再生待機状態にしたいので、アプリが起動していないときに接続される可能性も鑑みなければならない。故に、ForegrondServiceでの実装となる。

### 有線イヤホンの場合
`AudioManager.ACTION_HEADSET_PLUG`を拾えばいいらしい。その時にintentにくっついてきているstateを取り出して、接続状態を調べる。ここがちょっと不確かなのだが、以下のような感じらしい。

|state               |状態                    |
|:------------------:|:----------------------:|
|0                   |イヤホンが抜けている     |
|1                   |イヤホンが刺さっている   |
|2                   |マイク付きイヤホンが刺さっている|

ということでstateが0より大きいかで判断すればいいようだ。情報が不確かなので間違ていたらコメントよろしくお願いします。

### Bluetoothイヤホンの場合
Bluetoothの場合は一段とめんどくさい。とりあえずBluetoothへのアクセスを実行するために`android.permission.BLUETOOTH`を追加する。

そして、Bluetoothの接続・切断を検知するには`BluetoothProfile.ServiceListener`を`getProfileProxy`で登録すればいいらしい。接続時に呼ばれるリスナーの引数にある`BluetoothProfile`より、接続したのがBluetoothイヤホン（ヘッドセット）なのかを判定すればいい。

...と思っていた。実際途中までこの方針でコードを組んでいたのだが、ここであることに気づく。そう、`BluetoothProfile.ServiceListener`が意味わからんタイミングで呼ばれるのである。接続した瞬間に切断したり、接続が2回呼ばれたりと。

これはたまらんと思い、`BluetoothProfile.ServiceListener`は接続された（接続される予定の・接続されている）デバイスがイヤホンなのかを判定するためだけに使用することにした。では、接続されたと最終的に検知するにはどのようにしたらいいのか。

これは`BluetoothDevice.ACTION_ACL_CONNECTED`をキャッチすればいい。先ほどの`BluetoothProfile.ServiceListener`よりもわずかに遅く呼ばれるため、`BluetoothProfile.ServiceListener`で`BluetoothProfile`を保持しておけば、`BluetoothDevice.ACTION_ACL_CONNECTED`をキャッチしたときに最終的な判定ができるという仕組みだ。

# 実装
```Kotlin
class MonitorHeadsetService : Service() {
    
    private var currentBluetoothHeadset: BluetoothHeadset? = null
    private var isBluetoothHeadsetConnected = false
    
    private val monitorHeadsetFilter = IntentFilter().apply {
        addAction(AudioManager.ACTION_HEADSET_PLUG)
        addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
        addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
    }
    
    private val monitorHeadsetReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            when (intent?.action) {
                AudioManager.ACTION_HEADSET_PLUG        -> {
                    if (intent.getIntExtra("state", -1) > 0) {
                        onInsertHeadset()
                    }
                }
                BluetoothDevice.ACTION_ACL_CONNECTED    -> {
                    Thread.sleep(2000)
                    
                    Log.d(TAG, "Broadcast: ACTION_ACL_CONNECTED")
                    if (currentBluetoothHeadset?.connectedDevices?.size ?: 0 > 0) {
                        isBluetoothHeadsetConnected = true
                        onInsertHeadset()
                    }
                }
                BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
                    Log.d(TAG, "Broadcast: ACTION_ACL_DISCONNECTED")
                    isBluetoothHeadsetConnected = false
                }
            }
        }
    }
    
    private val bluetoothPolicyListener = object : BluetoothProfile.ServiceListener {
        override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
            if (profile == BluetoothProfile.HEADSET) {
                Log.d(TAG, "BluetoothProfile onServiceConnected")
                
                currentBluetoothHeadset = proxy as BluetoothHeadset
                isBluetoothHeadsetConnected = (currentBluetoothHeadset?.connectedDevices?.size ?: 0 > 0)
            }
        }
        
        override fun onServiceDisconnected(profile: Int) {
            if (profile == BluetoothProfile.HEADSET) {
                Log.d(TAG, "BluetoothProfile onServiceDisconnected")
                
                currentBluetoothHeadset = null
            }
        }
    }
    
    override fun onBind(p0: Intent?): IBinder? = null
    
    override fun onCreate() {
        super.onCreate()
        
        Log.d(TAG, "MonitorHeadsetService onCreate")
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            startForeground(MONITOR_HEADSET_SERVICE_ID, createMonitorNotify())
        }
    }
    
    
    override fun onDestroy() {
        super.onDestroy()
        
        try {
            unregisterReceiver(monitorHeadsetReceiver)
        }
        catch (e: Exception){
            e.printStackTrace()
        }
    }
    
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
        
        registerReceiver(monitorHeadsetReceiver, monitorHeadsetFilter)
        bluetoothAdapter.getProfileProxy(this, bluetoothPolicyListener, BluetoothProfile.HEADSET)
        
        return START_STICKY
    }
    
    private fun onInsertHeadset() {
        Log.d(TAG, "Headset is inserting.")
        
        //音楽再生待機状態へ
    }
       
    private fun createMonitorNotify(): Notification {
        val notifyTitle = getString(R.string.mMonitorNotifyTitle)
        val notifyText = getString(R.string.mMonitorNotifyText)
        val notifyDescription = getString(R.string.mMonitorNotifyDescription)
        val notifyChannelName = getString(R.string.mMonitorNotifyChannelName)
        
        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && notificationManager.getNotificationChannel(MONITOR_HEADSET_NOTIFY_ID.toString()) == null) {
            val channel = NotificationChannel(MONITOR_HEADSET_NOTIFY_ID.toString(), notifyChannelName, NotificationManager.IMPORTANCE_LOW).apply {
                description = notifyDescription
            }
            
            notificationManager.createNotificationChannel(channel)
        }
        
        val stackBuilder = TaskStackBuilder.create(this).addNextIntent(Intent(this, MainActivity::class.java))
        val pendingIntent = stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
        
        val notificationBuilder = NotificationCompat.Builder(this, MONITOR_HEADSET_NOTIFY_ID.toString()).apply {
            setSmallIcon(R.drawable.ic_headset)
            setContentTitle(notifyTitle)
            setContentText(notifyText)
            setAutoCancel(false)
            setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
            setContentIntent(pendingIntent)
        }
        
        return notificationBuilder.build()
    }
    
    companion object {
        const val MONITOR_HEADSET_SERVICE_ID = 72
        const val MONITOR_HEADSET_NOTIFY_ID = 69
    }
}
```

# 解説
...とはいっても「方針」の部分で大部分を話しているので省略。

が、ポイントを一つ。先程、`BluetoothDevice.ACTION_ACL_CONNECTED`のほうが飛んでくるのが遅いと書いたけれど、実機で確認していると「遅い」というより「同時」で、`BluetoothProfile`を保持するよりも早く`BluetoothDevice.ACTION_ACL_CONNECTED`が飛んできてしまうという事態が何度かあった。

そのため、`BluetoothDevice.ACTION_ACL_CONNECTED`の処理部分では2秒の待機処理を挟むことにした。本当はこのような書き方はよくないと思うのだけど、2秒も待機すればほぼ大丈夫だと思う。もっと良い方法がある方はコメントお願いします。

# 感想
色々と腑に落ちない部分はあったけれど、以外にもあっさりと解決してしまった。宣伝ではないけど、最終的にどのような動作になったのか確認したい方は、下記のアプリで確認できる。設定->全般->ヘッドセットの状態を検出をONにしてから、イヤホンを抜き差ししてもらいたい。アプリがKillされていてもサービスが起動状態になっていれば音楽再生待機状態に移行すると思う（通知が来る）。

この記事が参考になったなら、ぜひアプリをインストールしてレビューしてほしい。

[ミュージック -歌詞付き音楽プレイヤー-](https://play.google.com/store/apps/details?id=caios.android.music)