はじめに
Android6.0(Marshmallow)から、BLEを使うアプリにはandroid.permission.BLUETOOTH
、android.permission.BLUETOOTH_ADMIN
に加えて、android.permission.ACCESS_COARSE_LOCATION
またはandroid.permission.ACCESS_FINE_LOCATION
のパーミッションが必要となりました。1,2
皆さん、ご存知でしたか?
私は知りませんでした。知らずにNexus5をバージョンアップしたら、自作アプリでBLEデバイスに接続できなくなって年始早々慌てました。
で、悔しいので、ちょっとソース眺めてみたよ、という記録です。
なお、ここに記載されている内容の正確性は保証できません。間違い等に気がつかれた場合は、コメントでご指摘いただけると助かります。
結論
長いので先に結論を書いておきます。
GattService.java
のstartScan
に位置情報パーミッションの有無をチェックするコードが追加されており、位置情報のパーミッシヨンが与えられていないときはSecurityExceptionが投げられてスキャンに失敗する。
事象
Android6.0でBLEのスキャンを行う際に、ACCESS_COARSE_LOCATION
またはACCESS_FINE_LOCAITON
のいずれかのパーミッションがないと、デバイスが検出されません。
アプリが異常終了したり、端末に何かメッセージが表示されたりはしませんでした。
なので、非常にわかりづらい。ちょうどデバイス側のファームウェアを変えていたところだったので、てっきりBLEデバイスをダメにしたかと焦りまくりです。
logcat
とはいえ、logcatにはちゃんと以下のようなエラーが記録されます。というか、logcatにしか出ません。
W/Binder: Caught a RuntimeException from the binder stub implementation.
java.lang.SecurityException: Need ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION permission to get scan results
at android.os.Parcel.readException(Parcel.java:1620)
at android.os.Parcel.readException(Parcel.java:1573)
at android.bluetooth.IBluetoothGatt$Stub$Proxy.startScan(IBluetoothGatt.java:772)
at android.bluetooth.le.BluetoothLeScanner$BleScanCallbackWrapper.onClientRegistered(BluetoothLeScanner.java:324)
at android.bluetooth.IBluetoothGattCallback$Stub.onTransact(IBluetoothGattCallback.java:56)
at android.os.Binder.execTransact(Binder.java:453)
とりあえず、Bluetooth周りということで、BluetoothLeScanner.javaから当たってみます。
BluetoothLeScanner.java
エラーが発生しているところを見てみましょう。
private class BleScanCallbackWrapper extends BluetoothGattCallbackWrapper {
private IBluetoothGatt mBluetoothGatt;
(snip)
/**
* Application interface registered - app is ready to go
*/
@Override
public void onClientRegistered(int status, int clientIf) {
Log.d(TAG, "onClientRegistered() - status=" + status + " clientIf=" + clientIf);
synchronized (this) {
if (mClientIf == -1) {
if (DBG) Log.d(TAG, "onClientRegistered LE scan canceled");
}
if (status == BluetoothGatt.GATT_SUCCESS) {
mClientIf = clientIf;
try {
mBluetoothGatt.startScan(mClientIf, false, mSettings, mFilters,
mResultStorages, ActivityThread.currentOpPackageName());
} catch (RemoteException e) {
Log.e(TAG, "fail to start le scan: " + e);
mClientIf = -1;
}
} else {
// registration failed
mClientIf = -1;
}
notifyAll();
}
}
(snip)
}
実際にエラーになっているのは、mBluetoothGatt.startScan(mClientIf, false, mSettings, mFilters, mResultStorages, ActivityThread.currentOpPackageName());
の部分ですが、ここでは特に位置情報パーミッションに限った記載はないですね。
ちなみに、ここを呼び出しているところには、結果を取得するためにACCESS_COARSE_LOCATION
か、ACCESS_FINE_LOCATION
が必要と記載されていますが、位置情報関連のパーミッションはチェックしていません。
/**
* Start Bluetooth LE scan with default parameters and no filters. The scan results will be
* delivered through {@code callback}.
* <p>
* Requires {@link android.Manifest.permission#BLUETOOTH_ADMIN} permission.
* An app must hold
* {@link android.Manifest.permission#ACCESS_COARSE_LOCATION ACCESS_COARSE_LOCATION} or
* {@link android.Manifest.permission#ACCESS_FINE_LOCATION ACCESS_FINE_LOCATION} permission
* in order to get results.
*
* @param callback Callback used to deliver scan results.
* @throws IllegalArgumentException If {@code callback} is null.
*/
@RequiresPermission(Manifest.permission.BLUETOOTH_ADMIN)
public void startScan(final ScanCallback callback) {
(snip)
startScan(null, new ScanSettings.Builder().build(), callback);
}
private void startScan(List<ScanFilter> filters, ScanSettings settings,
final ScanCallback callback, List<List<ResultStorageDescriptor>> resultStorages) {
BluetoothLeUtils.checkAdapterStateOn(mBluetoothAdapter);
if (settings == null || callback == null) {
throw new IllegalArgumentException("settings or callback is null");
}
synchronized (mLeScanClients) {
if (mLeScanClients.containsKey(callback)) {
postCallbackError(callback, ScanCallback.SCAN_FAILED_ALREADY_STARTED);
return;
}
IBluetoothGatt gatt;
try {
gatt = mBluetoothManager.getBluetoothGatt();
} catch (RemoteException e) {
gatt = null;
}
if (gatt == null) {
postCallbackError(callback, ScanCallback.SCAN_FAILED_INTERNAL_ERROR);
return;
}
if (!isSettingsConfigAllowedForScan(settings)) {
postCallbackError(callback,
ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED);
return;
}
if (!isHardwareResourcesAvailableForScan(settings)) {
postCallbackError(callback,
ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES);
return;
}
if (!isSettingsAndFilterComboAllowed(settings, filters)) {
postCallbackError(callback,
ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED);
return;
}
BleScanCallbackWrapper wrapper = new BleScanCallbackWrapper(gatt, filters,
settings, callback, resultStorages);
wrapper.startRegisteration();
}
}
ということで、次はIBluetoothGatt
を調べてみます。
#IBluetoothGatt.aidl / GattService.java
I〜となっていることから予想できるように、IBluetoothGattはインターフェースで、IBluetoothGatt.aidlで定義されています。
IBluetoothGatt.aidlを実装しているのは?ということで、AndroidManifest.xmlを見ると、下記の記載が見つかります。
<service
android:process="@string/process"
android:name = ".gatt.GattService"
android:enabled="@bool/profile_supported_gatt">
<intent-filter>
<action android:name="android.bluetooth.IBluetoothGatt" />
</intent-filter>
</service>
と言うことで、早速startScan()
を見てみます。
private static class BluetoothGattBinder extends IBluetoothGatt.Stub implements IProfileServiceBinder {
private GattService mService;
(snip)
@Override
public void startScan(int appIf, boolean isServer, ScanSettings settings,
List<ScanFilter> filters, List storages, String callingPackage) {
GattService service = getService();
if (service == null) return;
service.startScan(appIf, isServer, settings, filters, storages, callingPackage);
}
}
serviceはGattService自体なので、その中のstartScanは?というと、、、
void startScan(int appIf, boolean isServer, ScanSettings settings,
List<ScanFilter> filters, List<List<ResultStorageDescriptor>> storages,
String callingPackage) {
if (DBG) Log.d(TAG, "start scan with filters");
enforceAdminPermission();
if (needsPrivilegedPermissionForScan(settings)) {
enforcePrivilegedPermission();
}
boolean hasLocationPermission = Utils.checkCallerHasLocationPermission(this,
mAppOps, callingPackage);
final ScanClient scanClient = new ScanClient(appIf, isServer, settings, filters, storages);
scanClient.hasLocationPermission = hasLocationPermission;
scanClient.hasPeersMacAddressPermission = Utils.checkCallerHasPeersMacAddressPermission(
this);
mScanManager.startScan(scanClient);
}
はい、ここに居ました。Utils.checkCallerHasLocationPermission
。
Utils.java
Utils.checkCallerHasLocationPermission
では、Android M以上の場合は位置情報のパーミッションがないとSecurityExceptionが投げられます。
/**
* Checks that calling process has android.Manifest.permission.ACCESS_COARSE_LOCATION or
* android.Manifest.permission.ACCESS_FINE_LOCATION and a corresponding app op is allowed
*/
public static boolean checkCallerHasLocationPermission(Context context, AppOpsManager appOps,
String callingPackage) {
if (context.checkCallingOrSelfPermission(android.Manifest.permission.
ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
&& isAppOppAllowed(appOps, AppOpsManager.OP_FINE_LOCATION, callingPackage)) {
return true;
}
if (context.checkCallingOrSelfPermission(android.Manifest.permission.
ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
&& isAppOppAllowed(appOps, AppOpsManager.OP_COARSE_LOCATION, callingPackage)) {
return true;
}
// Enforce location permission for apps targeting M and later versions
if (isMApp(context, callingPackage)) {
throw new SecurityException("Need ACCESS_COARSE_LOCATION or "
+ "ACCESS_FINE_LOCATION permission to get scan results");
} else {
// Pre-M apps running in the foreground should continue getting scan results
if (isForegroundApp(context, callingPackage)) {
return true;
}
Log.e(TAG, "Permission denial: Need ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION "
+ "permission to get scan results");
}
return false;
}
これでスキャンが失敗する、ということのようです。
雑感
Android 6.0(多分)から[設定] > [位置情報]のメニュー内にある[スキャン]に「Bleutoothのスキャン」というのが追加されています。
ACCESS_COARSE_LOCAITON / ACCESS_FINE_LOCATION
というと、GPS/Wi-Fi/モバイルネットワークで使うイメージですが、Bluetoothでも位置情報が取得できるようになったのでパーミッションを追加しろ、ということなのかもしれないですね。
ただ、Bluetoothに加えて、GPS(をイメージさせるようなパーミッション)が付いているとなると、電池を喰いそうなイメージが強くなるのが気にはなります。また、権限を気にするユーザーへの説明も面倒そうで、正直あまり嬉しくはないような気がします。。。
追記(2016/01/06)
コメントでご指摘いただいた「ペアリング済みのデバイス」の場合の動作を確認してみました。
上で確認したとの違いは、
- 位置情報のパーミッションを削除する。
-
BluetoothLeScanner.startScan()
でデバイスをスキャンする代わりに、BluetoothAdapter.getBondedDevices()
でペアリング済みのデバイスを取得する。
の2点で、それ以外の接続やCharacteristicの書き込みに違いはありません。
結果的には、位置情報のパーミッションがなくても、接続や書き込みには問題がありませんでした。
なので、本記事のタイトルは、正確には、「どうしてAndroid 6.0でBLEの "スキャンをする" のに位置情報のパーミッションが必要なワケ?」でした。
追記(2016/01/12)
上記以外にも位置情報パーミッションと位置情報モードの設定が影響することが確認できましたので、フォロー記事を書きました。
Android 6.0でBLEデバイスのスキャン結果を受け取るには位置情報モードをオンにする必要がある