2017.10.03

Nearby Connections API 2.0を使ってオフラインで端末間通信アプリを作ってみた


こんにちは。次世代システム研究室のT.D.Qです。
みなさん、花火大会で友人と連絡したいけど、連絡できなくて困った事は無いでしょうか?また、Wi-Fiがない飛行機内でも友達と話すことができたらいいなと思うときがありますでしょうか?Googleが2017/7/31に公開したNearby Connections API 2.0でこの課題を解決できるので、今回のブログで紹介したいと思います。

Nearby Connections API 2.0とは


近くにある完全オフラインの端末間で、P2P方式によって高帯域幅、低遅延の暗号化データ転送を行うAPIです。100m以内のデバイスとの接続には、Wi-FiやBluetooth LE、Classic Bluetoothなどが利用されます。Android 4.3 Jelly Bean以降かつGoogle Playサービス11.0以上を実行しているすべてのAndroid 端末でこのAPIが利用できるようになりました。このAPIの1.0バージョンは2015年に初めて発表されましたが、2.0では大きく進化しました。
APIの詳細はこちら
今回、このAPIを調査するため、簡単なオフライン通話アプリを実装してみたいと思います。イメージとしては、ユーザがAndroid端末を使って、完全オフラインで他のユーザと話すことができるアプリです。

開発環境

1.MacBook Pro、macOS Sierra 10.12.6
2.Android Studio 2.3.3
3.動作確認端末
  Xperia™ A4 SO-04G(Androidバージョン5.0.2)
  Xperia™ Z4 SO-03H(Androidバージョン7.0)

アプリの実装


アプリ全体のソースが長く、かつ今回の記事ではNearby Connections API 2.0の調査がメインのため、下記は重要な箇所の抜粋のみで紹介したいと思います。

Nearby Connections API 2.0をインストールする


build.gradleに以下の行を追加することでGoogle Play Services 11のplay-services-nearbyがインストールされます。
compile 'com.google.android.gms:play-services-nearby:11.0.1'

Nearby Connections API 2.0に必要なアクセス権限を設定する


このAPIで構成されるネットワークは「1:N型」と「M:N型」の2種類ありますが、今回のアプリは一人が話すと残りのメンバーが聞くという形で実現したいので、「1:N型(スター型)」を使います。
スター型が必要なアクセス権限を設定します。

AndroidManifest.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" />

近傍接続(Nearby Connections)を初期化する


Nearby Connections APIを使うため、まずNearby Connections APIの初期化が必要です。そのため、アプリが立ち上がるときにNearby Connections APIのインスタンスを生成しておきます。
また、端末間の接続を管理するため、ActivityクラスがGoogleApiClient.OnConnectionFailedListener, GoogleApiClient.ConnectionCallbacksインタフェースを実装します。

public class NearbyConnectionsActivity extends AppCompatActivity
    implements GoogleApiClient.OnConnectionFailedListener, GoogleApiClient.ConnectionCallbacks {

/** 「1:N型(スター型)」を宣言 */
private static final Strategy STRATEGY = Strategy.P2P_STAR;

/** クライアント名:接続するときに他の端末に表示する文字列 */
public static final String CLIENT_NAME = "Android Nearby Connections 2.0デモ";

/** 端末間認証するためのID、アプリを特定できるユニーク文字列 */
public static final String SERVICE_ID = "jiken.gmo.jp.nearby.DEMO_SERVICE_ID";

private GoogleApiClient mGoogleApiClient;

  /** 接続しているモードならtrue */
 private boolean mIsConnecting = false;

  /** 探しているモードならtrue */
  private boolean mIsDiscovering = false;

  /** 接続待ちモードならtrue */
  private boolean mIsAdvertising = false;

 @Override
  protected void onCreate(Bundle savedInstanceState) {
    mGoogleApiClient = new GoogleApiClient.Builder(this)
        .addConnectionCallbacks(this)
        .addOnConnectionFailedListener(this)
        .addApi(Nearby.CONNECTIONS_API)
        .build();
  }

  @Override
  public void onStart() {
    super.onStart();
    mGoogleApiClient.connect();
    /** 以降はオーディオ再生クラスの初期化 */
  }

  @Override
  public void onStop() {
    super.onStop();
    if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) {
      mGoogleApiClient.disconnect();
    }
    /** 以降はオーディオ再生クラスの破棄 */
  }
...
}

近傍接続の各イベントを実装する


完全オフラインでピアツーピア端末間通信するため、Nearby Connections API 2.0が以下の画像のように各イベントを提供しています。

How to Enable Contextual App Experiences (Google I/O ’17)


実際のソースで各イベントを実装してみましょう。処理の流れが以下の通りです。

1.まず、アプリが立ち上がるときにNearby ConnectionsのstartDiscoveringイベントが実行されて、近くのAndroid機同士を探します。ここで、「1:N型(スター型)」のネットワーク構成を設定します。
protected void startDiscovering() {
    mIsDiscovering = true;
    mDiscoveredEndpoints.clear();
    Nearby.Connections.startDiscovery(
            mGoogleApiClient,
            getServiceId(),
            new EndpointDiscoveryCallback() {
              @Override
              public void onEndpointFound(String endpointId, DiscoveredEndpointInfo info) {
                logD(
                    String.format(
                        "onEndpointFound(endpointId=%s, serviceId=%s, endpointName=%s)",
                        endpointId, info.getServiceId(), info.getEndpointName()));
                /** 発見した端末のサービスIDを確認する。同じ場合は、発見した端末を登録します */
                if (getServiceId().equals(info.getServiceId())) {
                  Endpoint endpoint = new Endpoint(endpointId, info.getEndpointName());
                  mDiscoveredEndpoints.put(endpointId, endpoint);
                  onEndpointDiscovered(endpoint);
                }
              }

              @Override
              public void onEndpointLost(String endpointId) {
                logD(String.format("onEndpointLost(endpointId=%s)", endpointId));
              }
            },
           /** 「1:N型(スター型)」方式を設定する */
            new DiscoveryOptions(STRATEGY))
        .setResultCallback(
            new ResultCallback<Status>() {
              @Override
              public void onResult(@NonNull Status status) {
                if (status.isSuccess()) {
                  onDiscoveryStarted();
                } else {
                  mIsDiscovering = false;
                  logW(
                      String.format(
                          "Discovering failed. Received status %s.",
                          ConnectionsActivity.toString(status)));
                  onDiscoveryFailed();
                }
              }
            });
  }
2.話したい人が何かのアクション(端末をシェークやアプリ内の話すボタンを押すなどアイディアによります)でNearby ConnectionsのstartAdvertisingイベントを実行し、接続したい人にお知らせします。
protected void startAdvertising() {
    mIsAdvertising = true;
    Nearby.Connections.startAdvertising(
            mGoogleApiClient,
            getName(),
            getServiceId(),
            mConnectionLifecycleCallback,
            new AdvertisingOptions(STRATEGY))
        .setResultCallback(
            new ResultCallback<Connections.StartAdvertisingResult>() {
              @Override
              public void onResult(@NonNull Connections.StartAdvertisingResult result) {
                if (result.getStatus().isSuccess()) {
                  logV("Now advertising endpoint " + result.getLocalEndpointName());
                  onAdvertisingStarted();
                } else {
                  mIsAdvertising = false;
                  logW(
                      String.format(
                          "Advertising failed. Received status %s.",
                          ConnectionsActivity.toString(result.getStatus())));
                  onAdvertisingFailed();
                }
              }
            });
  }

  /** Stops advertising. */
  protected void stopAdvertising() {
    mIsAdvertising = false;
    Nearby.Connections.stopAdvertising(mGoogleApiClient);
  }

3.アプリ間の各認証イベントを実行することで端末間の接続を確立します。ここで、Nearby ConnectionsのConnectionLifecycleCallbackクラスを使うことで管理できます。
 /** 他の端末に接続するためのCallback処理 */
  private final ConnectionLifecycleCallback mConnectionLifecycleCallback =
      new ConnectionLifecycleCallback() {
        @Override
        public void onConnectionInitiated(String endpointId, ConnectionInfo connectionInfo) {
          logD(
              String.format(
                  "onConnectionInitiated(endpointId=%s, endpointName=%s)",
                  endpointId, connectionInfo.getEndpointName()));
          Endpoint endpoint = new Endpoint(endpointId, connectionInfo.getEndpointName());
          mPendingConnections.put(endpointId, endpoint);
          ConnectionsActivity.this.onConnectionInitiated(endpoint, connectionInfo);
        }

        @Override
        public void onConnectionResult(String endpointId, ConnectionResolution result) {
          logD(String.format("onConnectionResponse(endpointId=%s, result=%s)", endpointId, result));

          mIsConnecting = false;

          if (!result.getStatus().isSuccess()) {
            logW(
                String.format(
                    "Connection failed. Received status %s.",
                    ConnectionsActivity.toString(result.getStatus())));
            onConnectionFailed(mPendingConnections.remove(endpointId));
            return;
          }
          connectedToEndpoint(mPendingConnections.remove(endpointId));
        }

        @Override
        public void onDisconnected(String endpointId) {
          if (!mEstablishedConnections.containsKey(endpointId)) {
            logW("Unexpected disconnection from endpoint " + endpointId);
            return;
          }
          disconnectedFromEndpoint(mEstablishedConnections.get(endpointId));
        }
  };

4.話したい人のほうが、Nearby ConnectionsのsendPayloadで他の人に音声データを送って、接続しているアプリはNearby Connectionsが提供するPayloadCallbackクラスのonPayloadReceivedやonPayloadTransferUpdateイベントで音声データを取得して再生します。

/** 話したい人向けのイベント */
 private final GestureDetector mGestureDetector =
      new GestureDetector(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP) {
        @Override
        protected void onHold() {
          logV("onHold");
     /** 
          * 話す人の音声データをNearby Connectionsで接続している人に送る 
          * この処理の中に重要なメソッドは、Nearby.ConnectionsのsendPayloadメソッドです
          * Nearby.Connections.sendPayload(mGoogleApiClient, new ArrayList<>(endpoints), payload)
          */
          startRecording();
        }

        @Override
        protected void onRelease() {
          logV("onRelease");
          /** 音声データの送信を終了 */
          stopRecording();
        }
  };

 /** 話を聞いている人向けのイベント */
 private final PayloadCallback mPayloadCallback =
      new PayloadCallback() {
        @Override
        public void onPayloadReceived(String endpointId, Payload payload) {
          logD(String.format(&quot;onPayloadReceived(endpointId=%s, payload=%s)&quot;, endpointId, payload));
          /** onReceiveは、話している人の端末から音声データを取得して、再生する機能を実行するメソッドです */
          onReceive(mEstablishedConnections.get(endpointId), payload);
        }

        @Override
        public void onPayloadTransferUpdate(String endpointId, PayloadTransferUpdate update) {
          logD(
              String.format(
                  "onPayloadTransferUpdate(endpointId=%s, update=%s)", endpointId, update));
        }
 };
5.最後に、stopAdvertisingが実行されることで会話が終わります。

アプリをビルドして動作確認してみる

上記のソースコードをビルドし、APKファイルを検証端末にインストールして動作確認して見ました。機内モードでインターネット接続がなくても、ちゃんと会話することができますね。以下は動作確認中のキャプチャーです。

Sony S0-04G(79488)とSony S0-03H(54385)は、相手を探しているモードで立ち上がる。

Sony S0-03H(54385)のほうからブロードキャストして、Sony S0-04Gに接続する

端末間通信で通話する

接続時に、テザリングエラーが発生することがありましたが、再接続で解消されました。

まとめ

今回、Nearby Connections API 2.0を使って完全オフラインでピアツーピア端末間通信アプリを実装してみました。動作確認したところ音声データの送信が結構スムーズで、機内モードでインターネット接続しなくても対話することができました。このAPIを使って、Wi-Fi環境が充実していない環境での緊急速報的なデータの配信やオフライン環境でのデータ共有、動画共有、マルチプレーゲームの利用など、さまざまなサービスの提供が期待できますね。それでは。

最後に

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ募集職種一覧からご応募をお願いします。

皆さんのご応募をお待ちしています。