Nearby Connections APIを使ってみる

[Nearby Connections APIの公式ドキュメント](https://developers.google.com/nearby/connections/overview)を適当に噛み砕いて日本語にしてみました。

## Nearby Connections APIとは

Nearby Connections APIとは、**ネットワークを使用せずに近くにあるAndroid端末同士でコネクションを張ってデータをやりとりすることができる機能**です。実際の通信にはWi-Fi DirectやBluetoothが使われますが、通信方式の詳細は隠蔽されており、Nearby Connections APIを利用する側はどのような方式で通信をしているかを意識する必要はありません。

サーバーを用意する必要がなく、物理的に近くにいる端末同士でデータを交換するという性質から、ローカルマルチプレイヤーゲームや画像や音声などのファイルの交換などの用途が考えられます。

Nearby Connections APIでは複数台のデバイス間でコネクションを張ることが可能ですが、ここでは簡略化のため1対1でのコネクションを張る想定で説明します。

コネクションを張るためには、どちらかの端末がAdvertiseを行いもう片方の端末がDiscoveryを行う必要があります。Advertiseは「自分はここにいるよ」というメッセージを周囲に知らせることで、DiscoveryはAdvertiseされた端末を発見することです。
Discoveryしている端末がAdvertiseしている端末を発見したら、Discovery側からAdvertise側へコネクションのリクエストを送ります。Advertise側はDiscovery側からのコネクションリクエストを受け取り、それを承諾することで両者間のコネクションが確立されます。
コネクションを確立した後は、お互いどちらからでも相手に対してデータを送ったり、相手からデータを受け取ったりすることが可能です。どちらかがコネクション切断を要求したり回線状況が悪化したりしてコネクションを維持することができなくなったら、コネクションは切断されます。


## 準備編

Nearby APIを使う前に、必要な準備を行いましょう。

### パーミッションを付ける

Nearby Connections APIを使用するためには、以下のパーミッションが必要です。
Android 6.0以降で動作するためにはRuntime Permissionの処理が必要になります。とりあえずテスト的に動かすだけであれば`targetSdkVersion 21`にしてビルドすれば、自動的にパーミッションが許可されるのでおすすめです。

```xml
<!-- Required for Nearby Connections -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
```

### ライブラリを追加する

アプリモジュールの`build.gradle`の`dependencies`ブロックに、以下の宣言を追加します。

```groovy:app/build.gradle
dependencies {
    implementation 'com.google.android.gms:play-services-nearby:11.6.0'
}
```

### サービスIDを決める

Nearby Connections APIで通信を行うためには、サービスIDというのを事前に決めておく必要があります。サービスIDの値によってNearby APIを使用しているアプリを識別して異なるアプリ同士の通信が割り込まないようにしています。
サービスIDは任意の文字列です。そして画面に表示する必要もないので、パッケージ名など適当に他とかぶらない値を選択すればよいでしょう。

サービス名はいくつかのNearby Connections APIのメソッドに引数として渡します。以下の説明の中では、`SERVICE_ID`として表記します。

### ニックネームを決める

Nearby Connections APIでは、通信相手を識別するためにニックネームという概念があります。「○○さんと接続しようとしています。接続しますか？」的なメッセージで接続前の確認を行うために画面に表示することも考慮した名前を付ける必要があります。
ですが、とにかく近くにある端末とは無条件に接続するというアプリであればニックネームの値は何でも構いませんし、端末ごとにユニークである必要もありません。

ニックネームはいくつかのNearby Connections APIのメソッドに引数として渡します。以下の説明の中では、`getNickName()`として表記します。


## 実装編

それではNearby APIを使ったコードの実装に入ります。なお、説明にKotlinを使用していますが、もちろんJavaでも利用可能です。

### Advertise側

Advertiseする側は、以下のような処理を行います。`mConnectionLifecycleCallback`については後述します。
`.addOnSuccessListener`,`addOnFailureListener`によりAdvertiseの成否が受け取れます。ここで受け取れるのはAdvertiseを開始できたかどうかであって、Advertiseした結果他の端末から発見されたかどうかではありません。

```kotlin
Nearby.getConnectionsClient(this)
        .startAdvertising(
                getNickName(),
                SERVICE_ID,
                mConnectionLifecycleCallback,
                AdvertisingOptions(Strategy.P2P_STAR))
        .addOnSuccessListener {
            // Advertise開始した
        }
        .addOnFailureListener {
            // Advertiseできなかった
        }
}
```

### Discovery側

Discoveryする側は、以下のような処理を行います。`mConnectionLifecycleCallback`については後述します。
`.addOnSuccessListener`,`addOnFailureListener`によりDiscoveryの成否が受け取れます。ここで受け取れるのはDiscoveryを開始できたかどうかであって、Discoveryした結果他の端末を発見したかどうかではありません。
端末を発見した時は`EndpointDiscoveryCallback.onEndpointFound`に通知されます。アプリの要件によってはここで接続先端末をユーザーに選択させるためのUIを表示したりするでしょう。以下の説明ではユーザーの承諾を得たりせず問答無用でコネクションをリクエストするようにしています。

```kotlin
private fun startDiscovery() {
    Nearby.getConnectionsClient(this)
            .startDiscovery(
                    serviceId,
                    mEndpointDiscoveryCallback,
                    DiscoveryOptions(Strategy.P2P_STAR))
            .addOnSuccessListener {
                // Discovery開始した
            }
            .addOnFailureListener {
                // Discovery開始できなかった
            }
}

private val mEndpointDiscoveryCallback = object : EndpointDiscoveryCallback() {
    override fun onEndpointFound(endpointId: String, discoveredEndpointInfo: DiscoveredEndpointInfo) {
        // Advertise側を発見した

        // とりあえず問答無用でコネクション要求してみる
        Nearby.getConnectionsClient(context)
            .requestConnection(getNickName(), endpointId, mConnectionLifecycleCallback)
    }

    override fun onEndpointLost(endpointId: String) {
        // 見つけたエンドポイントを見失った
    }
}
```

### mConnectionLifecycleCallback

Advertise側もDiscovery側も、`mConnectionLifecycleCallback`というものを渡しています。ここから推測できると思いますが、**この後の流れはAdvertise側もDiscovery側も同じ**です。

それではこの`mConnectionLifecycleCallback`がどのようなものかを見てみましょう。`mPayloadCallback`については後述します。

```kotlin
private val mConnectionLifecycleCallback = object : ConnectionLifecycleCallback() {

    override fun onConnectionInitiated(endpointId: String, connectionInfo: ConnectionInfo) {
        // 他の端末からコネクションのリクエストを受け取った時

        // とりあえず来る者は拒まず即承認
        Nearby.getConnectionsClient(context)
            .acceptConnection(endpointId, mPayloadCallback)
    }

    override fun onConnectionResult(endpointId: String, result: ConnectionResolution) {

        // コネクションリクエストの結果を受け取った時

        when (result.status.statusCode) {
            ConnectionsStatusCodes.STATUS_OK -> {
                // コネクションが確立した。今後通信が可能。
                // 通信時にはendpointIdが必要になるので、フィールドに保持する。
                mRemoteEndpointId = endpointId
            }

            ConnectionsStatusCodes.STATUS_CONNECTION_REJECTED -> {
                // コネクションが拒否された時。通信はできない。
                mRemoteEndpointId = null
            }

            ConnectionsStatusCodes.STATUS_ERROR -> {
                // エラーでコネクションが確立できない時。通信はできない。
                mRemoteEndpointId = null
            }
        }
    }

    // コネクションが切断された時
    override fun onDisconnected(endpointId: String) {
        mRemoteEndpointId = null
    }

}
```

`ConnectionLifecycleCallback.onConnectionResult`が呼ばれて`result.status.statusCode == ConnectionsStatusCodes.STATUS_OK`だった場合に、両端末間でのコネクションが確立します。後でデータをやりとりするためにはこの引数に渡されている`endpointId`という値が必要になるため、フィールドに保持しています。

### mPayloadCallback

上記のコードで渡している`mPayloadCallback`というものは、データを受け取った時に呼ばれる通知を受け取るオブジェクトです。これは以下のようなものです。

```kotlin
private val mPayloadCallback = object : PayloadCallback() {
    override fun onPayloadReceived(endpointId: String, payload: Payload) {
        when (payload.type) {
            Payload.Type.BYTES -> {
                // バイト配列を受け取った時
                val data = payload.asBytes()!!
                // 処理
            }
            Payload.Type.FILE -> {
                // ファイルを受け取った時
                val file = payload.asFile()!!
                // 処理
            }
            Payload.Type.STREAM -> {
                // ストリームを受け取った時
                val stream = payload.asStream()!!
                // 処理
            }
        }
    }

    override fun onPayloadTransferUpdate(endpointId: String, update: PayloadTransferUpdate) {
        // 転送状態が更新された時詳細は省略
    }
}
```

### データを送る

そして最後にデータの送り方です。Nearby Connections APIで送受信するデータはPayloadと呼ばれています。Payloadは`Payload.fromXXX`のメソッドで作成します。上記の受信処理を見ればわかるとおり、送れるPayloadはバイト配列・ファイル・ストリームの3種類があります。それぞれ`Payload.fromBytes`,`Payload.fromFile`,`Payload.fromStream`で作成します。

`"Hello world"`という文字列をPayloadとして送信するコードは以下のようになります。

```kotlin
val data = "Hello world".toByteArray()
val payload = Payload.fromBytes(data)

Nearby.getConnectionsClient(context)
    .sendPayload(mRemoteEndpointId, payload)
```

## 注意点

* AdvertiseするのとDiscoveryするのはどちらが先でも構いません。
* AdvertiseとDiscoveryを同時に行うこともできます。
* `sendPayload`で送るメッセージは送った順番と同じ順番で到着することが保証されます。
* Advertiseした端末をDiscoveryした端末が発見するまでには数秒かかる可能性があります。
* コネクションが確立した後の通信にはWi-Fi Directが使われる場合があり、この時端末は**インターネットに繋がっていない状態になります。**
* バイト配列のペイロードは[32KBまで](https://developers.google.com/nearby/connections/v11-update)です。