Search code examples
androidkotlinbluetooth-lowenergyandroid-permissions

Android : Permissions check for BLE


To use Bluetooth Low Energy in an Android App it is necessary to include multiple permissions in the Manifest and a runtime check the user has approved these permissions

The issue is there are 6 possible permissions but attempting to check all the permissions will fail! This is because runtime permissions are never granted for some versions of Android.

The 6 permissions are

<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

A big thank you to @Kozmotronik for the simple solution of only checking permissions required by each version of Android

The following checks the required permissions and if not already granted adds to a list. It then requests the missing permissions.

private fun checkAndRequestMissingPermissions() {
    // check required permissions - request those which have not already been granted
    val missingPermissionsToBeRequested = ArrayList<String>()
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH) != PackageManager.PERMISSION_GRANTED)
        missingPermissionsToBeRequested.add(Manifest.permission.BLUETOOTH)

    if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED)
        missingPermissionsToBeRequested.add(Manifest.permission.BLUETOOTH_ADMIN)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        // For Android 12 and above require both BLUETOOTH_CONNECT and BLUETOOTH_SCAN
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED)
            missingPermissionsToBeRequested.add(Manifest.permission.BLUETOOTH_CONNECT)
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED)
            missingPermissionsToBeRequested.add(Manifest.permission.BLUETOOTH_SCAN)
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        // FINE_LOCATION is needed for Android 10 and above
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
            missingPermissionsToBeRequested.add(Manifest.permission.ACCESS_FINE_LOCATION)
    } else {
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED)
            missingPermissionsToBeRequested.add(Manifest.permission.ACCESS_COARSE_LOCATION)
    }

    if (missingPermissionsToBeRequested.isNotEmpty()) {
        writeToLog("Missing the following permissions: $missingPermissionsToBeRequested")
        ActivityCompat.requestPermissions(this, missingPermissionsToBeRequested.toArray(arrayOfNulls<String>(0)), REQUEST_MULTIPLE_PERMISSIONS)
    } else {
        writeToLog("All required permissions GRANTED !")
    }

}

onRequestPermissionsResult() can then check the user has approved the required permissions

override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode == REQUEST_MULTIPLE_PERMISSIONS) {
        for (idx in permissions.indices) {
            var result = if (grantResults[idx] == PackageManager.PERMISSION_GRANTED) "Granted" else "Denied"
            writeToLog("REQUEST_MULTIPLE_PERMISSIONS Permission ${permissions[idx]} $result}")
        }
    }
}

Android requires a permission check before giving access to some BLE functions. Where the permission will not be granted it must still be checked, but can be made to work by adding a check of the Android version.

For example

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
    writeToLog("Scan permission denied")
} else {
    bluetoothScanner.startScan(filters, scanSettings, scanCallback)
    writeToLog("Bluetooth Scan Started")
}

Recently @Kozmotronik has improved his answer which is shown below


Solution

  • Google made Android more strict by adding the new BLUETOOTH_CONNECT and BLUETOOTH_SCAN permissions. You will get SecurityException in runtime if you attempt to access any BLE API that requires these permissions. So we need to check the permissions in the activity which is set to android.intent.action.MAIN in the manifest file. I call that as MainActivity. Unfortunately I don't code in Kotlin yet, so I will write the examples in Java.

    MainActivity

    public class MainActivity extends AppCompatActivity {
    
        private static int BLE_PERMISSIONS_REQUEST_CODE = 0x55; // Could be any other positive integer value
        private int permissionsCount;
        //...
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
    
            // Other codes...
            checkBlePermissions();
    
            // Maybe some more codes...
        }
    
        // Maybe some other codes...
    
        private String getMissingLocationPermission() {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
                    && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
                // COARSE is needed for Android 6 to Android 10
                return Manifest.permission.ACCESS_COARSE_LOCATION;
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                // FINE is needed for Android 10 and above
                return Manifest.permission.ACCESS_FINE_LOCATION;
            }
            // No location permission is needed for Android 6 and below
            return null;
        }
    
        private boolean hasLocationPermission(String locPermission) {
            if(locPermission == null) return true; // An Android version that doesn't need a location permission
            return ContextCompat.checkSelfPermission(getApplicationContext(), locPermission) ==
                    PackageManager.PERMISSION_GRANTED;
        }
    
    
        private String[] getMissingBlePermissions() {
            String[] missingPermissions = null;
    
            String locationPermission = getMissingLocationPermission();
            // For Android 12 and above
            if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                if(ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_SCAN)
                        != PackageManager.PERMISSION_GRANTED) {
                    missingPermissions = new String[1];
                    missingPermissions[0] = Manifest.permission.BLUETOOTH_SCAN;
                }
    
                if(ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_CONNECT)
                        != PackageManager.PERMISSION_GRANTED) {
                    if (missingPermissions == null) {
                        missingPermissions = new String[1];
                        missingPermissions[0] = Manifest.permission.BLUETOOTH_CONNECT;
                    } else {
                        missingPermissions = Arrays.copyOf(missingPermissions, missingPermissions.length + 1);
                        missingPermissions[missingPermissions.length-1] = Manifest.permission.BLUETOOTH_CONNECT;
                    }
                }
    
            }
            else if(!hasLocationPermission(locationPermission)) {
                missingPermissions = new String[1];
                missingPermissions[0] = getMissingLocationPermission();
            }
            return missingPermissions;
        }
    
        private void checkBlePermissions() {
            String[] missingPermissions = getMissingBlePermissions();
            if(missingPermissions == null || missingPermissions.length == 0) {
                Log.i(TAG, "checkBlePermissions: Permissions is already granted");
                return;
            }
    
            for(String perm : missingPermissions)
                Log.d(TAG, "checkBlePermissions: missing permissions "+perm);
            permissionsCount = missingPermissions.length;
    
            requestPermissions(missingPermissions, BLE_PERMISSIONS_REQUEST_CODE);
        }
    
        // Maybe some other codes...
    
        @Override
        public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
            if(requestCode == BLE_PERMISSIONS_REQUEST_CODE) {
                int index = 0;
                for(int result: grantResults) {
                    if(result == PackageManager.PERMISSION_GRANTED) {
                        Log.d(TAG, "Permission granted for "+permissions[index]);
                        if(permissionsCount > 0) permissionsCount--;
                        if(permissionsCount == 0) {
                            // All permissions have been granted from user.
                            // Here you can notify other parts of the app ie. using a custom callback or a viewmodel so on.
                        }
                    } else {
                        Log.d(TAG, "Permission denied for "+permissions[index]);
                        // TODO handle user denial i.e. show an informing dialog
                    }
                }
            } else {
                super.onRequestPermissionsResult(requestCode, permissions, grantResults);
            }
        }
    }
    

    We are done with requesting the all needed permissions depending on the device's SDK so far. Now we need to block the API call pathways to be able to check the permissions. Somewhere where you implement bluetooth scanning put a function like startScanning() as in the code example below, instead of using the BleScanner.scan() API directly. All the following functions must be in the same activity or fragment.

    Another activity or fragment where scanning is implemented

    
    private String getMissingLocationPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
                && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            // COARSE is needed for Android 6 to Android 10
            return Manifest.permission.ACCESS_COARSE_LOCATION;
        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // FINE is needed for Android 10 and above
            return Manifest.permission.ACCESS_FINE_LOCATION;
        }
        // No location permission is needed for Android 6 and below
        return null;
    }
    
    private boolean hasLocationPermission() {
        String missingLocationPermission = getMissingLocationPermission();
        if(missingLocationPermission == null) return true; // No permissions needed
        return ContextCompat.checkSelfPermission(requireContext(), missingLocationPermission) ==
                PackageManager.PERMISSION_GRANTED;
    }
    
    private boolean checkLocationService(@Nullable Runnable r) {
        boolean locationServiceState = isLocationServiceEnabled();
        String stateVerbose = locationServiceState ? "Location is on" : "Location is off";
        Log.d(TAG, stateVerbose);
        if(!locationServiceState){
    
            new MaterialAlertDialogBuilder(requireContext())
                    .setCancelable(false)
                    .setTitle("Location Service Off")
                    .setView("Location service must be enabled in order to scan the bluetooth devices.")
                    .setPositiveButton(android.R.string.ok, (dialog, which) ->
                            startActivity(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)))
                    .setNegativeButton(android.R.string.cancel, (dialog, which) -> {
                        if(r != null) r.run();
                    })
                    .create().show();
        }
        return  locationServiceState;
    }
    
    private boolean isLocationServiceEnabled(){
        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P){
            // This is provided as of API 28
            LocationManager lm = (LocationManager) requireContext().getSystemService(Context.LOCATION_SERVICE);
            return lm.isLocationServiceEnabled();
        } else {
            // This is deprecated as of API 28
            int mod = Settings.Secure.getInt(requireContext().getContentResolver(), Settings.Secure.LOCATION_MODE,
                    Settings.Secure.LOCATION_MODE_OFF);
            return (mod != Settings.Secure.LOCATION_MODE_OFF);
        }
    }
    
    private void startScanning() {
        // Here we intervene the scanning process and check whether the user allowed us to use location.
        if(!hasLocationPermission()) {
            // Here you have to request the approprite location permission similar to that main activity class
            return;
        }
        // Location service must be enabled
        if(!checkLocationService(() -> // Pass a Runnable that starts scanning)) return;
    
        // Everything is good, CAN START SCANNING
    }
    

    This logic is a little confusing by nature but it is robust and have been running in a real application that presents in Google Play Store. However It is not 100% complete code since you need to adapt the idea behind it to your application.