その後のその後

iOSエンジニア 堤 修一のブログ github.com/shu223

Classic Bluetooth について iOS アプリ開発者ができること

Bluetooth Low Energy については Core Bluetooth で色々と制御できますが、Classic Bluetooth(以降クラシックBT)については基本的に開発者は制御できません*1



そう、確かにアプリ内からクラシックBTデバイスと接続したり、データを送るとか送らないとか制御したり、通信を切断したり、といったことはできないのですが、そんな中でもアプリ側から一切クラシックBTデバイスの存在を感知することができないのかというとそうでもなく、いくつかの「口」はあります。


というわけでそういう「口」を集めてみました。他にもあればぜひ教えてください!

ボタン操作イベントの取得

BTイヤフォンの再生・停止等のボタン操作イベントを取得するには、次のように取得開始を宣言し、かつファーストレスポンダになります。

UIApplication.sharedApplication().beginReceivingRemoteControlEvents()
self.becomeFirstResponder()


イベントが飛んで来ると、`remoteControlReceivedWithEvent:` が呼ばれます。

override func remoteControlReceivedWithEvent(event: UIEvent?) {
    guard event?.type == .RemoteControl else { return }
    
    if let event = event {

        switch event.subtype {
            
        case .RemoteControlPlay:
            print("Play")
        case .RemoteControlPause:
            print("Pause")
        case .RemoteControlStop:
            print("Stop")
        case .RemoteControlTogglePlayPause:
            print("TogglePlayPause")
        case .RemoteControlNextTrack:
            print("NextTrack")
        case .RemoteControlPreviousTrack:
            print("PreviousTrack")
        case .RemoteControlBeginSeekingBackward:
            print("BeginSeekingBackward")
        case .RemoteControlEndSeekingBackward:
            print("EndSeekingBackward")
        case .RemoteControlBeginSeekingForward:
            print("BeginSeekingForward")
        case .RemoteControlEndSeekingForward:
            print("EndSeekingForward")
        default:
            print("Others")
        }
    }
}


ただし、オーディオセッションカテゴリによっては、イベントがアプリまで飛んでこなくなります(OSでは拾っているが、アプリまで渡してくれない)。試したところでは、`AVAudioSessionCategoryPlayback` では取得可、`AVAudioSessionCategoryPlayAndRecord` では取得不可。

MPRemoteCommandCenter

上記の方法は今でも deprecated になってはいないものの、リファレンスによると、

In iOS 7.1 and later, use the shared MPRemoteCommandCenter object to register for remote control events. You do not need to call this method when using the shared command center object.

と、iOS 7.1 以降では `MPRemoteCommandCenter` を使うように書かれています。

MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
[commandCenter.playCommand addTargetUsingBlock:^(MPRemoteCommandEvent *event) {
    // Begin playing the current track.
    [[MyPlayer sharedPlayer] play];
}


参考: Remote Control Events - Event Handling Guide for iOS

要注意!

記事を書きながら、念のため手元でシンプルなデモをつくって試したところこれがなぜか動作しない・・・!(BTイヤフォンのボタンイベントが飛んでこない)以前、試したときは動いたのに・・・


もちろんファーストレスポンダになるのを忘れる、というよくあるハマりポイントはクリアしています。過去に試したときと条件を合わせるため、同じBTデバイスを使い、iOS 8 で、同じオーディオセッションカテゴリ(Playback)を用いてもダメ。


で、ふと気付きました。


システムの音楽プレイヤーがボタンイベントを捕まえている状況だとアプリまでイベントが飛んで来ないのではないかと。ここでいう「システムの音楽プレイヤー」とは、iOS標準のミュージックアプリおよび `[MPMusicPlayerController systemMusicPlayer]` でアクセスできるプレイヤーのことです。


そこで AVAudioPlayer を使ってアプリ内で音楽を再生しつつクラシックBTデバイスのボタンを操作してみたところ・・・動作しました!


というわけで上述の仮説で合っているようです。

接続/切断をフック

これもイヤフォンやヘッドセット等のオーディオデバイス限定にはなりますが、そういったデバイスが接続/切断されるとき、オーディオの入力・出力ルート(iOS の世界では Audio Route と呼ばれる)の変更通知 `AVAudioSessionRouteChangeNotification` が飛んできます。


というわけでこれを監視しておき、

NSNotificationCenter.defaultCenter().addObserverForName(
    AVAudioSessionRouteChangeNotification,
    object: nil,
    queue: nil) { (notification) -> Void in
        
        // 通知を受け取ったときの処理
}


この状態でイヤフォンやヘッドセットをつなぐと、オーディオルート変更通知が飛んでくるので、そこで今のオーディオルートの入力ポートと出力ポートがそれぞれ何なのか、ということを知ることができます。

let route  = AVAudioSession.sharedInstance().currentRoute
let inPort  = route.inputs.first
let outPort = route.outputs.first


ここで、`AVAudioSessionPortDescription` の `portType` からそのポートのタイプを取得でき、たとえば A2DP プロファイルのBTイヤフォンを繋いだ場合は、`portType` は `AVAudioSessionPortBluetoothA2DP` (中身の文字列は `BluetoothA2DPOutput`)に、HFP プロファイルのBTヘッドセットを繋いだ場合は `AVAudioSessionPortBluetoothHFP` になるので、これを用いればクラシックBTデバイスの接続・切断をフックできることになります。


ただ、この方法も万能ではなくて、音楽再生中とか、録音中とか、オーディオデバイスが機能している状態じゃないと変更通知が飛んできません。(詳細な条件は要調査)


いつでもどうしても確実にフックしたい場合は `currentRoute` をポーリングするとかになるのかと思います(が、やったことないのでこれも何か思わぬ落とし穴があるかもしれません)。

接続デバイス名の取得

前項にて現在のオーディオルート情報 `AVAudioSessionRouteDescription` を取得し、そこから入力/出力ポートの情報を保持する `AVAudioSessionPortDescription` オブジェクトを取り出しました。


この `portName` プロパティから、デバイス名を取得できます。

let route  = AVAudioSession.sharedInstance().currentRoute
let outPort = route.outputs.first
if let outPort = outPort {
    print("out port name:\(outPort.portName)")
}

(BTイヤフォン接続時の実行結果)

out port name:Jabra Rox Wireless v2.5.4


(これ以外に、`UID` というプロパティもありますが、`xx:xx:xx:xx:xx:xx-tacl` とMACアドレスらしきものが入っている場合があったり、単に `Wired Headphones` と入っている場合があったりで、統一性がなく何かの判別には使いづらいかなと感じました)

入力・出力デバイスの選択

`AVAudioSessionRouteDescription` は `inputs`、 `outputs` と、複数の入出力ポートを持てるようになっています。AVAudioSession に `setPreferredInput:` というメソッドがあるので、入力デバイスは明示的に選択できそうです。

/* Select a preferred input port for audio routing. If the input port is already part of the current audio route, this will have no effect.
   Otherwise, selecting an input port for routing will initiate a route change to use the preferred input port, provided that the application's
   session controls audio routing. Setting a nil value will clear the preference. */

public func setPreferredInput(inPort: AVAudioSessionPortDescription?) throws


ただ `setPreferredOutput:` というメソッドは見当たらないので、出力デバイスが複数ある場合はどうするんでしょうか。inputと連動するのか、別のメソッドがあるのか。まだこのあたりは試したことないので、今度挙動を確かめてみます。

つづく

AVAudioSession のヘッダを見ているとまだありそうなので、引き続き何か分かり次第追記していきます。


*1:MFiプログラム締結時(External Accessory framework 利用可能時)は例外