Fork me on GitHub

Android 6.0 动态权限申请与 7.0 适配 File Uri 小记

写在前面

看了下上一篇文章的写作日期,转眼之间已经过去了大半个月了……一个国庆小长假下来也是放松了不少,不过学习也不能过于疏忽了。以前偷懒没有看适配6.0 和 7.0的东西,最近在下也是正式的抛弃了大三时买的ip6,入了一台小米mix2。mix2是基于Android 7.1的系统的,自己平时也喜欢用自己的手机调试应用,各种没有适配导致的崩溃自然也是免不了的。以前没看权限适配一是因为6.0以上的系统覆盖率不是很高,二也是因为那时候要看的东西很多,这东西不是很急迫,现在各个厂商的手机出厂系统都是6.0以上的了,也是时候看一下了。本文包括以下内容:

  • Android 6.0 动态权限申请
  • RxPermission
  • Android 7.0 File Uri 导致的崩溃以及如何适配

Android 6.0 动态权限申请

这里先推荐一波官方的文档:https://developer.android.com/training/permissions/requesting.html#perm-request,文档讲的还是比较详尽的,爱自己折腾的同学(比如我)看到这应该say goodbye了

再见

扯犊子时间到此为止,继续正文。系统权限分为几个保护级别,这里需要了解的是两个保护级别是正常权限和危险权限。正常权限只要应用声明了,系统就会自动给应用该权限。而危险权限在Android 6.0 及以上时,则需要通过动态申请来获取权限。当然,考虑到一些兼容性问题,项目的 targetSdkVersion <= 22 时,并不需要动态申请权限。但适配是早晚要去适配的。。。躲也躲不掉,还是先了解下为妙。任何权限都可以属于一个权限组,危险权限也有自己的组别,当你请求了某组权限中的某个权限成功,那么该组的其他权限系统也将授予。比如申请了 STORAGE 权限组的 READ_EXTERNAL_STORAGE 权限,那么该组的 WRITE_EXTERNAL_STORAGE 权限在使用时就无需申请了。接下来放一张危险权限组及危险权限的 截图 ,markdown 制表还是挺麻烦的……偷个懒

危险权限组及危险权限

动态权限申请实操

前面简介写完了,下面开始实操,申请权限主要分为以下几个步骤:

  • 检查是否拥有权限
  • 如果以前用户拒绝过,提示
  • 申请权限
  • 在回调中查看是否申请成功

首先是检查和申请权限,虽然步骤是以上所述,但是代码比较简单,就不一一拆开了,代码中的注释都比较详细。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
int permission = ContextCompat.checkSelfPermission(MainActivity.this,
Manifest.permission.CAMERA);
if (permission == PackageManager.PERMISSION_GRANTED) {
// 有此权限
Toast.makeText(MainActivity.this, "已经具有该权限,无需再申请", Toast.LENGTH_SHORT).show();
} else {
// 无此权限,申请权限
if (ActivityCompat.shouldShowRequestPermissionRationale(MainActivity.this,
Manifest.permission.CAMERA)) {
// 如果之前请求过权限但用户拒绝了请求
Toast.makeText(MainActivity.this, "请求相机权限,将用于拍照", Toast.LENGTH_SHORT).show();
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
ActivityCompat.requestPermissions(MainActivity.this,
new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA);
}
}, 500);
} else {
// 请求权限
ActivityCompat.requestPermissions(MainActivity.this,
new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA);
}
}


// 回调
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
switch (requestCode) {
case REQUEST_CAMERA:
// 如果请求被取消了,result数组将是空的
if (grantResults.length > 0 &&
grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// 成功申请该权限
Toast.makeText(this, "成功申请相机权限", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, "您已拒绝该权限,无法使用相机功能 GG,请手动打开该权限", Toast.LENGTH_SHORT).show();
}
break;

default:
break;
}
}

上面的 shouldShowRequestPermissionRationale 方法会返回一个布尔值,这个方法返回值的规则如下:

  • 如果之前请求过此权限但用户拒绝了请求,将返回 true
  • 如果用户在过去拒绝了请求,并在权限请求系统对话框中选择了Don’t ask again,此方法将返回false。
  • 如果设备规范禁止应用具有该权限,返回false

我这个代码的基本流程和文档中是一致的,后来在测试的时候发现第一次请求也会走false那个选项,如果我拒绝了这个权限,后续就不会再有权限申请弹窗了,在申请权限的时候系统会自动拒绝。所以我觉得正确的做法应该是在回调中判断权限被拒绝时使用shouldShowRequestPermissionRationale方法,如果返回false则打开设置界面让用户去打开权限。当然,由于国产手机的各种系统定制。。。打开权限设置界面可能并不是一个非常轻松的过程……需要适配……这里就不做这件事了。

RxPermission

上面的权限申请相对来说还是比较繁琐的,接下来介绍一下三方RxPermission,从Rx这俩字你应该可以看出来,这玩意是需要依赖RxJava的。不过对于现在的应用开发来说,RxJava,Okhttp几乎都是标配了,所以说我觉得问题不大,依赖如下:

1
2
3
4
5
6
// rxpermission
compile 'com.tbruyelle.rxpermissions2:rxpermissions:0.9.4@aar'
// rxjava
compile 'io.reactivex.rxjava2:rxjava:2.1.2'
// rxandroid
compile 'io.reactivex.rxjava2:rxandroid:2.0.1'

申请权限代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
RxPermissions rxPermissions = new RxPermissions(MainActivity.this);
rxPermissions.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.subscribe(new Observer<Boolean>() {
@Override
public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) {

}

@Override
public void onNext(@io.reactivex.annotations.NonNull Boolean isGranted) {
if (isGranted) {
Toast.makeText(MainActivity.this, "权限申请成功", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(MainActivity.this, "权限申请失败,用户拒绝了此权限", Toast.LENGTH_SHORT).show();
}
}

@Override
public void onError(@io.reactivex.annotations.NonNull Throwable e) {
e.printStackTrace();
Toast.makeText(MainActivity.this, "权限申请失败,代码异常", Toast.LENGTH_SHORT).show();
}

@Override
public void onComplete() {

}
});

当然了,如果你希望在权限被拒绝时进行进一步的操作,可以使用如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
rxPermissions
.requestEach(Manifest.permission.CAMERA,
Manifest.permission.READ_PHONE_STATE)
.subscribe(permission -> { // will emit 2 Permission objects
if (permission.granted) {
// `permission.name` is granted !
} else if (permission.shouldShowRequestPermissionRationale)
// Denied permission without ask never again
} else {
// Denied permission with ask never again
// Need to go to the settings
}
});

恩,是的,下面一段代码是从仓库上复制来的。。。好了,不要在意这些细节,接着看一下关于File uri 在 Android N 上引发异常的适配。

7.0 适配 File Uri

这里拿拍照作为例子,平时我们调用系统相机实现拍照功能代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
    if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
Toast.makeText(MainActivity.this, "SD卡状态异常", Toast.LENGTH_SHORT).show();
return;
}
long dateTaken = System.currentTimeMillis();
// 图像名称
CharSequence fileName = DateFormat.format("yyyy-MM-dd kk.mm.ss", dateTaken);
// 图像路径
String path = Environment.getExternalStorageDirectory().toString() +
File.separator + "forpermission" + File.separator + fileName + ".jpg";
File imageFile = new File(path);
if (!imageFile.getParentFile().exists()) {
imageFile.getParentFile().mkdirs();
}
if (!imageFile.exists()) try {
imageFile.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}

// 根据文件解析出文件对应的Uri
Uri uri = Uri.fromFile(imageFile);
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
// 判断是否有 Activity 能处理 intent
if (intent.resolveActivity(getPackageManager()) != null) {
startActivityForResult(intent, REQUEST_TAKE_PHOTO);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);

if(resultCode == RESULT_OK){
if(requestCode == REQUEST_TAKE_PHOTO){
Toast.makeText(this, "拍照成功", Toast.LENGTH_SHORT).show();
}
}
}

在 7.0 以前运行这段代码是没问题的,那么在 7.0 之后呢?

异常

抛出了 FileUriExposedException 异常,恩,直接crash,心里相想必有一万头草泥马奔腾而过吧。解释官网上也有,Android 7.0 行为变更,中这段话描述了原因:

原因

行,你说用什么就用什么。接下来先详细的记录一下操作过程,之后再解释一下细节。首先在清单文件中声明:

1
2
3
4
5
6
7
8
9
10
11
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="com.xiasuhuei321.studyforpermission.takePhotoN"
android:exported="false"
android:grantUriPermissions="true">

<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>

</provider>

接下来新建xml包并新建file_paths xml文件:

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8"?>
<paths>
<root-path
name="root"
path="."/>

<external-files-path
name="camera_photo"
path="/storage/emulated/0/forpermission/" />
</paths>

拍照代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
Toast.makeText(MainActivity.this, "SD卡状态异常", Toast.LENGTH_SHORT).show();
return;
}
long dateTaken = System.currentTimeMillis();
// 图像名称
CharSequence fileName = DateFormat.format("yyyy-MM-dd kk.mm.ss", dateTaken);
// 图像路径
String path = Environment.getExternalStorageDirectory().toString() +
File.separator + "forpermission" + File.separator + fileName + ".jpg";
File imageFile = new File(path);
if (!imageFile.getParentFile().exists()) {
imageFile.getParentFile().mkdirs();
}
if (!imageFile.exists()) try {
imageFile.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}

// 根据文件解析出文件对应的Uri
Uri uri = FileProvider.getUriForFile(MainActivity.this,
"com.xiasuhuei321.studyforpermission.takePhotoN", imageFile);
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
// 判断是否有 Activity 能处理 intent
if (intent.resolveActivity(getPackageManager()) != null) {
startActivityForResult(intent, REQUEST_TAKE_PHOTO);
}

恩,留个小坑,操作流程写了,余下的具体解析待填。
好了,决定不填了,就是这么任性,哈哈哈哈哈哈哈。