一篇文章搞定《Android权限问题(全版本)》

一、前言

文章内容如下:
如果你只是想快速的完成你Android权限申请的工作,那么直接上工具PermissionX
如果是想真正的了解Android的权限问题,那么建议你用15分钟通读一下本文。(可以不去实验,收藏以备后用)
首先了解Android版本和SDK的关系,帮助我们分辨后面的权限版本。
其次把最常见的Android7-13版本的我们需要注意的权限问题聊一下。
最后是现在App都需要的动态申请权限的工具使用。

二、Android版本和SDK版本的关系

目前最新的Android版本已经到了Android13,相应的SDK版本也达到了33。说的不如看图快,直接看。
位置:(Android Studio -> Preferences -> Appearance & Behavior -> System Settings -> Android SDK)

三、Android版本变化中权限的变化

这里说一下比较明显变化的版本,还有最近几个版本的变化:
众所周知Android5.1 -> Android6.0的权限变化,都被大家了解烂了。但还是要说一下,多看看对你没坏处。
可以看到Android5.1到Android6.0的权限变化,也就是对应着我们的SDK23版本
也就是说当build.gradle中targetSdkVersion设置小于23时,会继续引用旧版本权限管理机制,当targetSdkVersion大于等于23时,则会使用新的权限管理。

1、Android5以前 — targetSdkVersion < 23

当targetSdkVersion小于23时,你的项目会继续使用旧版本的权限机制:
所申请的权限只需要在AndroidManifest.xml列举就可以(如下),从而容易导致一些安全隐患:
在这里插入图片描述
1、用户在安装时获取到所有权限,在使用权限时无需进行预判断。
2、如果用户手动来到设置将权限关闭,我们的项目在用到该权限时会发生Crash,所以如果使用低版本权限管理,请将需要用到权限的地方try-catch起来。
3、低版本权限提示弹窗都是手机厂商自定义的。他们拥有系统权限,在检测到你的App使用权限时会提示用户权限授权,如果用户拒绝则替用户到设置中心关闭权限。

2、Android6 — targetSdkVersion23

1、将权限分为了普通权限和隐私权限,普通权限在清单文件中声明则直接获取。隐私权限也需要在清单文件中声明,但是在安装完成后,所有的隐私权限都为拒绝状态,需要在用到隐私权限时判断权限是否开启,否则项目直接发生Crash。
比如常用的隐私权限:相机、定位、打电话、短信、读写存储、麦克风录音、传感器。(具体的看下图)
隐私权限
上图中有一个权限群的概念,同一组的任何一个权限被授权了,其他权限也自动被授权。
例如,一旦WRITE_EXTERNAL_STORAGE被授权了,APP同时也就有了READ_EXTERNAL_STORAGE权限。
这里申请权限的步骤想必大家也是清楚的,我就随便提一嘴。(因为可以直接使用下面的PermissionX去帮你减少这些繁琐的权限处理过程)
1、在AndroidManifest.xml声明
2、检查我们未授权的权限checkSelfPermission(Context context, String permission)
3、申请我们的权限requestPermissions(Activity activity, String[] permissions, int requestCode)
4、申请权限的结果onRequestPermissionsResult(int requestCode, String[] permissions, String[] grantResults)
5、后续就是自己处理逻辑了(PermissionX提供了一些处理方案)

3、Android7 — targetSdkVersion24、25

为了提高私有目录的安全性,防止应用信息的泄漏,从 Android 7.0 开始,应用私有目录的访问权限被做限制。具体表现为,开发人员不能够再简单地通过 file:// URI 访问其他应用的私有目录文件或者让其他应用访问自己的私有目录文件。会触发 FileUriExposedException的错误异常。
解决办法:使用FileProvider(具体使用不做赘述了)
作为四大组件之一的 ContentProvider,一直扮演着应用间共享资源的角色。这里我们要使用到的 FileProvider,就是 ContentProvider 的一个特殊子类,帮助我们将访问受限的 file:// URI 转化为可以授权共享的 content:// URI。

4、Android8 — targetSdkVersion26、27

Android 8.0 之前,如果应用在运行时请求权限并且被授予该权限,系统会错误地将属于同一权限组并且在清单中注册的其他权限也一起授予应用。(上面有提到权限组)
对于针对 Android 8.0 的应用,此行为已被纠正。系统只会授予应用明确请求的权限。然而,一旦用户为应用授予某个权限,则所有后续对该权限组中权限的请求都将被自动批准。
出现的问题大家也就知道了,就是之前申请了READ_EXTERNAL_STORAGE相应的WRITE_EXTERNAL_STORAGE也会申请,而现在不可以。
解决方案:之前没动态申请WRITE_EXTERNAL_STORAGE的再加入申请权限列表中申请就可以了。
但是这里READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE是一个权限组,所以申请的时候也只是提示你同意一个存储权限就可以了。

5、Android9 — targetSdkVersion28

前台服务需要添加权限
在安卓9.0版本之后,必须要授予FOREGROUND_SERVICE权限,才能够使用前台服务,否则会抛出异常。

6、Android10 — targetSdkVersion29

重点来了:作用域存储(这个带好好说一说,因为这是很多应用都会遇到的问题)
这个新功能直接颠覆了长久以来我们一直惯用的外置存储空间的使用方式,因此大量App都将面临着较多代码模块的升级。
Android长久以来都支持外置存储空间这个功能,也就是我们常说的SD卡存储。故App都喜欢在SD卡的根目录下建立一个自己专属的目录,用来存放各类文件和数据。
优点:第一,存储在SD卡的文件不会计入到应用程序的占用空间当中,也就是说即使你在SD卡存放了1G的文件,你的应用程序在设置中显示的占用空间仍然可能只有几十K。
第二,存储在SD卡的文件,即使应用程序被卸载了,这些文件仍然会被保留下来,这有助于实现一些需要数据被永久保留的功能。
缺点:第一,流氓行为即使我卸载了一个完全不再使用的程序,它所产生的垃圾文件却可能会一直保留在我的手机上
第二,存储在SD卡上的文件属于公有文件,所有的应用程序都有权随意访问,这也对数据的安全性带来了很大的挑战。(这种流氓行为Google他出手了,也就是作用域存储)
Google的作用域到底是什么:
从Android 10开始,每个应用程序只能有权在自己的外置存储空间关联目录下读取和创建文件。
获取该目录的代码为:

context.getExternalFilesDir()
// storage/emulated/0/Android/data/<包名>/files(大概目录为)

将数据存放到这个目录下,你将可以完全使用之前的写法来对文件进行读写,不需要做任何变更和适配。但同时,刚才提到的那两个“好处”也就不存在了。也就是应用删除相关存储也会消失。
那么有些朋友可能会问了,我就是需要访问其他目录该怎么办呢?比如读取手机相册中的图片,或者向手机相册中添加一张图片。
为此,Android系统针对文件类型进行了分类,图片、音频、视频这三类文件将可以通过MediaStore API来进行访问,而其他类型的文件则需要使用系统的文件选择器来进行访问。
具体的适配Android10的解决方案写在下面了:Android10存储适配方案

7、Android11 — targetSdkVersion30

如果按照Android10的解决方案进行适配了,那么就什么都不需要做。
如果是暂时的设置了requestLegacyExternalStorage=“true”

<manifest ... >
  <application android:requestLegacyExternalStorage="true" ...>
    ...
  </application>
</manifest>

那么恭喜你:Scoped Storage就会被强制启用。也就是requestLegacyExternalStorage设置无效。
解决方案
1、按照Android10的解决方案进行适配(最好适配了。不然后续版本也要不断修改)
2、你必须在AndroidManifest.xml中声明MANAGE_EXTERNAL_STORAGE权限

 <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
     tools:ignore="ScopedStorage" />

注意相比于传统声明一个权限,这里增加了tools:ignore="ScopedStorage"这样一个属性。因为如果不加上这个属性,Android Studio会用一个警告提醒我们,绝大部分的应用程序都不应该申请这个权限。
之后利用ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION这个action来跳转到指定的授权页面,进行授权。(所以还是去适配Android10的解决方案吧)

if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
        Environment.isExternalStorageManager()) {
    Toast.makeText(this, "已获得访问所有文件权限", Toast.LENGTH_SHORT).show()
} else {
    val builder = AlertDialog.Builder(this)
        .setMessage("本程序需要您同意允许访问所有文件权限")
        .setPositiveButton("确定") { _, _ ->
            val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
            startActivity(intent)
        }
    builder.show()
}

8、Android12 — targetSdkVersion31、32

1、新增模糊定位功能,用户可以选择让应用只能访问大致位置。
在Android12上,如果你的应用需要获取用户准确的位置信息,那么就需要同时申请准确位置和大概位置两项权限,AndroidManifest.xml文件中的代码如下:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

注意:此两项权限也都需要动态申请
2、获取安装的应用列表权限
在Android 11上在使用PackageManger的方法来获取安装的应用列表的时候就需要在AndroidManifest.xml文件中进行申请android.permission.QUERY_ALL_PACKAGES此权限了,但是Android12中部分手机还要添加android.permission.GET_INSTALLED_APPS权限才能正常获取到应用列表,权限代码如下:

    <uses-permission android:name="android.permission.GET_INSTALLED_APPS"/>
    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
        tools:ignore="QueryAllPackagesPermission" />

android12虽然不用动态申请这两个权限,但是首次进入应用还会出现授权访问的提示。
3、蓝牙运行时权限:
新增了几个蓝牙相关的运行时权限。原因是因为当开发者去访问一些蓝牙相关的接口时,却需要申请地理位置权限才行,这就让一些对隐私敏感的用户非常反感。下面三个哦。
BLUETOOTH_SCAN,BLUETOOTH_ADVERTISE,BLUETOOTH_CONNECT。

9、Android13 — targetSdkVersion33

1、Google在Android 13上对本地数据访问权限做了更进一步的细化
从Android 13开始,如果你的应用targetSdk指定到了33或以上,那么READ_EXTERNAL_STORAGE权限就完全失去了作用,申请它将不会产生任何的效果。
相对应地,Google新增了READ_MEDIA_IMAGES、READ_MEDIA_VIDEO和READ_MEDIA_AUDIO这3个运行时权限,分别用于管理手机的照片、视频和音频文件。
也就是说,以前只要申请一个READ_EXTERNAL_STORAGE权限就可以了。现在不行了,得按需申请,用户从而能够更加精细地了解你的应用到底申请了哪些媒体权限。

<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

2、通知运行时权限
在之前的Android系统中,任何一个应用想要发出通知的话都是不需要经过用户同意的,想发就能发。这就使得我们的手机通知栏经常被一些垃圾通知占领
这次Android 13则把通知纳入了运行时权限管理,也就是说,以后想要发送通知,得要先经过用户同意授权才行了。

<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

3、WIFI运行时权限
和蓝牙类似,当开发者去访问一些WIFI相关的接口时,如热点、WIFI直连、WIFI RTT等,也需要申请地理位置权限才行。

Android 13当中新增了一个NEARBY_WIFI_DEVICES权限,当再使用以上场景相关的WIFI API时,我们只需申请NEARBY_WIFI_DEVICES权限即可,从而更好地保护了用户的隐私。

四、Android10存储权限适配

1、临时解决方案(仅适合适配到Android10)

临时解决方案仅仅适合在Android10上解决问题,到了Android11都是不允许的。也就是你的targetSdkVersion升级到了30就需要重新适配。
Android10的临时解决方法很简单,只需要在AndroidManifest.xml中加入如下配置即可:

<manifest ... >
  <application android:requestLegacyExternalStorage="true" ...>
    ...
  </application>
</manifest>

这段配置表示,即使在Android 10系统上,仍然允许使用之前遗留的外置存储空间的用法来运行程序,这样就不用对代码进行任何修改了。

2、首先是作用域存储(上面讲到了,就不赘述)

简单来说就是把你原来用的获取SD卡根目录随意存储的方式,换成自己的外置存储目录。
原来的:获取后创建自己想要的文件夹存储(Android10以上是不可以了,也就是你targetSdkVersion >= 29)

Environment.getExternalStorageDirectory()
// storage/emulated/0 (正常获取的默认目录)

修改为:自己的外置存储空间,这就可以进行存储了

context.getExternalFilesDir()
// storage/emulated/0/Android/data/<包名>/files (获取的默认目录)

3、读取手机相册、视频、音频

这里只举例图片,视频和音频是一样的方式获取。
不同于过去可以直接获取到相册中图片的绝对路径,在作用域存储当中,我们只能借助MediaStore API获取到图片的Uri,示例代码如下:

val cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, "${MediaStore.MediaColumns.DATE_ADDED} desc")
if (cursor != null) {
    while (cursor.moveToNext()) {
        val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
        val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
        println("image uri is $uri")
    }
        cursor.close()
}

上述代码中,我们先是通过ContentResolver获取到了相册中所有图片的id,然后再借助ContentUris将id拼装成一个完整的Uri对象。一张图片的Uri格式大致如下所示:

content://media/external/images/media/321

那么有些朋友可能会问了,获取到了Uri之后,我又该怎样将这张图片显示出来呢?这就有很多种办法了,比如使用Glide来加载图片,它本身就支持传入Uri对象来作为图片路径:

Glide.with(context).load(uri).into(imageView)

4、添加图片到相册(视频、音频)

将一张图片添加到手机相册要相对稍微复杂一点
直接上代码:
第一步:构建一个ContentValues对象
第二步:添加他的三个参数一个是图片显示的名称:DISPLAY_NAME。一个是图片的mime类型:MIME_TYPE。还有一个是图片存储的路径,不过这个值在Android 10和之前的系统版本中的处理方式不一样。Android 10中新增了一个RELATIVE_PATH常量,表示文件存储的相对路径,可选值有DIRECTORY_DCIM、DIRECTORY_PICTURES、DIRECTORY_MOVIES、DIRECTORY_MUSIC等,分别表示相册、图片、电影、音乐等目录。而在之前的系统版本中并没有RELATIVE_PATH,所以我们要使用DATA常量(已在Android 10中废弃),并拼装出一个文件存储的绝对路径才行。
第三步:调用ContentResolver的insert()方法即可获得插入图片的Uri。
第四步:调用ContentResolver的openOutputStream()方法获得文件的输出流,然后将Bitmap对象写入到该输出流当中即可。

fun addBitmapToAlbum(bitmap: Bitmap, displayName: String, mimeType: String, compressFormat: Bitmap.CompressFormat) {
    val values = ContentValues()
    values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
    values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
    } else {
        values.put(MediaStore.MediaColumns.DATA, "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName")
    }
    val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
    if (uri != null) {
        val outputStream = contentResolver.openOutputStream(uri)
        if (outputStream != null) {
            bitmap.compress(compressFormat, 100, outputStream)
                        outputStream.close()
        }
    }
}

5、下载文件到Download目录

执行文件下载操作是一个很常见的场景,比如说下载pdf、doc文件,或者下载APK安装包等等。在过去,这些文件我们通常都会下载到Download目录,这是一个专门用于存放下载文件的目录。而从Android 10开始,我们已经不能以绝对路径的方式访问外置存储空间了,所以文件下载功能也会受到影响。
第一种解决方法:同时也是最简单的一种方式,就是更改文件的下载目录。将文件下载到应用程序的关联目录下,这样不用修改任何代码就可以让程序在Android 10系统上正常工作。但使用这种方式,你需要知道,下载的文件会被计入到应用程序的占用空间当中,同时如果应用程序被卸载了,该文件也会一同被删除。另外,存放在关联目录下的文件只能被当前的应用程序所访问,其他程序是没有读取权限的。
第二种解决方案
将文件下载到Download目录,和向相册中添加一张图片的过程是差不多的,Android 10在MediaStore中新增了一种Downloads集合,专门用于执行文件下载操作。

val values = ContentValues()
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)

其余你下载的流程不用改变,只替换你写入文件的部分。

五、直接上工具:动态申请权限PermissionX

说了这么多,直接上工具,毕竟干活重要。
PermissionX1.7全面支持Android 13的运行时权限申请
正常的申请权限的:1、检查权限、申请权限、结果返回、返回后的处理(跳转手动的等等),代码臃肿,流程繁琐。有没有,有木有。

实现原理

具体用法之前,我们先来简单说一下它的实现原理
之后慢慢开发者们开始去衍生了一些封装的方案,比如将运行时权限的操作封装到BaseActivity中,或者提供一个透明的Activity来处理运行时权限等。(涉及到Activity那就是重量级了,毕竟还要注册)
这时候有人想到了用轻量级的Fragment进行注册,毕竟Android在Fragment中也提供了一份相同的API,使得我们在Fragment中也能申请运行时权限。
但不同的是,Fragment并不像Activity那样必须有界面,我们完全可以向Activity中添加一个隐藏的Fragment,然后在这个隐藏的Fragment中对运行时权限的API进行封装。这是一种非常轻量级的做法,不用担心隐藏Fragment会对Activity的性能造成什么影响。
这就是PermissionX的实现原理了。

使用

1、引入工具

repositories {
  google()
  mavenCentral()
}

dependencies {
    implementation 'com.guolindev.permissionx:permissionx:1.7.1'
}

2、基础简单使用

PermissionX.init(this)
.permissions(Manifest.permission.CALL_PHONE)
.request { allGranted, grantedList, deniedList ->
    if (allGranted) {
        call()
    } else {
        Toast.makeText(this, "您拒绝了拨打电话权限", Toast.LENGTH_SHORT).show()
    }
}

是的,PermissionX的基本用法就这么简单。首先调用init()方法来进行初始化,并在初始化的时候传入一个FragmentActivity参数。由于AppCompatActivity是FragmentActivity的子类,所以只要你的Activity是继承自AppCompatActivity的,那么直接传入this就可以了。
接下来调用permissions()方法传入你要申请的权限名,这里传入CALL_PHONE权限。你也可以在permissions()方法中传入任意多个权限名,中间用逗号隔开即可。
最后调用request()方法来执行权限申请,并在Lambda表达式中处理申请结果。可以看到,Lambda表达式中有3个参数:allGranted表示是否所有申请的权限都已被授权,grantedList用于记录所有已被授权的权限,deniedList用于记录所有被拒绝的权限。
对比原来的方式,简单明了许多有木有!!!!

其他情况处理

1、假如用户拒绝了某个权限,在下次申请之前,我们最好弹出一个对话框来向用户解释申请这个权限的原因,这个又该怎么实现呢?
onExplainRequestReason()方法可以用于监听那些被用户拒绝,而又可以再次去申请的权限。
而我们只需要将onExplainRequestReason()方法串接到request()方法之前即可,如下所示:

PermissionX.init(this)
.permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
.onExplainRequestReason { deniedList ->
}
.request { allGranted, grantedList, deniedList ->
    if (allGranted) {
        Toast.makeText(this, "所有申请的权限都已通过", Toast.LENGTH_SHORT).show()
    } else {
        Toast.makeText(this, "您拒绝了如下权限:$deniedList", Toast.LENGTH_SHORT).show()
    }
}

这种情况下,所有被用户拒绝的权限会优先进入onExplainRequestReason()方法进行处理,拒绝的权限都记录在deniedList参数当中。接下来,我们只需要在这个方法中调用showRequestReasonDialog()方法,即可弹出解释权限申请原因的对话框

PermissionX.init(this)
.permissions(Manifest.permission.CAMERA, Manifest.permission.READ_CONTACTS, Manifest.permission.CALL_PHONE)
.onExplainRequestReason { deniedList ->
    showRequestReasonDialog(deniedList, "即将重新申请的权限是程序必须依赖的权限", "我已明白", "取消")
}
.request { allGranted, grantedList, deniedList ->
    if (allGranted) {
        Toast.makeText(this, "所有申请的权限都已通过", Toast.LENGTH_SHORT).show()
    } else {
        Toast.makeText(this, "您拒绝了如下权限:$deniedList", Toast.LENGTH_SHORT).show()
    }
}

showRequestReasonDialog()方法接受4个参数:第一个参数是要重新申请的权限列表,这里直接将deniedList参数传入。第二个参数则是要向用户解释的原因,我只是随便写了一句话,这个参数描述的越详细越好。第三个参数是对话框上确定按钮的文字,点击该按钮后将会重新执行权限申请操作。第四个参数是一个可选参数,如果不传的话相当于用户必须同意申请的这些权限,否则对话框无法关闭,而如果传入的话,对话框上会有一个取消按钮,点击取消后不会重新进行权限申请,而是会把当前的申请结果回调到request()方法当中。
具体的功能给你个地址自己看:师傅领进门修行在个人

  • 21
    点赞
  • 76
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值