This guide explains how to add support for Android Runtime Permissions in apps/plugins built with Solar2D Native.
When an Android application runs on a device, the operating system provides only limited access to resources. Before your application (or plugin) can make use of restricted resources, you must declare your intent to use them. This is done by editing your AndroidManifest.xml
or by specifying the permissions in metadata.lua
. See Declaring Permissions for more information.
For apps running on Android 5.1 or lower, the AndroidManifest.xml
file is the sole decider for what permissions are allowed, since the user approves of them when installing the app.
For apps running on Android 6.0 or newer, the app can't reliably assume that it has access to dangerous permissions.
Permissions not defined in the dangerous permissions table do not need to be handled at runtime, but they still need to be declared. Essentially, this means that an app/plugin can't always assume that it has the required permissions, so it must gracefully handle the user's rights. This guide covers how you can check for the state of permissions, request permissions, and handle the results.
If the device is running Android 5.1 or lower, permissions are granted on app install. No runtime checks need to be performed.
If the device is running Android 6.0 or newer, dangerous permissions are requested at runtime. This requires that both the developer and the end user consider whether the permission should be granted.
The Corona Camera sample exhibits how media.hasSource( media.Camera )
has been modified to report more information back to the user and how your project should take that information into account.
To begin supporting and testing runtime permissions, you must first target Android 6.0. Simply change a number to 23
in a few places within the project (API Level 23 corresponds with Android 6.0).
AndroidManifest.xml
— Change android:targetSdkVersion="xx"
to android:targetSdkVersion="23"
. This will allow you to test your changes in the local project.
project.properties
— Change target=Google Inc.:Google APIs:xx
target=Google Inc.:Google APIs:23
project.plugin.properties
— For target=android-xx
to target=android-23
. This change will affect the plugin itself.
Once it has been determined which permissions the app/plugin requires, declaration is handled as follows:
If you are developing a native plugin, you must declare permissions within metadata.lua
. This ensures that the permissions are automatically imported for Solar2D users.
For example, if the android.permission.SEND_SMS
permission is required, the usesPermissions
table must contain it like this:
local metadata = { plugin = { manifest = { usesPermissions = { "android.permission.SEND_SMS", }, }, }, } return metadata
In addition, for Solar2D Native developers, you should communicate that these permissions must be explicitly added to their AndroidManifest.xml
file
Solar2D Native users must add permissions to AndroidManifest.xml
explicitly.
For example, if the android.permission.SEND_SMS
permission is required, the AndroidManifest.xml
must contain it like this:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.snazzyapp"> <uses-permission android:name="android.permission.SEND_SMS"/> </manifest>
Supporting runtime permissions in Corona is very similar to supporting them in native Android apps. This guide is intended as a supplement to Google's Requesting Permissions at Run Time guide and follows along in parallel with it.
In Corona, we have provided an interface for developers to handle their permission requests without having to worry about API Levels or permission leak.
The majority of the functionality you'll need comes from our com.ansca.corona.permissions.PermissionsServices
class. This provides several APIs intended to bridge Google's permission handling with the AndroidManifest.xml
file, as well as the state of various permissions.
See Checking for Permissions and Requesting Permissions below for details.
This can be done via Google's context.checkSelfPermission() API, however it's only available in API Level 23, it doesn't protect against permission leak, and it ignores instances when a permission is not listed in AndroidManifest.xml
. Instead, we recommend checking for permissions using Corona's PermissionsServices.getPermissionStateFor()
API. This provides the state of a permission on all supported Android versions and can also determine if the desired permission is missing from AndroidManifest.xml
.
// Determine the PermissionState for the our ability to send SMS messages. PermissionsServices permissionsServices = new PermissionsServices(CoronaEnvironment.getApplicationContext()); PermissionState sendSMSState = permissionsServices.getPermissionStateFor(PermissionsServices.Permission.SEND_SMS);
Here, we use a PermissionsServices
instance to query the state of the SEND.SMS
permission. If we get PermissionState.GRANTED
back, we know that we're able to continue to call code which requires this permission.
PermissionsServices.getPermissionStateFor()
returns a PermissionState
enum with one of the following values. It's important that your app/plugin can handle each one of these possible permission states without crashing or getting into a bad state.
This permission has been granted by the user. Corresponds to Google's android.content.pm.PackageManager.PERMISSION_GRANTED
. When a permission is in this state, you're permitted to do whatever necessary that requires the permission.
This permission isn't listed in the app's AndroidManifest.xml
file. If a permission is in this state, one of the following scenarios may apply:
metadata.lua
may not have specified the permission as being required.The context in which you handle PermissionState.MISSING
depends on whether the specific permission is mandatory for core functionality or whether it's merely associated with a Camera
permission to function and, if the Camera
permission state is PermissionState.MISSING
, you should alert the developer that they must add this to their AndroidManifest.xml
file. Corona's PermissionsServices.showMissingFromManifestAlert()
API makes this easy:
// Display a "missing from manifest" alert PermissionsServices permissionsServices = new PermissionsServices(CoronaEnvironment.getApplicationContext()); permissionsServices.showPermissionMissingFromManifestAlert(PermissionsServices.Permission.CAMERA, "The <Super Cool QR Scanner Plugin> requires access to the device's camera(s)!");
This permission has either been explicitly denied, or has not yet been requested. Corresponds to Google's android.content.pm.PackageManager.PERMISSION_DENIED
.
If a permission is in this state, it means that the permission is specified in the project's AndroidManifest.xml
file, but the Android OS has denied the app access to it. At this point, you should check whether the user still wants requests for the permission via PermissionsServices.shouldNeverAskAgain()
. Assuming the user still wants requests for this permission, the app should now request access to the denied permission.
// Request permission to use external storage PermissionsServices permissionsServices = new PermissionsServices(CoronaEnvironment.getApplicationContext()); if (!permissionsServices.shouldNeverAskAgain(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE)) { // Request Write External Storage permission. We cover how to do this in the section below }
When everything is put together, the code to check for a permission state and handle all possible outcomes will typically contain this template:
// Only do our dangerous work if we have permission to use external storage PermissionsServices permissionsServices = new PermissionsServices(CoronaEnvironment.getApplicationContext()); PermissionState writeExternalStoragePermissionState = permissionsServices.getPermissionStateFor(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE); switch(writeExternalStoragePermissionState) { case MISSING: // The Corona developer forgot or refused to add the permission to the AndroidManifest.xml break; case DENIED: // Check PermissionsServices.shouldNeverAskAgain() and try to request permission break; default: // Permission is granted; continue as usual doDangerousWork(); }
Now that you know how to determine the state of a permission, you'll need to request required permissions using permissionsServices.requestPermissions()
. Read further to learn about PermissionsSettings
and OnRequestPermissionsHandler
.
Calling PermissionsServices.requestPermissions()
is asynchronous and it will temporarily suspend the user's application while presenting the permission UI to the user. The response will be on the main UI thread, not on the Lua thread. Be sure to use a CoronaRuntimeTaskDispatcher
if necessary.
A PermissionsSettings
object is used to define the permissions you want to request. For example, you can request a single permission:
// Define that we will request permission to write to external storage PermissionsSettings settings = new PermissionsSettings(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE); PermissionsServices permissionsServices = new PermissionsServices(CoronaEnvironment.getApplicationContext()); permissionsServices.requestPermissions(settings, MyOnRequestPermissionsHandler);
Or, you can request multiple permissions:
// Define that we will request permission to write to external storage and access the camera PermissionsSettings settings = new PermissionsSettings(new String[] {PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE, PermissionsServices.Permission.CAMERA}); PermissionsServices permissionsServices = new PermissionsServices(CoronaEnvironment.getApplicationContext()); permissionsServices.requestPermissions(settings, MyOnRequestPermissionsHandler);
In both of these examples, MyOnRequestPermissionsHandler
is an implementation of the OnRequestPermissionsHandler
. Learn more about this in the next section.
As PermissionsServices.requestPermissions()
is asynchronous, a callback is required to indicate when the user has finished denying or granting the requested permissions. This is done through the CoronaActivity.OnRequestPermissionsResultHandler
interface. When the user is finished, the onHandleRequestPermissionsResult
method from this interface will be called.
Here is a basic implementation:
private class MyRequestPermissionsResultHandler implements CoronaActivity.OnRequestPermissionsResultHandler { @Override public void onHandleRequestPermissionsResult(CoronaActivity activity, int requestCode, String[] permissions, int[] grantResults) { // Clean up and unregister our request (you should always do this) PermissionsSettings permissionsSettings = activity.unregisterRequestPermissionsResultHandler(this); if (permissionsSettings != null) { permissionsSettings.markAsServiced(); } // Use PermissionServices to ensure that we have permission to write to external storage PermissionsServices permissionsServices = new PermissionsServices(activity); if (permissionsServices.getPermissionStateFor(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE) == PermissionState.GRANTED) { // Handle the case where our permission was granted } else { // Handle the case where our permissions were not granted } } }
You can then use the OnRequestPermissionsResultHandler
as follows:
PermissionsSettings settings = new PermissionsSettings(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE); MyRequestPermissionsResultHandler handler = new MyRequestPermissionsResultHandler() PermissionsServices permissionsServices = new PermissionsServices(CoronaEnvironment.getApplicationContext()); permissionsServices.requestPermissions(settings, handler);
Putting this together, we can write a class that implements CoronaActivity.OnRequestPermissionsResultHandler
and handles permissions for us. The implementation may look like this:
private class MyFileIOFunctionRequestPermissionsResultHandler implements CoronaActivity.OnRequestPermissionsResultHandler { public void handleRun() { // Check for WRITE_EXTERNAL_STORAGE permission PermissionsServices permissionsServices = new PermissionsServices(CoronaEnvironment.getApplicationContext()); PermissionState writeExternalStoragePermissionState = permissionsServices.getPermissionStateFor(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE); switch(writeExternalStoragePermissionState) { case MISSING: // The Corona developer didn't add the permission to the AndroidManifest.xml // As it is required for our app to function, we'll error out here // If the permission were not critical, we could work around it here permissionsServices.showPermissionMissingFromManifestAlert(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE, "My plugin requires access to the device's storage!"); break; case DENIED: if (!permissionsServices.shouldNeverAskAgain(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE)) { // Create our Permissions Settings to compare against in the handler PermissionsSettings settings = new PermissionsSettings(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE); // Request Write External Storage permission permissionsServices.requestPermissions(settings, this); } break; default: // Permission is granted! run(); } } @Override public void onHandleRequestPermissionsResult(CoronaActivity activity, int requestCode, String[] permissions, int[] grantResults) { // Clean up and unregister our request (you should always do this) PermissionsSettings permissionsSettings = activity.unregisterRequestPermissionsResultHandler(this); if (permissionsSettings != null) { permissionsSettings.markAsServiced(); } // Check for WRITE_EXTERNAL_STORAGE permission PermissionsServices permissionsServices = new PermissionsServices(activity); if (permissionsServices.getPermissionStateFor(PermissionsServices.Permission.WRITE_EXTERNAL_STORAGE) == PermissionState.GRANTED) { run(); } else { // We were denied permission } } public void run() { // We are sure we have the required permissions; write to storage! }
We'd move our dangerous code into the run()
function above. The exposed Lua function may then look like this:
private class MyFileIOFunction implements NamedJavaFunction { @Override public String getName() { // e.g. "myPlugin.doSomethingWithExternalStorage()" return "doSomethingWithExternalStorage"; } @Override public int invoke(LuaState L) { // NOTE: RequestPermissionsResultHandlers are NOT guaranteed to be synchronous. Here we have two different behaviors: // 1) If the permission is already granted, or we are on Android version < 6.0, we return immediately on the lua // thread with no interruption // 2) If the permission needs the user to approve it (Android 6.0+), we wait and handle the request later; this must // be dispatched to run on the lua thread, not the main or UI thread // Be sure to use a CoronaRuntimeTask if needed! MyFileIOFunctionRequestPermissionsResultHandler resultHandler = new MyFileIOFunctionRequestPermissionsResultHandler(L); resultHandler.handleRun(); return 0; } }
It's important to note that the OnRequestPermissionsResultHandler
is not guaranteed to be synchronous. Checking the code above, we see that there are two different ways to call run()
:
We already have the permission granted (perhaps because we're not on Android 6.0+ and we simply defined the permission in AndroidManifest.xml
). In this case, we call run()
from the Lua thread, synchronously.
The app does not have permission, so we request permission. This causes the app to suspend, displaying a UI prompting the user to accept or deny the permission request. The result of this interaction is sent to OnRequestPermissionsResultHandler.onHandleRequestPermissionsResult()
on the main UI thread. A CoronaRuntimeTaskDispatcher
should be used to ensure that we interact with Lua at the proper time (see below).
private void run() { // This function can be called from either the main thread, or the Lua thread; safely dispatch it // Note that we'll need to store the lua state we want to dispatch to elsewhere final CoronaRuntimeTaskDispatcher dispatcher = new CoronaRuntimeTaskDispatcher(fLuaState); dispatcher.send( new CoronaRuntimeTask() { @Override public void executeUsing(CoronaRuntime runtime) { LuaState L = runtime.getLuaState(); // ... } } );