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


# はじめに

前回「[どうしてAndroid 6.0でBLEを使うのに位置情報のパーミッションが必要なワケ?](http://qiita.com/hisanaka/items/c37d5ecb8fb520e6c9ec)」で、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_COARSE_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」**。

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

[^1]: [RiskFinder Blog](http://blog.riskfinder.co.jp/2015/11/android60.html)

# 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`は、[前回](http://qiita.com/hisanaka/items/c37d5ecb8fb520e6c9ec)見たように、`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* )が許可されている。
1. 端末の位置情報モードがオンになっている。

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