はじめに
前回「どうして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デバイスをスキャンして検出するためには、次の条件をいずれも満たす必要があります。
- 位置情報パーミション( ACCESS_COARSE_LOCATION または ACCESS_FINE_LOCATION )が許可されている。
- 端末の位置情報モードがオンになっている。
パーミッションモデルの変更は彼方此方で取り上げられていて、Context.requestPermissions()
で許可を求めるという意識はありますが、Bluetoothに直接は関係なさそうな位置情報のパーミッションが必要という罠に加えて、端末の設定まで変えないといけないという罠の二段構えでした。