ぽたうん学習帳

portownの勉強内容をつらつらまとめていきます。勉強中、かつ自分なりの言葉で書いているので、間違っていたり誤解を生む表現だったりします。

AndroidでBLE Peripheralを作るときはonServiceAddedを待つべき

背景

Androidアプリ(BLE Central)とAndroidアプリ(BLE Peripheral)の間で通信をさせていたところ、 Central側でServiceのDiscoveryが完了したにもかかわらず、存在するはずのServiceやCharacteristic、DescriptorがCentralから見えないという現象が発生した。

Peripheral側で追加したServiceからgetCharacteristic()やgetDescriptor()で中身を見てもきちんと登録されているが、 Central側で発見したServiceからgetService()やgetCharacteristic()、getDescriptor()するとUUIDが合っていても結果がnullとなる。 (nullとなるService・Characteristic・Descriptorは毎回変わったり変わらなかったりする。全て正常に取れるときもある)

Centralに使用したのはNexus 5X (Android 7.1.1)、 Peripheralに使用したのはNexus 9 (Android 7.1.1) である。

要約

BluetoothGattServer#addService() を連続して呼ぶ場合、一つ呼ぶごとに BluetoothGattServerCallback#onServiceAdded() が呼ばれるまで待機した方がよい。 onServiceAdded() が呼ばれたら一度コンテキストを切り、微小時間(50msなど)待機してから次の addService() を行うと、より問題の発生確率を下げることができる。 (addService を呼んだら NullPointerException が発生した、という問題も回避できる)

ただし、addServiceしてcloseしてまたaddServiceして……などと繰り返すと BluetoothGattServerCallback#onServiceAdded() 自体が呼ばれない場合もある(っぽい)ので注意。 (また別の制御が必要)

addServiceの挙動

BluetoothGattServer#addService()

Android 7.1.1 での BluetoothGattServer#addService() の実装は以下のようになっている。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/frameworks/base/core/java/android/bluetooth/BluetoothGattServer.java#addService

mService.beginServiceDeclaration(mServerIf, service.getType(),
    service.getInstanceId(), service.getHandles(),
    new ParcelUuid(service.getUuid()), service.isAdvertisePreferred());

List<BluetoothGattService> includedServices = service.getIncludedServices();
for (BluetoothGattService includedService : includedServices) {
    mService.addIncludedService(mServerIf,
        includedService.getType(),
        includedService.getInstanceId(),
        new ParcelUuid(includedService.getUuid()));
}

List<BluetoothGattCharacteristic> characteristics = service.getCharacteristics();
for (BluetoothGattCharacteristic characteristic : characteristics) {
    int permission = ((characteristic.getKeySize() - 7) << 12)
                        + characteristic.getPermissions();
    mService.addCharacteristic(mServerIf,
        new ParcelUuid(characteristic.getUuid()),
        characteristic.getProperties(), permission);

    List<BluetoothGattDescriptor> descriptors = characteristic.getDescriptors();
    for (BluetoothGattDescriptor descriptor: descriptors) {
        permission = ((characteristic.getKeySize() - 7) << 12)
                            + descriptor.getPermissions();
        mService.addDescriptor(mServerIf,
            new ParcelUuid(descriptor.getUuid()), permission);
    }
}

mService.endServiceDeclaration(mServerIf);

引数の service (BluetoothGattService) から必要な情報を受け取り、mService へ渡している。

mService の型は IBluetoothGatt であり、これは Android 内部の Service(BLEのServiceではなく android.app.Service)である GattService へのインタフェースである。

このメソッド内では、 IBluetoothGatt#beginServiceDeclaration()IBluetoothGatt#endServiceDeclaration() の間で IBluetoothGatt#addIncludedService()IBluetoothGatt#addCharacteristic()IBluetoothGatt#addDescriptor() を呼んで サービスを登録していることがわかる。(android.app.Service と紛らわしいので、以下、BLEのServiceはカタカナでサービスと記述する)

GattService(IBluetoothGatt実装部分)

各メソッドの実装は以下にある。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/packages/apps/Bluetooth/src/com/android/bluetooth/gatt/GattService.java#1941

void beginServiceDeclaration(int serverIf, int srvcType, int srvcInstanceId,
                             int minHandles, UUID srvcUuid, boolean advertisePreferred) {
    enforceCallingOrSelfPermission(BLUETOOTH_PERM, "Need BLUETOOTH permission");

    if (DBG) Log.d(TAG, "beginServiceDeclaration() - uuid=" + srvcUuid);
    ServiceDeclaration serviceDeclaration = addDeclaration();
    serviceDeclaration.addService(srvcUuid, srvcType, srvcInstanceId, minHandles,
        advertisePreferred);
}

void addIncludedService(int serverIf, int srvcType, int srvcInstanceId,
                        UUID srvcUuid) {
    enforceCallingOrSelfPermission(BLUETOOTH_PERM, "Need BLUETOOTH permission");

    if (DBG) Log.d(TAG, "addIncludedService() - uuid=" + srvcUuid);
    getActiveDeclaration().addIncludedService(srvcUuid, srvcType, srvcInstanceId);
}

void addCharacteristic(int serverIf, UUID charUuid, int properties,
                       int permissions) {
    enforceCallingOrSelfPermission(BLUETOOTH_PERM, "Need BLUETOOTH permission");

    if (DBG) Log.d(TAG, "addCharacteristic() - uuid=" + charUuid);
    getActiveDeclaration().addCharacteristic(charUuid, properties, permissions);
}

void addDescriptor(int serverIf, UUID descUuid, int permissions) {
    enforceCallingOrSelfPermission(BLUETOOTH_PERM, "Need BLUETOOTH permission");

    if (DBG) Log.d(TAG, "addDescriptor() - uuid=" + descUuid);
    getActiveDeclaration().addDescriptor(descUuid, permissions);
}

void endServiceDeclaration(int serverIf) {
    enforceCallingOrSelfPermission(BLUETOOTH_PERM, "Need BLUETOOTH permission");

    if (DBG) Log.d(TAG, "endServiceDeclaration()");

    if (getActiveDeclaration() == getPendingDeclaration()) {
        try {
            continueServiceDeclaration(serverIf, (byte)0, 0);
        } catch (RemoteException e) {
            Log.e(TAG,""+e);
        }
    }
}

beginServiceDeclaration() にて addDeclaration() して ServiceDeclaration を作成、 あとはその ServiceDeclaration に対して Characteristic や Descriptor などを add していく、という流れになっている。

そして endServiceDeclaration() にて ActiveDeclaration と PendingDeclaration が一致したら continueServiceDeclaration() を呼んでいる。

ActiveDeclaration とか PendingDeclaration とはなんぞや、というのは以下のあたりにある。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/packages/apps/Bluetooth/src/com/android/bluetooth/gatt/GattService.java#139

private ServiceDeclaration addDeclaration() {
    synchronized (mServiceDeclarations) {
        mServiceDeclarations.add(new ServiceDeclaration());
    }
    return getActiveDeclaration();
}

private ServiceDeclaration getActiveDeclaration() {
    synchronized (mServiceDeclarations) {
        if (mServiceDeclarations.size() > 0)
            return mServiceDeclarations.get(mServiceDeclarations.size() - 1);
    }
    return null;
}

private ServiceDeclaration getPendingDeclaration() {
    synchronized (mServiceDeclarations) {
        if (mServiceDeclarations.size() > 0)
            return mServiceDeclarations.get(0);
    }
    return null;
}

private void removePendingDeclaration() {
    synchronized (mServiceDeclarations) {
        if (mServiceDeclarations.size() > 0)
            mServiceDeclarations.remove(0);
    }
}

要するに、内部に mServiceDeclarations という ServiceDeclaration のキューがあり、 ActiveDeclaration はキューの末尾、PendingDeclaration はキューの先頭にある ServiceDeclaration のことである。 (キューが空の場合はどちらも null である)

そして、鍵となるのが残りの continueServiceDeclaration() である。

GattService(内部メソッド)

continueServiceDeclaration() の実装は以下だ。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/packages/apps/Bluetooth/src/com/android/bluetooth/gatt/GattService.java#2114

private void continueServiceDeclaration(int serverIf, int status, int srvcHandle) throws RemoteException {
    if (mServiceDeclarations.size() == 0) return;
    if (DBG) Log.d(TAG, "continueServiceDeclaration() - srvcHandle=" + srvcHandle);

    boolean finished = false;

    ServiceDeclaration.Entry entry = null;
    if (status == 0)
        entry = getPendingDeclaration().getNext();

    if (entry != null) {
        if (DBG) Log.d(TAG, "continueServiceDeclaration() - next entry type="
            + entry.type);
        switch(entry.type) {
            case ServiceDeclaration.TYPE_SERVICE:
                if (entry.advertisePreferred) {
                    mAdvertisingServiceUuids.add(entry.uuid);
                }
                gattServerAddServiceNative(serverIf, entry.serviceType,
                    entry.instance,
                    entry.uuid.getLeastSignificantBits(),
                    entry.uuid.getMostSignificantBits(),
                    getPendingDeclaration().getNumHandles());
                break;

            case ServiceDeclaration.TYPE_CHARACTERISTIC:
                gattServerAddCharacteristicNative(serverIf, srvcHandle,
                    entry.uuid.getLeastSignificantBits(),
                    entry.uuid.getMostSignificantBits(),
                    entry.properties, entry.permissions);
                break;

            case ServiceDeclaration.TYPE_DESCRIPTOR:
                gattServerAddDescriptorNative(serverIf, srvcHandle,
                    entry.uuid.getLeastSignificantBits(),
                    entry.uuid.getMostSignificantBits(),
                    entry.permissions);
                break;

            case ServiceDeclaration.TYPE_INCLUDED_SERVICE:
            {
                int inclSrvc = mHandleMap.getServiceHandle(entry.uuid,
                                        entry.serviceType, entry.instance);
                if (inclSrvc != 0) {
                    gattServerAddIncludedServiceNative(serverIf, srvcHandle,
                                                       inclSrvc);
                } else {
                    finished = true;
                }
                break;
            }
        }
    } else {
        gattServerStartServiceNative(serverIf, srvcHandle,
            (byte)BluetoothDevice.TRANSPORT_BREDR | BluetoothDevice.TRANSPORT_LE);
        finished = true;
    }

    if (finished) {
        if (DBG) Log.d(TAG, "continueServiceDeclaration() - completed.");
        ServerMap.App app = mServerMap.getById(serverIf);
        if (app != null) {
            HandleMap.Entry serviceEntry = mHandleMap.getByHandle(srvcHandle);

            if (serviceEntry != null) {
                app.callback.onServiceAdded(status, serviceEntry.serviceType,
                    serviceEntry.instance, new ParcelUuid(serviceEntry.uuid));
            } else {
                app.callback.onServiceAdded(status, 0, 0, null);
            }
        }
        removePendingDeclaration();

        if (getPendingDeclaration() != null) {
            continueServiceDeclaration(serverIf, (byte)0, 0);
        }
    }
}

ここでは、PendingDeclaration から Entry(サービスやCharacteristicなど)を一つ一つ取り出し、 その種類に応じた native メソッドを呼んで実際の登録を行っている。

なお、このメソッド内ではループをしていないが、各 native メソッドが完了するたびにこの continueServiceDeclaration() が呼ばれるようになっている。 (詳細は省略するが、呼び出し箇所は以下)

http://tools.oesf.biz/android-7.1.1_r1.0/xref/packages/apps/Bluetooth/src/com/android/bluetooth/gatt/GattService.java#1662

そして、PendingDeclaration 内の全ての Entry の登録が済んだ(finished)ら、

  1. BluetoothGattServerCallback#onServiceAdded() を呼ぶ(正確にはBinderのCallbackを経由するが)
  2. 登録の済んだ PendingDeclaration をキューから取り除く
  3. まだキューに ServiceDeclaration が残っていれば、continueServiceDeclaration()再帰的に呼び出す

ということを行う。

問題の箇所

問題となるのは、「各 native メソッドが完了するたびに continueServiceDeclaration() が呼ばれて登録が進む」という部分である。

実はこの native メソッド、非同期なのである。

例としてサービスの登録をざっくりと追おう。(本質でないので興味のない方は流してください)

まず gattServerAddServiceNative() が呼ばれる。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/packages/apps/Bluetooth/jni/com_android_bluetooth_gatt.cpp#1513

その中で、sGattIf->server->add_service() が呼ばれる。実体は以下だ。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/system/bt/btif/src/btif_gatt_server.c#btif_gatts_add_service

この中の btif_transfer_context() によって処理がワーカスレッドに切り替わる。(ここで非同期となる)

ワーカスレッドで BTIF_GATTS_CREATE_SERVICE イベントが発行され、以下の場所で処理されて BTA_GATTS_CreateService() が呼ばれる。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/system/bt/btif/src/btif_gatt_server.c#442

BTA_GATTS_CreateService() では BTA_GATTS_API_CREATE_SRVC_EVT イベントを発行する。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/system/bt/bta/gatt/bta_gatts_api.c#BTA_GATTS_CreateService

イベントは以下の場所で処理され、bta_gatts_create_srvc() が呼ばれる。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/system/bt/bta/gatt/bta_gatts_main.c#86

bta_gatts_create_srvc() 内では実際の登録処理とコールバックのための情報作成を行った後、BTA_GATTS_CREATE_EVT イベントを発行する。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/system/bt/bta/gatt/bta_gatts_act.c#375

イベントは以下の場所で処理され、server->service_added_cb が呼ばれる。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/system/bt/btif/src/btif_gatt_server.c#207

このメソッドの実装は以下にあり、ここで native から Java へと戻り GattServer#onServiceAdded() が呼ばれる。

http://tools.oesf.biz/android-7.1.1_r1.0/xref/packages/apps/Bluetooth/jni/com_android_bluetooth_gatt.cpp#btgatts_service_added_cb

このように、native での処理は非同期で行われる。

問題の内部

さて、ここまでで以下のことが判明した。

  • サービスの登録は ServiceDeclaration を使って行われる。
  • ServiceDeclaration はキューにより管理されており、先頭の ServiceDeclaration には PendingDeclaration、末尾の ServiceDeclaration には ActiveDeclaration という名前が付けられている。
  • ServiceDeclaration は複数の Entry から構成される。Entry とはサービス、Characteristic、Descriptorのことである。
  • サービスの登録は Entry ごとに分割して行われ、一つの Entry の登録が完了したら次の Entry を登録、Entry がなくなったら次の ServiceDeclarationEntry、という流れで進む。この処理は非同期である。

ここで、サービスを二つ持つ Peripheral を作成することを考えよう。

この Peripheral は二つのサービス S1 と S2 を持ち、サービス S2 は一つの Characteristic C1 を持つ。 (簡単化のため、S1 は Characteristic を持たないものとする)

これらのサービスを S1 -> S2 の順番で登録する。 するとまず、S1 を追加(addService())した段階で以下のような状態になる。

  • 呼ばれたメソッド : beginServiceDeclaration()
  • キュー : [S1のServiceDeclaration(構築中)]
  • ActiveDeclaration : S1のServiceDeclaration
  • PendingDeclaration : S1のServiceDeclaration
  • BLEの状態 : 待機中

そして、S1 には Characteristic がないので、すぐに次の状態になる。

  • 呼ばれたメソッド : endServiceDeclaration()
  • キュー : [S1のServiceDeclaration(構築完了)]
  • ActiveDeclaration : S1のServiceDeclaration
  • PendingDeclaration : S1のServiceDeclaration
  • BLEの状態 : 待機中

ActiveDeclaration と PendingDeclaration が一致するので、continueServiceDeclaration() が呼ばれ、以下の状態になる。

  • 呼ばれたメソッド : continueServiceDeclaration()
  • キュー : [S1のServiceDeclaration(空)]
  • ActiveDeclaration : S1のServiceDeclaration
  • PendingDeclaration : S1のServiceDeclaration
  • BLEの状態 : S1 登録中

PendingDeclaration の先頭 Entry が remove され、S1 の ServiceDeclaration の中身が空になることに注意。

さて、S1 の登録処理は前述の通り非同期である。 ここでは、登録完了前に S2 の追加が実行されたことにしよう。状態は以下のようになる。

  • 呼ばれたメソッド : beginServiceDeclaration()
  • キュー : [S1のServiceDeclaration(空), S2のServiceDeclaration(構築中)]
  • ActiveDeclaration : S2のServiceDeclaration
  • PendingDeclaration : S1のServiceDeclaration
  • BLEの状態 : S1 登録中

この状態ではまだ C1 の追加は行われておらず、S2 の ServiceDeclaration 内に C1 の情報はない。

さて、ここで S1 の登録処理が完了したとしよう。状態は以下のようになる。

  • 呼ばれたメソッド : continueServiceDeclaration()
  • キュー : [S2のServiceDeclaration(構築中)]
  • ActiveDeclaration : S2のServiceDeclaration
  • PendingDeclaration : S2のServiceDeclaration
  • BLEの状態 : S1 登録完了

構築中である S2 の ServiceDeclaration が PendingDeclaration となっていることに注意してほしい。

ここで、PendingDeclaration が存在するので、同期で再び continueServiceDeclaration() が呼ばれる。

  • 呼ばれたメソッド : continueServiceDeclaration()
  • キュー : [S2のServiceDeclaration(空)]
  • ActiveDeclaration : S2のServiceDeclaration
  • PendingDeclaration : S2のServiceDeclaration
  • BLEの状態 : S2 登録中

S2 の ServiceDeclaration からサービスの Entry が削除され、Entry が空となる。

ここで C1 の追加処理が走れば正常に処理が続いていくが、それより S2 の登録処理が先に完了してしまうと、以下の状態になる。

  • 呼ばれたメソッド : continueServiceDeclaration()
  • キュー : [](空)
  • ActiveDeclaration : null
  • PendingDeclaration : null
  • BLEの状態 : S2 登録完了

これにより、S2 は C1 の情報なしに登録されてしまう

さらに、この後 C1 の追加処理を ActiveDeclaration に対して行おうとするが、ActiveDeclaration は既に null になっているため、 NullPointerException が発生する。

試していたコードでは、この NPE を catch して処理を継続していたため、情報の欠如が発生してしまっていた。

対策

結論から言うと、確実な回避策は無い、かもしれない

なぜなら、BluetoothServerCallback#onServiceAdded() が「PendingDeclaration の削除、および後続の ServiceDeclaration が存在するかどうかの判定」のに呼ばれるためである。

まず、この問題を回避するための必要条件は 「連続して addService() を行う場合、PendingDeclaration が null になって(=キューが空になって)かつ continueServiceDeclaration() のメソッドを抜けてから後続の addService() を行うこと」 である。

これを満たそうとすると、以下の部分の処理順がネックとなる。

private void continueServiceDeclaration(int serverIf, int status, int srvcHandle) throws RemoteException {
    ...
            if (serviceEntry != null) {
                app.callback.onServiceAdded(status, serviceEntry.serviceType,
                    serviceEntry.instance, new ParcelUuid(serviceEntry.uuid));
            } else {
                app.callback.onServiceAdded(status, 0, 0, null);
            }
        }
        removePendingDeclaration();

        if (getPendingDeclaration() != null) {
            continueServiceDeclaration(serverIf, (byte)0, 0);
        }
}

onServiceAdded()removePendingDeclaration() の呼出と continueServiceDeclaration()再帰呼出が続いているため、 単純に onServiceAdded() を待って addService() しただけでは条件を満たせないのだ。(問題の発生確率は下がるだろうが)

もし onServiceAdded() を呼ぶスレッドから Looper が取得可能であるなら、その場で Handler を作成して post() した先で addService() することでこの条件を満たすことができる。 (スレッドまで調べる気力が現在ないので可能かわからないが)

しかしそれが無理となると、適当なスレッドに post() するしか手がない。 20ms とか 50ms とか適当な時間だけ postDelayed() すれば「ほぼ確実」くらいの回避策にはなると思う。

仮に 7.1.1 で目的の Looper の取得が可能だとしても、それ以前のバージョンでも可能かどうか不明であるし、 大人しく postDelayed() しておくのがよいのかもしれない。

ちなみに 8.0 からはこのあたりの実装が大きく変わっており、おそらくこの問題は発生しなくなっているのではないかと思われる。

http://tools.oesf.biz/android-8.0.0_r1.0/xref/frameworks/base/core/java/android/bluetooth/BluetoothGattServer.java#687

http://tools.oesf.biz/android-8.0.0_r1.0/xref/packages/apps/Bluetooth/src/com/android/bluetooth/gatt/GattService.java#2391

http://tools.oesf.biz/android-8.0.0_r1.0/xref/packages/apps/Bluetooth/jni/com_android_bluetooth_gatt.cpp#1560

http://tools.oesf.biz/android-8.0.0_r1.0/xref/system/bt/btif/src/btif_gatt_server.cc#add_service_impl

上記箇所をざっと見たが、7.x の「サービス情報を構築しつつ下層で登録処理を実施」という構成ではなく、 「サービス情報を全て構築し、まとめて下層に渡して登録処理を実施」という形に変わっているようなので、 今回のような非同期問題は起きないのではないだろうか。

余談

簡易的に「onServiceAdded() を待機してから後続の addService() を呼ぶ」という処理を実装してみたところ、うまくいっているように見えた。

しかし、「BluetoothGattServer を作成して addService()(から onServiceAdded() 待機→後続 addService())」と「BluetoothGattServer#close() を呼んでサーバ終了」をすばやく繰り返したところ、 onServiceAdded() 自体が呼ばれなくなるという別の問題が発生してしまった。 (当然、後続の addService() は呼べない)

onServiceAdded() の待機処理に問題があったのか、BluetoothGattServer#close() の完了を待つなど別の処理が必要なのか、詳細はまだ不明である。