在Android6.0(API 23)之前Android的权限机制太过于简单粗暴:App安装时会提示用户APP所需权限,用户同意安装后就会永久授权应用所需全部权限。其中大部分用户并不知道为什么APP需要这些权限,也不知道这些权限意味着什么。这样很容易被“别有用心”的APP利用,对用户的隐私及账户安全形成了很大的挑战。
Google也开始意识到问题的严重性并且开始收紧权限,在6.0(API 23)做了大的改动,引入了动态权限管理概念。
对于开发者而言,在6.0之前应用申请权限只要将权限在Androidmanifest.xml中注册即可获取对应权限。在6.0之后Google将所有权限进行了整合分组,将系统权限分为几个保护级别也就是权限组。
需要了解的两个最重要保护级别是normal permissions和dangerous permissions:
normal permissions:涵盖应用需要访问数据或资源,但对用户隐私或其他应用操作风险很小的区域。例如,设置时区的权限就是正常权限。如果应用声明其需要正常权限,系统会自动向应用授予该权限。
dangerous permissions:涵盖应用需要涉及用户隐私信息的数据或资源,或者可能对用户存储的数据或其他应用的操作产生影响的区域。例如,能够读取用户的联系人属于危险权限。如果应用声明其需要危险权限,则用户必须明确向应用授予该权限。
查看dangerous permissions权限可以在perm-groups 或通过adb shell pm list permissions -d -g
查看
总结一下就是Google将权限进行了分组,申请属于normal permissions的权限在manifest中声明即可,对于dangerous permissions 权限必须运行时动态申请。
开发者可能要问了:一个规模一般的APP需要的权限最少也有7,8个,难道要一个个申请么?这开发工作量可不小啊!
Google也考虑到了对开发者的影响,上面介绍的权限组的作用也就体现出来了:
如果应用请求其清单中列出的危险权限,而应用目前在权限组中没有任何权限,则系统会向用户显示一个对话框,描述应用要访问的权限组。对话框不描述该组内的具体权限。例如,如果应用请求 READ_CONTACTS 权限,系统对话框只说明该应用需要访问设备的联系信息。如果用户批准,系统将向应用授予其请求的权限。
如果应用请求其清单中列出的危险权限,而应用在同一权限组中已有另一项危险权限,则系统会立即授予该权限,而无需与用户进行任何交互。例如,如果某应用已经请求并且被授予了 READ_CONTACTS 权限,然后它又请求 WRITE_CONTACTS,系统将立即授予该权限。
总结一下就是开发者申请危险权限时,只要获得过属于同一组的其他权限的授权,即可立即获得系统对该权限的授权,无需重复申请。
权限适配主要受APP运行环境及APP开发使用的targetSdkVersion
有关,分为以下几种情况:
targetSdkVersion >= 23 && App运行在 >= 6.0(api 23)系统的终端上
这种情况就会采用标准的Google**运行时动态权限机制**
targetSdkVersion >= 23 && App运行在 < 6.0(api 23)系统的终端上
这种情况就会采用原权限机制即安装时获取授权机制
targetSdkVersion < 23 && App运行在 >= 6.0(api 23)系统的终端上
这种情况就会采用兼容模式,即在安装时获取授权。但是用户可以在设置界面随时关闭/开启 敏感权限。APP需做好权限关闭的逻辑处理。
targetSdkVersion < 23 && App运行在 < 6.0(api 23)系统的终端上
这种情况就会采用原权限机制即安装时获取授权机制
注意:部分国产ROM存在自定义权限机制导致差异,下节详述。
适配权限按步骤可以分为四步:权限检查,权限申请,授权结果处理,权限解释。
权限检查:可用的API较多,如下:
Activity#checkSelfPermission(String permission)
进行检查ContextCompat#checkSelfPermission(Context context, String permission)
进行检查推荐使用ContextCompat#checkSelfPermission
进行检查,这样可以不依赖Activity。
已获授权返回PackageManager#PERMISSION_GRANTED
,未获授权返回PackageManager#PERMISSION_DENIED
权限申请:可以同时申请多个权限,requestCode标识当前申请,会弹出系统权限申请弹窗。
Activity#requestPermissions(String[] permissions, int requestCode)
ActivityCompat#requestPermissions(Activity activity,String[] permissions, int requestCode)
推荐使用ActivityCompat#requestPermissions
,会根据API版本使用最佳方式。
我们可以注意到,无论使用哪种方式进行权限申请都需要依赖Activity,因为系统在权限处理后是需要回调到Activity。
授权结果处理:权限请求会通过接口回调的方式回调到Activity:
/**
* @param requestCode 权限申请request code
* @param permissions 申请的权限
* @param grantResults 授权结果
*/
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
@NonNull int[] grantResults) {
}
ActivityCompat#shouldShowRequestPermissionRationale
API可以判断用户是否拒绝过当前申请权限,开发者可以弹出自定义框解释申请权限原因public boolean shouldShowRequestPermissionRationale(permission)
{
1、APP是第一次申请这个权限的话,返回false
2、用户拒绝,勾选了不再提示的话,返回false
3、用户拒绝,但是没有勾选不再提示的话,返回true
因此如果想在第一次就给用户提示,需要记录权限是否申请过,没有申请过的话,强制弹窗提示,而不能根据这个方法的返回值来。
最好在第一次给用户提示,因为Google的权限申请文案本地化翻译太差劲了,会导致用户很莫名其妙。
}
这几步就是一个标准的权限申请处理步骤,我们可以看出步骤虽然不多,但是确实挺繁琐的。权限一多,需要写很多重复代码。这里我推荐大家一些处理的比较好的开源库,可以让权限申请更简单:
1. EasyPermissions ,Google官方示例推荐。只需关心权限处理结果即可,还可以结合注解使代码更精简。
2. PermissionsDispatcher ,封装了大部分权限申请逻辑,使用方便。
3. AndPermission ,封装的比较好,对国产ROM做了支持,兼容Android8.0权限变化,API链式调用简洁方便。
相信很多开发者在机型适配或使用中会发现有些机型明明是6.0以下版本,但是也存在运行时权限管理机制。
这是因为部分国产ROM(MIUI等)在4.4以上自己使用了一套权限检测机制,在这些机型上如果我们使用上一节介绍的6.0权限适配是无法适用的,具体的表现如下:
1. 使用ContextCompat#checkSelfPermission
无法检测出当前权限是否被授权,在API23以下manifest中注册过就会返回true,API23以上可能出现实际是被关闭但是返回true的情况。
2. 不支持动态权限申请
那我们该如何检测权限是否被正确授权?
虽然很坑爹,但是办法还是有的。
查阅资料发现国产ROM中针对权限的开关可以被AppOpsManager#noteOp(int op)
检测出来,我们可以利用这个类来检测权限授权状态,这里我们介绍这个个新的类AppOpsManager
:
/**
* API for interacting with "application operation" tracking.
*
* This API is not generally intended for third party application developers; most
* features are only available to system applications. Obtain an instance of it through
* {@link Context#getSystemService(String) Context.getSystemService} with
* {@link Context#APP_OPS_SERVICE Context.APP_OPS_SERVICE}.
*/
public class AppOpsManager {
}
AppOpsManager中将权限与OP_*操作码做了一一映射,比如:
android.Manifest.permission.READ_CONTACTS
public static final int OP_READ_CONTACTS = 4;
这些操作码与支持的权限是在不断添加的,我们可以通过观察public static final int _NUM_OP = 62;
知道当前API版本支持的操作总数,需要做好兼容。
但是通过注释可以知道,这个类主要是给系统应用使用的,并不提供给第三方应用开发者。具体表现在类中基本所有方法都被@hide标记,开发者是无法直接使用的。那该怎么办呢?
我们搜索AppOpsManager有关的类,发现Google贴心的在v4包中提供了一个兼容类AppOpsManagerCompat
,这个类对开发者来说是开放的,并且从API4向后兼容。
/**
* 检测权限,小米上使用
*
* @param context
* @param permission
* @return
*/
private static boolean hasSelfPermissionForXiaomi(Context context, String permission) {
// 将permission转换成OP_*
String permissionToOp = AppOpsManagerCompat.permissionToOp(permission);
if (permissionToOp == null) {
// 不支持的权限,或者是normal permission
return true;
}
int noteOp = AppOpsManagerCompat.noteOp(context, permissionToOp, android.os.Process.myUid(), context.getPackageName());
// AppOpsManagerCompat 与 checkSelfPermission都检测过则表明权限被开启
return noteOp == AppOpsManagerCompat.MODE_ALLOWED && ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
}
我们使用这个方法就能对像小米这种自定义权限ROM进行权限检查了,但在测试中发现这在< API 23上一直返回true,这是什么原因呢?我们看看AppOpsManagerCompat
是如何实现的:
private static final AppOpsManagerImpl IMPL;
static {
if (Build.VERSION.SDK_INT >= 23) {
IMPL = new AppOpsManager23();
} else {
IMPL = new AppOpsManagerImpl();
}
}
private static class AppOpsManagerImpl {
public String permissionToOp(String permission) {
// 直接返回null,导致检测方法直接判断为成功
return null;
}
public int noteOp(Context context, String op, int uid, String packageName) {
// 直接调用也会直接返回MODE_IGNORED,不会进行检测
return MODE_IGNORED;
}
public int noteProxyOp(Context context, String op, String proxiedPackageName) {
return MODE_IGNORED;
}
}
private static class AppOpsManager23 extends AppOpsManagerImpl {
@Override
public String permissionToOp(String permission) {
return AppOpsManagerCompat23.permissionToOp(permission);
}
@Override
public int noteOp(Context context, String op, int uid, String packageName) {
return AppOpsManagerCompat23.noteOp(context, op, uid, packageName);
}
@Override
public int noteProxyOp(Context context, String op, String proxiedPackageName) {
return AppOpsManagerCompat23.noteProxyOp(context, op, proxiedPackageName);
}
}
我们发现AppOpsManagerCompat
在23以下的实现类中,一些方法直接返回指定值并不会进行权限检测。
这就尴尬了,在低版本AppOpsManagerCompat
失效,而且AppOpsManager
方法又是@hide无法直接使用,那只能用反射去实现了:
/**
* 通过反射调用 AppOpsManager#noteOp
*
* @param context
* @param op
* @return
*/
public static boolean noteOp(Context context, int op) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
AppOpsManager appOpsManager = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
if (appOpsManager != null) {
try {
Method method = AppOpsManager.class.getDeclaredMethod("noteOp", Integer.TYPE, Integer.TYPE,
String.class);
int noteOp = (int) method.invoke(appOpsManager, op, Process.myUid(), context.getPackageName());
return noteOp == AppOpsManager.MODE_ALLOWED;
} catch (Exception e) {
e.printStackTrace();
}
}
}
return false;
}
总结:
对于权限检测:
1. Build.VERSION.SDK_INT >= 23时,直接使用我们上面的hasSelfPermissionForXiaomi
方法
2. Build.VERSION.SDK_INT < 23时,使用我们上面的反射方法noteOp
。
对于权限申请:
1. Build.VERSION.SDK_INT >= 23时,使用上节介绍的标准运行时权限申请方式
2. Build.VERSION.SDK_INT < 23时,没有办法申请API,只能通过引导用户跳转权限设置页进行配置。
有几个权限其行为方式与正常权限及危险权限都不同。SYSTEM_ALERT_WINDOW
和 WRITE_SETTINGS
特别敏感,因此大多数应用不应该使用它们。
SYSTEM_ALERT_WINDOW
我们接触的比较多,有些功能像桌面悬浮窗,桌面歌词等,只要是通过向WindowManager里面添加View方式的,都需要通过这个权限来实现。
这里拿SYSTEM_ALERT_WINDOW
来讲解如何处理这种特殊权限:
// AndroidManifest中添加权限
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
// 定义权限申请request code
public static int ACTION_MANAGE_OVERLAY_PERMISSION_REQUEST_CODE= 123;
public void requestPermission() {
// 通过Settings.canDrawOverlays(this)可以检测是否包含SYSTEM_ALERT_WINDOW权限
if (!Settings.canDrawOverlays(this)) {
// 申请权限,跳转到系统权限设置页面
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getPackageName()));
startActivityForResult(intent, ACTION_MANAGE_OVERLAY_PERMISSION_REQUEST_CODE);
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
// 在onActivityResult中判断是否授权成功
if (requestCode == ACTION_MANAGE_OVERLAY_PERMISSION_REQUEST_CODE) {
if (Settings.canDrawOverlays(this)) {
// 授权成功
}
}
}
Tips: 悬浮窗实现可以使用LayoutParams.TYPE_TOAST
替换 LayoutParams.TYPE_SYSTEM_ALERT
,LayoutParams.TYPE_TOAST
的使用不需要权限。(LayoutParams.TYPE_TOAST限制就是Android 4.4 (API level 18)及以下使用TYPE_TOAST无法接收触摸事件)
我们在6.0运行时动态权限适配一节中说到了,目前API进行权限申请都需要依赖Activity,因为系统在权限处理后是需要回调到Activity做处理的。
大部分场景都是可以满足的,但是对于没有Activity或者需要在不存在具体Activity进行权限申请的情况,就有点尴尬了。
输入法就遇到了这种尴尬的情况,输入法是系统服务,键盘区展示出来的其实是个Dialog。
有个这种场景:用户在输入法上点击语言输入,输入法需要获取录音权限才能进一步操作,那如何在这种场景去申请权限呢?
这就是我们这节的重点了,如何“不依赖Activity”进行权限申请,我们的方案如下:
我们这里的“不依赖Activity”指的是不依赖具体业务Activity,申请权限时启动一个只用于权限申请的Activity来完成申请流程。
这里我结合easypermissions 实现的例子如下:
PermissionsController封装了权限请求,通过统一API进行权限申请;具体申请交由EasyPermission(可替换任何库),仅处理对权限申请结果监听回调的处理
申请权限时仅需调用,并在回调中处理结果即可
PermissionsController.get(mContext).requestPermissions(this, null, RC_RECORD_PERM, Manifest.permission
.RECORD_AUDIO);
// PermissionsController.java
/**
*
* author : lee
* e-mail : [email protected]
* time : 2017/07/11
* desc : 权限控制类,用于处理权限请求时机控制等
* version: 1.0
*
*/
public class PermissionsController {
private static final String TAG = "PermissionsTag";
private static PermissionsController sInstance;
private final Context mContext;
// 权限回调监听
private final SparseArray mRequestIdToCallback = new SparseArray<>();
public PermissionsController(Context context) {
mContext = context.getApplicationContext();
}
@Nonnull
public static synchronized PermissionsController get(@Nonnull Context context) {
if (sInstance == null) {
sInstance = new PermissionsController(context);
}
return sInstance;
}
/**
* 请求权限;封装了键盘和常规请求
*
* @param callback 申请权限回调,不能为空
* @param activity 权限处理activity,键盘区传null,其他非空
* @param permissionsToRequest 需要申请的权限,多个权限可组合同时申请
*/
public synchronized void requestPermissions(@Nonnull PermissionsResultCallback callback,
Activity activity,
int requestId,
String... permissionsToRequest) {
List deniedPermissions = getDeniedPermissions(mContext, permissionsToRequest);
if (deniedPermissions.isEmpty()) {
// 被拒绝的权限为空,直接回调成功
callback.onPermissionsGranted(requestId, deniedPermissions);
return;
}
String[] permissionsArray = deniedPermissions.toArray(
new String[deniedPermissions.size()]);
if (activity != null) {
String rationale = activity.getString(R.string.permission_msg);
requestPermissions(activity, rationale, requestId, permissionsArray);
} else {
mRequestIdToCallback.put(requestId, callback);
PermissionsActivity.run(mContext, requestId, permissionsArray);
}
}
/**
* 常规请求方式,用于在activity中
*
* @param host 权限处理activity
* @param rationale 权限申请解释文本,仅当用户拒绝首次后才能弹出
* @param requestCode 权限申请request code
* @param perms 需要申请的权限,多个权限可组合同时申请
*/
public static void requestPermissions(
@NonNull Activity host, @NonNull String rationale,
int requestCode, @NonNull String... perms) {
EasyPermissions.requestPermissions(host, rationale, requestCode, perms);
}
/**
* 申请成功回调
*
* @param requestCode 权限申请request code
* @param perms 授权成功权限
*/
public synchronized void onPermissionsGranted(int requestCode, List perms) {
PermissionsResultCallback permissionsResultCallback = mRequestIdToCallback.get(requestCode);
mRequestIdToCallback.remove(requestCode);
if (permissionsResultCallback != null) {
permissionsResultCallback.onPermissionsGranted(requestCode, perms);
}
}
/**
* 申请失败回调
*
* @param requestCode 权限申请request code
* @param perms 授权失败权限
*/
public synchronized void onPermissionsDenied(int requestCode, List perms) {
PermissionsResultCallback permissionsResultCallback = mRequestIdToCallback.get(requestCode);
mRequestIdToCallback.remove(requestCode);
if (permissionsResultCallback != null) {
permissionsResultCallback.onPermissionsDenied(requestCode, perms);
}
}
/**
* 权限申请结果处理,需在activity onRequestPermissionsResult回调中调用
* @param requestCode 权限申请request code
* @param permissions 申请的权限
* @param grantResults 授权结果
* @param receivers 授权结果处理
*/
public static void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions,
@NonNull int[] grantResults,
@NonNull Object... receivers){
EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, receivers);
}
/**
* 存在一些权限被永久的拒绝
*
* @param host
* @param deniedPermissions
* @return
*/
public static boolean somePermissionPermanentlyDenied(@NonNull Activity host,
@NonNull List deniedPermissions) {
return EasyPermissions.somePermissionPermanentlyDenied(host, deniedPermissions);
}
/**
* 展示跳转设置页提示窗
*
* @param host
*/
public static void showAppSettingDialog(@NonNull Activity host) {
new AppSettingsDialog.Builder(host).build().show();
}
/**
* 是否已获取当前权限
*
* @param context
* @param perms
* @return
*/
public static boolean hasPermissions(Context context, @NonNull String... perms) {
return EasyPermissions.hasPermissions(context, perms);
}
/**
* 获取未授权权限
*
* @param context
* @param permissions
* @return
*/
public static List getDeniedPermissions(Context context, String... permissions) {
final List deniedPermissions = new ArrayList();
for (String permission : permissions) {
if (ContextCompat.checkSelfPermission(context, permission)
!= PackageManager.PERMISSION_GRANTED) {
deniedPermissions.add(permission);
}
}
return deniedPermissions;
}
/**
* 权限回调接口,直接继承 EasyPermissions.PermissionCallbacks
*/
public interface PermissionsResultCallback extends EasyPermissions.PermissionCallbacks{
}
}
PermissionsActivity 统一处理权限申请Activity,因为本身只是个载体,样式透明并不设置任何ContentView。
// AndroidManifest.xml
<activity android:name=".permission.PermissionsActivity"
android:theme="@style/permissionTheme"
android:taskAffinity=""
android:configChanges="orientation|screenSize|keyboardHidden"/>
// styles.xml
<style name="permissionTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
<item name="android:statusBarColor" tools:targetApi="lollipop">@android:color/transparent
style>
// PermissionsActivity.java
/**
*
* author : lee
* e-mail : [email protected]
* time : 2017/07/11
* desc : 请求权限的Activity
* version: 1.0
*
*/
public class PermissionsActivity extends Activity implements PermissionsController.PermissionsResultCallback {
private static final String TAG = "PermissionsActivity";
// 需要请求的权限
public static final String EXTRA_PERMISSION_REQUESTED_PERMISSIONS = "requested_permissions";
// 请求权限的requestCode
public static final String EXTRA_PERMISSION_REQUEST_CODE = "request_code";
// 默认无效requestCode
private static final int INVALID_REQUEST_CODE = -1;
private int mPendingRequestCode = INVALID_REQUEST_CODE;
/**
* 启动一个权限Activity,并立即请求权限
*
* @param context
* @param requestCode
* @param permissionStrings
*/
public static void run(
@NonNull Context context, int requestCode, @NonNull String... permissionStrings) {
Intent intent = new Intent(context.getApplicationContext(), PermissionsActivity.class);
intent.putExtra(EXTRA_PERMISSION_REQUESTED_PERMISSIONS, permissionStrings);
intent.putExtra(EXTRA_PERMISSION_REQUEST_CODE, requestCode);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
context.startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mPendingRequestCode = (savedInstanceState != null)
? savedInstanceState.getInt(EXTRA_PERMISSION_REQUEST_CODE, INVALID_REQUEST_CODE)
: INVALID_REQUEST_CODE;
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt(EXTRA_PERMISSION_REQUEST_CODE, mPendingRequestCode);
}
@Override
protected void onResume() {
super.onResume();
if (mPendingRequestCode == INVALID_REQUEST_CODE) {
final Bundle extras = getIntent().getExtras();
final String[] permissionsToRequest =
extras.getStringArray(EXTRA_PERMISSION_REQUESTED_PERMISSIONS);
mPendingRequestCode = extras.getInt(EXTRA_PERMISSION_REQUEST_CODE);
PermissionsController.requestPermissions(this, getString(R.string.permission_msg), mPendingRequestCode,
permissionsToRequest);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[]
grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
PermissionsController.onRequestPermissionsResult(requestCode, permissions, grantResults, this);
mPendingRequestCode = INVALID_REQUEST_CODE;
}
@Override
public void onPermissionsGranted(int requestCode, List perms) {
PermissionsController.get(this).onPermissionsGranted(requestCode, perms);
finish();
}
@Override
public void onPermissionsDenied(int requestCode, List perms) {
PermissionsController.get(this).onPermissionsDenied(requestCode, perms);
if (PermissionsController.somePermissionPermanentlyDenied(this, perms)) {
if (BuildConfig.DEBUG) {
Log.d("PermissionsTag", "存在一些权限被永久的拒绝");
}
PermissionsController.showAppSettingDialog(this);
}
finish();
}
}
以上代码还存在优化空间,但是思路就是这样。
我们之前在Android6.0权限申请时介绍过开发者申请危险权限时,只要获得过属于同一组的其他权限的授权,即可立即获得系统对该权限的授权,无需重复申请这一特性。
但是Android8.0对此行为发生了变化,具体表现为:系统只会授予应用明确请求的权限。然而一旦用户为应用授予某个权限,则所有后续对该权限组中权限的请求都将被自动批准,不会显示弹框。
区别:
1. Android6.0在申请权限成功后,属于同一组的其他权限会自动授权,无需申请。
2. Android8.0在申请权限成功后,在使用同一组权限时还是需要进行申请操作,只是申请会被自动授权。不申请则不会获得授权。
所以为了兼容8.0的变化,开发者在申请权限时最好一同申请权限组内其他权限,做好权限检查。更多内容可参考:Android8.0运行时权限策略变化和适配方案
Android权限机制与适配经验
Android 开发者必知必会的权限管理知识
Android8.0运行时权限策略变化和适配方案