Help us understand the problem. What is going on with this article?

Android 6.0でBLEデバイスのスキャン結果を受け取るには位置情報モードをオンにする必要がある

More than 3 years have passed since last update.

はじめに

前回「どうしてAndroid 6.0でBLEを使うのに位置情報のパーミッションが必要なワケ?」で、Android 6.0でBLEのスキャンを行う時に位置情報パーミッションがないと SecurityException が発生する理由について調べましたが、その際にソースコード内のあるコメントが引っかかってました。

* An app must hold
* {@link android.Manifest.permission#ACCESS_COARSE_LOCATION} or
* {@link android.Manifest.permission#ACCESS_FINE_LOCATION} permission
* in order to get results.

なぜ、「in order to scan」ではなく、「in order to get」なのか?

そして、Nexus5 + Android 6.0の環境では、位置情報のパーミッションを許可した状態でも、位置情報モードをオフにしているとスキャン結果が得られないというのがあって、そこも前回の調査では確認できていませんでした。

この2点について、ソースをもう少し眺めてみたら理由がわかりましたので、まとめます。

発生条件

  • BLEのスキャンを行うアプリに対して、位置情報パーミッション(ACCESS_COARSE_LOCATION または ACCESS_FINE_LOCATION)が許可されている。
  • 使用しているAndroid端末で「位置情報」がオフになっている。

発生事象

BluetoothLeScanner.startScan(ScanCallback callback)を実行しても、指定したコールバック関数が呼び出されない。

GattService.java

いきなり結論ですが、GattService.javaでスキャン結果を返す際に、位置情報パーミッションに加え、位置情報モードのオン/オフをチェックしており、いずれかが欠けるとScanCallbackを呼び出しません。

/**************************************************************************
* Callback functions - CLIENT
*************************************************************************/
void onScanResult(String address, int rssi, byte[] adv_data) {
    if (VDBG) Log.d(TAG, "onScanResult() - address=" + address
                + ", rssi=" + rssi);
    List<UUID> remoteUuids = parseUuids(adv_data);
    for (ScanClient client : mScanManager.getRegularScanQueue()) {
        if (client.uuids.length > 0) {
            int matches = 0;
            for (UUID search : client.uuids) {
                for (UUID remote: remoteUuids) {
                    if (remote.equals(search)) {
                        ++matches;
                        break; // Only count 1st match in case of duplicates
                    }
                }
            }

            if (matches < client.uuids.length) continue;
        }

        if (!client.isServer) {
            ClientMap.App app = mClientMap.getById(client.clientIf);
            if (app != null) {
                BluetoothDevice device = BluetoothAdapter.getDefaultAdapter()
                        .getRemoteDevice(address);
                ScanResult result = new ScanResult(device, ScanRecord.parseFromBytes(adv_data),
                        rssi, SystemClock.elapsedRealtimeNanos());
                // Do no report if location mode is OFF or the client has no location permission
                // PEERS_MAC_ADDRESS permission holders always get results
                if (hasScanResultPermission(client) && matchesFilters(client, result)) {
                    try {
                        ScanSettings settings = client.settings;
                        if ((settings.getCallbackType() &
                                ScanSettings.CALLBACK_TYPE_ALL_MATCHES) != 0) {
                            app.callback.onScanResult(result);
                        }
                    } catch (RemoteException e) {
                        Log.e(TAG, "Exception: " + e);
                        mClientMap.remove(client.clientIf);
                        mScanManager.stopScan(client);
                    }
                }
            }
        } else {
            ServerMap.App app = mServerMap.getById(client.clientIf);
            if (app != null) {
                try {
                    app.callback.onScanResult(address, rssi, adv_data);
                } catch (RemoteException e) {
                    Log.e(TAG, "Exception: " + e);
                    mServerMap.remove(client.clientIf);
                    mScanManager.stopScan(client);
                }
            }
        }
    }
}

/** Determines if the given scan client has the appropriate permissions to receive callbacks. */
private boolean hasScanResultPermission(final ScanClient client) {
    final boolean requiresLocationEnabled =
            getResources().getBoolean(R.bool.strict_location_check);
    final boolean locationEnabledSetting = Settings.Secure.getInt(getContentResolver(),
            Settings.Secure.LOCATION_MODE, Settings.Secure.LOCATION_MODE_OFF)
            != Settings.Secure.LOCATION_MODE_OFF;
    final boolean locationEnabled = !requiresLocationEnabled || locationEnabledSetting
            || client.legacyForegroundApp;
    return (client.hasPeersMacAddressPermission
            || (client.hasLocationPermission && locationEnabled));
}

しっかりと書かれています。これで「in order to get」の謎は解けました。

// Do no report if location mode is OFF or the client has no location permission

ただし、

// PEERS_MAC_ADDRESS permission holders always get results

とあり、 PEERS_MAC_ADDRESSパーミッションを持っていれば常に結果を受け取れる、となっています。

PEERS_MAC_ADDRESSパーミッション

あまり情報がない(Android Developersにも)のですが、Android 6.0から追加されたパーミッションのようです1

これ付ければいいんでしょ?なんて思ったら、甘い。プロテクションレベルが 「signature」

つまりは、普通のアプリでは使えません。

targetSdkVersion

上のコードを見て、もう1つ気になりました。

targetSdkVersionのチェックしてないの?

requiresLocationEnabled

R.bool.strict_location_checkを調べてみると 「true」 に設定されていました。

<!-- If true, we will require location to be enabled on the device to
    fire Bluetooth LE scan result callbacks in addition to having one
    of the location permissions. -->
<bool name="strict_location_check">true</bool>

ここはイメージ作りの際に設定できるのだと思います。

locationEnabledSetting

これは端末の設定をチェックしていますので、targetSdkVersionは関係ないですね。

locationEnabled

ということで、client.legacyForegroundApp「true」 を返せば、位置情報モードがオフでも結果が得られるハズです。

ScanClientは、前回見たように、GattService.javaで以下のように設定されています。

void startScan(int appIf, boolean isServer, ScanSettings settings,
        List<ScanFilter> filters, List<List<ResultStorageDescriptor>> storages,
        String callingPackage) {

    (snip)

    final ScanClient scanClient = new ScanClient(appIf, isServer, settings, filters, storages);
    scanClient.hasLocationPermission = Utils.checkCallerHasLocationPermission(this, mAppOps,
        callingPackage);
    scanClient.hasPeersMacAddressPermission = Utils.checkCallerHasPeersMacAddressPermission(
        this);
    scanClient.legacyForegroundApp = Utils.isLegacyForegroundApp(this, callingPackage);
    mScanManager.startScan(scanClient);
}

Utils.javaを確認すると、

public static boolean isLegacyForegroundApp(Context context, String pkgName) {
    return !isMApp(context, pkgName) && isForegroundApp(context, pkgName);
}

となっており、targetSdkVersionが22以下の場合は 「true」 が返り、結果を受け取れるようになっています。逆に言えば、23以上にすると、位置情報モードをオンにしないとダメです。

まとめ

Android 6.0で、targetSdkVersionを23にした場合、BLEデバイスをスキャンして検出するためには、次の条件をいずれも満たす必要があります。

  1. 位置情報パーミション( ACCESS_COARSE_LOCATION または ACCESS_FINE_LOCATION )が許可されている。
  2. 端末の位置情報モードがオンになっている。

パーミッションモデルの変更は彼方此方で取り上げられていて、Context.requestPermissions()で許可を求めるという意識はありますが、Bluetoothに直接は関係なさそうな位置情報のパーミッションが必要という罠に加えて、端末の設定まで変えないといけないという罠の二段構えでした。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away