[iOS] Bluetooth許可アラートを任意のタイミングで出したい

# はじめに

## Bluetooth許可アラートとは
これです。
これを任意のタイミングで表示できるようにしたい。

<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388166/14747d81-9452-6bcb-7f07-ab8acd1298e9.png">

## 背景
特定の画面のみでBluetoothを利用するアプリでは、「利用する画面に遷移した時」や「使用するボタンを押した時」等のタイミングでBluetooth許可アラートを出すのが何にBluetoothを利用するのかわかりやすくてユーザー体験的に良いと思います。
そういった場面に対応するためには、**任意のタイミングでBluetooth許可アラートを出すことができるようにする必要があります。**

# 基礎知識
## CBCentralManager

Bluetooth周りを扱うためには`CBCentralManager`を使います。

公式ドキュメント

https://developer.apple.com/documentation/corebluetooth/cbcentralmanager/

`CLLocationManager`(位置情報周りを扱うためのクラス)には`requestWhenInUseAuthorization()`という能動的に許可アラート表示を行うメソッドが存在しますが、見たところ、**`CBCentralManager`には能動的に許可アラート表示を行うメソッドが存在しません。**
ではどのタイミングで許可アラートが表示されるのでしょうか？

## 許可アラート表示タイミングの調査

まず簡単なプログラムで許可アラートが表示されるタイミングを確認してみましょう。

### テストアプリ

ボタンを押したときに端末のBluetoothの状態を表示する簡単なアプリです。

```swift
import UIKit
import CoreBluetooth

private extension CBManagerState {

    var name: String {
        switch self {
        case .unknown: return "unknown"
        case .resetting: return "resetting"
        case .unsupported: return "unsupported"
        case .unauthorized: return "unauthorized"
        case .poweredOff: return "poweredOff"
        case .poweredOn: return "poweredOn"
        @unknown default: return "unknown"
        }
    }
}

final class ViewController: UIViewController {

    // MARK: - Outlet

    @IBOutlet private weak var statusLabel: UILabel!

    // MARK: - Property

    private var centralManager: CBCentralManager!

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        print("manager init func: \(#function)")
        centralManager = CBCentralManager()
        centralManager.delegate = self
    }

    // MARK: - Action

    @IBAction private func buttonAction(_ sender: UIButton) {
        print("maneger used func: \(#function)")
        statusLabel.text = centralManager.state.name
    }
}

// MARK: - CBCentralManagerDelegate

extension ViewController: CBCentralManagerDelegate {

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        print("manager setuped func: \(#function)")
    }
}
```

### 実行

![trim.EE07EBE2-80A0-47D5-B3CE-1B535D2CD0AE.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388166/8c0b6b1b-c9a0-3808-272e-a594a9041060.gif)


**起動直後**にアラートが表示されました。
その時点のログを見てみると以下のようになっていました。

```
manager init func: viewDidLoad()
```

つまり、**Bluetooth許可アラートはCBCentralManagerが初期化されたタイミングで自動的に出る** ということです。

> 補足
> `CBCentralManager`の初期化時の`option`に`CBCentralManagerOptionShowPowerAlertKey`というものがありましたが、これは **初期化時にBluetoothがOFFになっている場合に警告アラートを表示するかどうか** というものであり、許可アラートとは別物でした。
参考
A Boolean value that specifies whether the system warns the user if the app instantiates the central manager when Bluetooth service isn’t available.
https://developer.apple.com/documentation/corebluetooth/cbcentralmanageroptionshowpoweralertkey

警告アラート↓

<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388166/12287514-fa05-563a-0e6a-6851bfae06fc.png">


# 許可アラートを任意のタイミングで出す

本題です。
この初期化されたタイミングで自動的に出る仕様を踏まえて、任意のタイミングで許可アラートを出せるように`CBCentralManager`をラップします。

## 実装

アプローチは簡単です。
初期化すると許可アラートが出るのだから、`CBCentralManager`を`Optional`にして持っておき、**任意のタイミングで初期化をできるようにすれば任意のタイミングで許可アラートを出すことができます。**

```swift
import CoreBluetooth

final class BluetoothManager: NSObject {

    // MARK: - Property

    private var centralManager: CBCentralManager?

    var state: String {
        guard let centralManager = centralManager else {
            fatalError("need call setupAndRequestBluetoothAuthrizationIfNeeded in BluetoothManager")
        }

        return centralManager.state.name
    }

    // MARK: - Internal

    /// セットアップし、必要ならBluetooth許可アラートを表示する
    func setupAndRequestBluetoothAuthorizationIfNeeded() {
        if centralManager == nil {
            // 必要な場合はここでBluetooth許可アラートが表示される
            centralManager = CBCentralManager()
            centralManager?.delegate = self
        }
    }
}

// MARK: - CBCentralManagerDelegate

extension AnyTimingBluetoothManager: CBCentralManagerDelegate {

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        print("BluetoohManager setuped")
    }
}
```

`setup`**=初期化** と `requestBluetoothAuthorization`**=許可アラートを出す** が`CBCentralManager`の仕様上切り離せないためこのようなメソッド仕様となっています。

> 補足
> **「`Optional`ではなく`lazy var`にすれば最初に利用した際に初期化されるし楽ではないか？」**
と思うかもしれません。その通りです。
しかし、`setupAndrequestBluetoothAuthorizationIfNeeded()`のようなメソッドが存在した方が「能動的に許可アラートを出すことができる」と分かりますし
`setupAnd...`を呼ばずに`BluetoothManager().state`のようにプロパティを触った際に、`lazy var`だと自動で初期化が起こり自動で許可アラートが表示されてしまいますが、`Optional`にすれば`fatalError`を出して「先に`setupAnd...`を読んでね」と**能動的に許可アラートを表示することを利用側に強制することができます。**

利用側

```swift
import UIKit

final class ViewController: UIViewController {

    // MARK: - Outlet

    @IBOutlet private weak var statusLabel: UILabel!

    // MARK: - Property

    private let bluetoothManager = BluetoothManager()

    // MARK: - Action

    @IBAction private func buttonAction(_ sender: UIButton) {
        bluetoothManager.setupAndRequestBluetoothAuthorizationIfNeeded()
        statusLabel.text = bluetoothManager.state
    }
}
```


また、好みによるかと思いますが、`...IfNeeded`ではなく、以下のような仕様にすることもできます。

```swift
var needSetupAndRequestAuthorization: Bool {
    return centralManager == nil
}

func setupAndRequestAuthorization() {
    centralManager = CBCentralManager()
    centralManager?.delegate = self
}
```

```swift
if bluetoothManager.needSetupAndRequestAuthorization {
    bluetoothManager.setupAndRequestAuthorization()
}
```

## 実行

![trim.5D76604F-522A-4923-ACCF-D45111C5D2F7.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388166/da3affd8-421b-1d62-8592-3abf9b4195cc.gif)


これでOK!!!
...かと思いきや、**新たな問題が発生しました。**

上のgifを見ても分かる通り、アラートを閉じた後、結果が`unknown`となってしまっています。

これは、
**許可アラートの表示中にプログラムの処理が止まらないため、許可アラート表示中に`statusLabel.text = bluetoothManager.state`の処理が走ってしまっている** からです。
(許可がない状態で`.state`を取得すると`unknown`になります)

これでは、Bluetoothを利用する処理を正しく実行するために2回ボタンを押さなくてはいけなくなり、ユーザー体験的に良くありません。

# アラート表示後に処理をしたい

先程の問題を解決するためには、許可アラートの表示中はプログラムの処理を止める必要があります。

```swift
bluetoothManager.setupAndRequestBluetoothAuthorizationIfNeeded()

// 許可アラートが閉じるまで処理を中断してほしい

statusLabel.text = bluetoothManager.state
```

こういう場合はどうすればいいのでしょうか？

**そうです。非同期処理です。**

`setupAndRequestBluetoothAuthorizationIfNeeded()`を **許可アラートが閉じるまで待機する非同期処理関数** にすれば良いです。


## 実装

非同期処理関数として実装するためには、**待機終了のポイントを適切に定める必要があります。**
ここで登場するのがデリゲートメソッドの`centralManagerDidUpdateState`です。
このメソッドは以下のように動作します。

- 許可状態が未決定の場合、初期化し、**許可アラートの結果が決定した後に**呼ばれる
    - 許可アラートの結果が許可/拒否のどちらでも呼ばれる
- 許可状態が決定済みの場合、**初期化後に**呼ばれる

つまり、**アラートが閉じた時** または **アラートを出す必要がない場合は初期化が完了した時** に呼ばれます。
今回実装したい非同期処理の待機終了ポイントして適切なので、このメソッドを使います。

```swift
import CoreBluetooth

final class BluetoothManager: NSObject {

    // MARK: - Property

    private var centralManager: CBCentralManager?
    private var continuation: CheckedContinuation<Void, Never>?

    var state: String {
        guard let centralManager = centralManager else {
            fatalError("need call setupAndRequestBluetoothAuthrizationIfNeeded in BluetoothManager")
        }

        return centralManager.state.name
    }

    // MARK: - Internal

    /// セットアップし、必要ならBluetooth許可アラートを表示する
    /// 許可状態が決定済みの場合: 許可アラートが表示されず、即時resume
    /// 許可状態が未決定の場合: 許可アラートが表示され、許可アラートが閉じた後にresume
    func setupAndRequestBluetoothAuthorizationIfNeeded() async {
        return await withCheckedContinuation { continuation in
            if centralManager == nil {
                // クラス内の変数にcontinuationを出し、後でresumeできるようにする
                self.continuation = continuation
                centralManager = CBCentralManager()
                centralManager?.delegate = self
            } else {
                // 既にセットアップされている場合は即待機終了
                continuation.resume()
            }
        }
    }
}

// MARK: - CBCentralManagerDelegate

extension BluetoothManager: CBCentralManagerDelegate {

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        print("BluetoohManager setuped")

        // 待機終了
        continuation?.resume()
        self.continuation = nil
    }
}

```

利用側

```swift
import UIKit

final class FinalSampleViewController: UIViewController {
    
    // MARK: - Outlet

    @IBOutlet private weak var statusLabel: UILabel!

    // MARK: - Property

    private let bluetoothManager = BluetoothManager()

    // MARK: - Action

    @IBAction private func buttonAction(_ sender: UIButton) {
        Task {
            await bluetoothManager.setupAndRequestBluetoothAuthorizationIfNeeded()
            statusLabel.text = bluetoothManager.state
        }
    }
}

```

## 実行

![trim.0D3CB5EC-7648-4883-9112-C757D3800B71.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/388166/802810d0-d7e4-6320-f15e-8c3eee7665d3.gif)

これで1回ボタンを押すだけで許可アラートが表示され、アラートが閉じた後にBluetoothを利用した処理を実行することができました！！

# (おまけ)iOS13未満にも対応させる場合

アプリごとのBluetoothの許可はiOS13から導入された物なので、iOS12以下は`CBCentralManager`を初期化しても許可アラートが出現することはありません。
しかし、**初期化後、`centralManagerDidUpdateState`が呼ばれるのを待たないと正常に動作しないので少し待機する必要があります。**

iOS13未満対応バージョン(iOS12を想定)
非同期処理ライブラリとして[PromiseKit](https://github.com/mxcl/PromiseKit)を使っています

```swift
import Foundation
import CoreBluetooth
import PromiseKit

final class BluetoothManager: NSObject {

    // MARK: - Property

    private var centralManager: CBCentralManager?

    private var statusObserver: (guarantee: Guarantee<Void>, resolve: (Void) -> Void)?

    var state: String {
        guard let centralManager = centralManager else {
            fatalError("need call setupAndRequestBluetoothAuthrizationIfNeeded in BluetoothManager")
        }

        return centralManager.state.name
    }

    // MARK: - Internal

    /// セットアップし、Bluetoothの許可が必要な場合はアラートを表示する
    func setupAndRequestBluetoothAuthorizationIfNeeded() -> Guarantee<Void> {
        if centralManager == nil {
            // 待機開始
            let statusObserver = Guarantee<Void>.pending()

            // あとでresolveできるようにresolverを変数に出す
            self.statusObserver = statusObserver

            let centralManager = CBCentralManager()
            centralManager.delegate = self
            self.centralManager = centralManager

            return statusObserver.guarantee
        } else {
            // 既にセットアップされている場合は即待機終了
            return Guarantee { $0(()) }
        }
    }
}

// MARK: - CBCentralManagerDelegate

extension BluetoothManager: CBCentralManagerDelegate {

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if #available(iOS 13.1, *) {
            // iOS13以上の場合は
            // 許可状態が決定済みの場合: 許可アラートが表示されず、即時呼ばれる
            // 許可状態が未決定の場合: 許可アラートが表示され、許可アラートが閉じた後に呼ばれる
            statusObserver?.resolve(())
            self.statusObserver = nil
        } else {
            // iOS12以下の場合は
            // 許可アラートは表示されず、セットアップが完了したら呼ばれる
            // これを待たないと.stateから正常な結果が取得できない
            statusObserver?.resolve(())
            self.statusObserver = nil
        }
    }
}
```

# サンプルコード

実行して確かめる場合は実機をご利用ください。シミュレーターではBluetooth許可アラートが表示されません。

https://github.com/kntkymt/BluetoothAlertSample

# 感想

`CBCentralManager`に手動で許可アラートを出すメソッドが存在しないのは、iOS13という途中からアプリごとの許可が追加されたことにより、仕様を変更できなかったからなのでしょうか...?

任意のタイミングで許可アラートを出せるように実装している最中も「本当に手動で出す方法はないのかな...?」とずっと不安でした()
もしもっと良い方法があれば教えてください。